Added JQueryQueryStringValueProviderFactory and JQueryQueryStringValueProvider

[Fixes #6372] jQuery ajax request with complex data does not work with .net core 1.1 model bindings
This commit is contained in:
Kiran Challa 2018-03-05 14:15:42 -08:00
parent 4acdebc5be
commit a952313f1c
13 changed files with 575 additions and 164 deletions

View File

@ -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.Collections.Generic;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
@ -12,11 +10,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
/// <summary>
/// An <see cref="IValueProvider"/> for jQuery formatted form data.
/// </summary>
public class JQueryFormValueProvider : BindingSourceValueProvider, IEnumerableValueProvider
public class JQueryFormValueProvider : JQueryValueProvider
{
private readonly IDictionary<string, StringValues> _values;
private PrefixContainer _prefixContainer;
/// <summary>
/// Initializes a new instance of the <see cref="JQueryFormValueProvider"/> class.
/// </summary>
@ -27,60 +22,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
BindingSource bindingSource,
IDictionary<string, StringValues> values,
CultureInfo culture)
: base(bindingSource)
: base(bindingSource, values, culture)
{
if (bindingSource == null)
{
throw new ArgumentNullException(nameof(bindingSource));
}
if (values == null)
{
throw new ArgumentNullException(nameof(values));
}
_values = values;
Culture = culture;
}
// Internal for testing
internal CultureInfo Culture { get; }
protected PrefixContainer PrefixContainer
{
get
{
if (_prefixContainer == null)
{
_prefixContainer = new PrefixContainer(_values.Keys);
}
return _prefixContainer;
}
}
/// <inheritdoc />
public override bool ContainsPrefix(string prefix)
{
return PrefixContainer.ContainsPrefix(prefix);
}
/// <inheritdoc />
public IDictionary<string, string> GetKeysFromPrefix(string prefix)
{
return PrefixContainer.GetKeysFromPrefix(prefix);
}
/// <inheritdoc />
public override ValueProviderResult GetValue(string key)
{
StringValues values;
if (_values.TryGetValue(key, out values) && values.Count > 0)
{
return new ValueProviderResult(values, Culture);
}
return ValueProviderResult.None;
}
}
}
}

View File

@ -2,18 +2,13 @@
// 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.Globalization;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Core;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
/// <summary>
/// A <see cref="IValueProviderFactory"/> for <see cref="JQueryFormValueProvider"/>.
/// An <see cref="IValueProviderFactory"/> for <see cref="JQueryFormValueProvider"/>.
/// </summary>
public class JQueryFormValueProviderFactory : IValueProviderFactory
{
@ -38,97 +33,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
private static async Task AddValueProviderAsync(ValueProviderFactoryContext context)
{
var request = context.ActionContext.HttpContext.Request;
var formCollection = await request.ReadFormAsync();
var valueProvider = new JQueryFormValueProvider(
BindingSource.Form,
await GetValueCollectionAsync(request),
JQueryKeyValuePairNormalizer.GetValues(formCollection, formCollection.Count),
CultureInfo.CurrentCulture);
context.ValueProviders.Add(valueProvider);
}
private static async Task<IDictionary<string, StringValues>> GetValueCollectionAsync(HttpRequest request)
{
var formCollection = await request.ReadFormAsync();
var builder = new StringBuilder();
var dictionary = new Dictionary<string, StringValues>(
formCollection.Count,
StringComparer.OrdinalIgnoreCase);
foreach (var entry in formCollection)
{
var key = NormalizeJQueryToMvc(builder, entry.Key);
builder.Clear();
dictionary[key] = entry.Value;
}
return dictionary;
}
// This is a helper method for Model Binding over a JQuery syntax.
// Normalize from JQuery to MVC keys. The model binding infrastructure uses MVC keys.
// x[] --> x
// [] --> ""
// x[12] --> x[12]
// x[field] --> x.field, where field is not a number
private static string NormalizeJQueryToMvc(StringBuilder builder, string key)
{
if (string.IsNullOrEmpty(key))
{
return string.Empty;
}
var indexOpen = key.IndexOf('[');
if (indexOpen == -1)
{
// Fast path, no normalization needed.
// This skips string conversion and allocating the string builder.
return key;
}
var position = 0;
while (position < key.Length)
{
if (indexOpen == -1)
{
// No more brackets.
builder.Append(key, position, key.Length - position);
break;
}
builder.Append(key, position, indexOpen - position); // everything up to "["
// Find closing bracket.
var indexClose = key.IndexOf(']', indexOpen);
if (indexClose == -1)
{
throw new ArgumentException(
message: Resources.FormatJQueryFormValueProviderFactory_MissingClosingBracket(key),
paramName: nameof(key));
}
if (indexClose == indexOpen + 1)
{
// Empty brackets signify an array. Just remove.
}
else if (char.IsDigit(key[indexOpen + 1]))
{
// Array index. Leave unchanged.
builder.Append(key, indexOpen, indexClose - indexOpen + 1);
}
else
{
// Field name. Convert to dot notation.
builder.Append('.');
builder.Append(key, indexOpen + 1, indexClose - indexOpen - 1);
}
position = indexClose + 1;
indexOpen = key.IndexOf('[', position);
}
return builder.ToString();
}
}
}

View File

@ -0,0 +1,100 @@
// 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.Collections.Generic;
using System.Text;
using Microsoft.AspNetCore.Mvc.Core;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
// Normalizes keys, in a keyvaluepair collection, from jQuery format to a format that MVC understands.
internal static class JQueryKeyValuePairNormalizer
{
public static IDictionary<string, StringValues> GetValues(
IEnumerable<KeyValuePair<string, StringValues>> originalValues,
int valueCount)
{
var builder = new StringBuilder();
var dictionary = new Dictionary<string, StringValues>(
valueCount,
StringComparer.OrdinalIgnoreCase);
foreach (var originalValue in originalValues)
{
var normalizedKey = NormalizeJQueryToMvc(builder, originalValue.Key);
builder.Clear();
dictionary[normalizedKey] = originalValue.Value;
}
return dictionary;
}
// This is a helper method for Model Binding over a JQuery syntax.
// Normalize from JQuery to MVC keys. The model binding infrastructure uses MVC keys.
// x[] --> x
// [] --> ""
// x[12] --> x[12]
// x[field] --> x.field, where field is not a number
private static string NormalizeJQueryToMvc(StringBuilder builder, string key)
{
if (string.IsNullOrEmpty(key))
{
return string.Empty;
}
var indexOpen = key.IndexOf('[');
if (indexOpen == -1)
{
// Fast path, no normalization needed.
// This skips string conversion and allocating the string builder.
return key;
}
var position = 0;
while (position < key.Length)
{
if (indexOpen == -1)
{
// No more brackets.
builder.Append(key, position, key.Length - position);
break;
}
builder.Append(key, position, indexOpen - position); // everything up to "["
// Find closing bracket.
var indexClose = key.IndexOf(']', indexOpen);
if (indexClose == -1)
{
throw new ArgumentException(
message: Resources.FormatJQueryFormValueProviderFactory_MissingClosingBracket(key),
paramName: nameof(key));
}
if (indexClose == indexOpen + 1)
{
// Empty brackets signify an array. Just remove.
}
else if (char.IsDigit(key[indexOpen + 1]))
{
// Array index. Leave unchanged.
builder.Append(key, indexOpen, indexClose - indexOpen + 1);
}
else
{
// Field name. Convert to dot notation.
builder.Append('.');
builder.Append(key, indexOpen + 1, indexClose - indexOpen - 1);
}
position = indexClose + 1;
indexOpen = key.IndexOf('[', position);
}
return builder.ToString();
}
}
}

View File

@ -0,0 +1,29 @@
// 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.Collections.Generic;
using System.Globalization;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
/// <summary>
/// An <see cref="IValueProvider"/> for jQuery formatted query string data.
/// </summary>
public class JQueryQueryStringValueProvider : JQueryValueProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="JQueryQueryStringValueProvider"/> class.
/// </summary>
/// <param name="bindingSource">The <see cref="BindingSource"/> of the data.</param>
/// <param name="values">The values.</param>
/// <param name="culture">The culture to return with ValueProviderResult instances.</param>
public JQueryQueryStringValueProvider(
BindingSource bindingSource,
IDictionary<string, StringValues> values,
CultureInfo culture)
: base(bindingSource, values, culture)
{
}
}
}

View File

@ -0,0 +1,37 @@
// 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.Globalization;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
/// <summary>
/// An <see cref="IValueProviderFactory"/> for <see cref="JQueryQueryStringValueProvider"/>.
/// </summary>
public class JQueryQueryStringValueProviderFactory : IValueProviderFactory
{
/// <inheritdoc />
public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var query = context.ActionContext.HttpContext.Request.Query;
if (query != null && query.Count > 0)
{
var valueProvider = new JQueryQueryStringValueProvider(
BindingSource.Query,
JQueryKeyValuePairNormalizer.GetValues(query, query.Count),
CultureInfo.InvariantCulture);
context.ValueProviders.Add(valueProvider);
}
return Task.CompletedTask;
}
}
}

View File

@ -0,0 +1,89 @@
// 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.Collections.Generic;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
/// <summary>
/// An <see cref="IValueProvider"/> for jQuery formatted form data.
/// </summary>
public abstract class JQueryValueProvider : BindingSourceValueProvider, IEnumerableValueProvider
{
private readonly IDictionary<string, StringValues> _values;
private PrefixContainer _prefixContainer;
/// <summary>
/// Initializes a new instance of the <see cref="JQueryValueProvider"/> class.
/// </summary>
/// <param name="bindingSource">The <see cref="BindingSource"/> of the data.</param>
/// <param name="values">The values.</param>
/// <param name="culture">The culture to return with ValueProviderResult instances.</param>
protected JQueryValueProvider(
BindingSource bindingSource,
IDictionary<string, StringValues> values,
CultureInfo culture)
: base(bindingSource)
{
if (bindingSource == null)
{
throw new ArgumentNullException(nameof(bindingSource));
}
if (values == null)
{
throw new ArgumentNullException(nameof(values));
}
_values = values;
Culture = culture;
}
/// <summary>
/// Gets the <see cref="CultureInfo"/> associated with the values.
/// </summary>
public CultureInfo Culture { get; }
/// <inheritdoc />
protected PrefixContainer PrefixContainer
{
get
{
if (_prefixContainer == null)
{
_prefixContainer = new PrefixContainer(_values.Keys);
}
return _prefixContainer;
}
}
/// <inheritdoc />
public override bool ContainsPrefix(string prefix)
{
return PrefixContainer.ContainsPrefix(prefix);
}
/// <inheritdoc />
public IDictionary<string, string> GetKeysFromPrefix(string prefix)
{
return PrefixContainer.GetKeysFromPrefix(prefix);
}
/// <inheritdoc />
public override ValueProviderResult GetValue(string key)
{
StringValues values;
if (_values.TryGetValue(key, out values) && values.Count > 0)
{
return new ValueProviderResult(values, Culture);
}
return ValueProviderResult.None;
}
}
}

View File

@ -21,12 +21,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
throw new ArgumentNullException(nameof(context));
}
var valueProvider = new QueryStringValueProvider(
BindingSource.Query,
context.ActionContext.HttpContext.Request.Query,
CultureInfo.InvariantCulture);
var query = context.ActionContext.HttpContext.Request.Query;
if (query != null && query.Count > 0)
{
var valueProvider = new QueryStringValueProvider(
BindingSource.Query,
query,
CultureInfo.InvariantCulture);
context.ValueProviders.Add(valueProvider);
context.ValueProviders.Add(valueProvider);
}
return Task.CompletedTask;
}

View File

@ -28,5 +28,9 @@
"TypeId": "public class Microsoft.AspNetCore.Mvc.ModelBinding.Binders.BodyModelBinderProvider : Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinderProvider",
"MemberId": "public .ctor(System.Collections.Generic.IList<Microsoft.AspNetCore.Mvc.Formatters.IInputFormatter> formatters, Microsoft.AspNetCore.Mvc.Internal.IHttpRequestStreamReaderFactory readerFactory, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.AspNetCore.Mvc.MvcOptions options)",
"Kind": "Removal"
},
{
"TypeId": "public class Microsoft.AspNetCore.Mvc.ModelBinding.JQueryFormValueProvider : Microsoft.AspNetCore.Mvc.ModelBinding.BindingSourceValueProvider, Microsoft.AspNetCore.Mvc.ModelBinding.IEnumerableValueProvider",
"Kind": "Removal"
}
]

View File

@ -116,6 +116,22 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Test
Assert.Equal("found", (string)result);
}
[Fact]
public async Task CreatesValueProvider_WithCurrentCulture()
{
// Arrange
var context = CreateContext("application/x-www-form-urlencoded", formValues: _backingStore);
var factory = new JQueryFormValueProviderFactory();
// Act
await factory.CreateValueProviderAsync(context);
// Assert
var valueProvider = Assert.Single(context.ValueProviders);
var jqueryFormValueProvider = Assert.IsType<JQueryFormValueProvider>(valueProvider);
Assert.Equal(CultureInfo.CurrentCulture, jqueryFormValueProvider.Culture);
}
private static ValueProviderFactoryContext CreateContext(string contentType, Dictionary<string, StringValues> formValues)
{
var context = new DefaultHttpContext();

View File

@ -0,0 +1,127 @@
// 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.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Primitives;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Test
{
public class JQueryQueryStringValueProviderFactoryTest
{
private static readonly Dictionary<string, StringValues> _backingStore = new Dictionary<string, StringValues>
{
{ "[]", new[] { "found" } },
{ "[]property1", new[] { "found" } },
{ "property2[]", new[] { "found" } },
{ "[]property3[]", new[] { "found" } },
{ "property[]Value", new[] { "found" } },
{ "[10]", new[] { "found" } },
{ "[11]property", new[] { "found" } },
{ "property4[10]", new[] { "found" } },
{ "[12]property[][13]", new[] { "found" } },
{ "[14][]property1[15]property2", new[] { "found" } },
{ "prefix[11]property1", new[] { "found" } },
{ "prefix[12][][property2]", new[] { "found" } },
{ "prefix[property1][13]", new[] { "found" } },
{ "prefix[14][][15]", new[] { "found" } },
{ "[property5][]", new[] { "found" } },
{ "[][property6]Value", new[] { "found" } },
{ "prefix[property2]", new[] { "found" } },
{ "prefix[][property]Value", new[] { "found" } },
{ "[property7][property8]", new[] { "found" } },
{ "[property9][][property10]Value", new[] { "found" } },
};
public static TheoryData<string> SuccessDataSet
{
get
{
return new TheoryData<string>
{
string.Empty,
"property1",
"property2",
"property3",
"propertyValue",
"[10]",
"[11]property",
"property4[10]",
"[12]property[13]",
"[14]property1[15]property2",
"prefix.property1[13]",
"prefix[14][15]",
".property5",
".property6Value",
"prefix.property2",
"prefix.propertyValue",
".property7.property8",
".property9.property10Value",
};
}
}
[Theory]
[MemberData(nameof(SuccessDataSet))]
public async Task GetValueProvider_ReturnsValueProvider_ContainingExpectedKeys(string key)
{
// Arrange
var context = CreateContext(_backingStore);
var factory = new JQueryQueryStringValueProviderFactory();
// Act
await factory.CreateValueProviderAsync(context);
// Assert
var valueProvider = Assert.Single(context.ValueProviders);
var result = valueProvider.GetValue(key);
Assert.Equal("found", (string)result);
}
[Fact]
public async Task DoesNotCreateValueProvider_WhenQueryIsEmpty()
{
// Arrange
var context = CreateContext(new Dictionary<string, StringValues>());
var factory = new JQueryQueryStringValueProviderFactory();
// Act
await factory.CreateValueProviderAsync(context);
// Assert
Assert.Empty(context.ValueProviders);
}
[Fact]
public async Task CreatesValueProvider_WithInvariantCulture()
{
// Arrange
var context = CreateContext(_backingStore);
var factory = new JQueryQueryStringValueProviderFactory();
// Act
await factory.CreateValueProviderAsync(context);
// Assert
var valueProvider = Assert.Single(context.ValueProviders);
var jqueryQueryStringValueProvider = Assert.IsType<JQueryQueryStringValueProvider>(valueProvider);
Assert.Equal(CultureInfo.InvariantCulture, jqueryQueryStringValueProvider.Culture);
}
private static ValueProviderFactoryContext CreateContext(Dictionary<string, StringValues> queryStringValues)
{
var context = new DefaultHttpContext();
context.Request.Query = new QueryCollection(queryStringValues);
var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor());
return new ValueProviderFactoryContext(actionContext);
}
}
}

View File

@ -0,0 +1,20 @@
// 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.Collections.Generic;
using System.Globalization;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
public class JQueryQueryStringValueProviderTest : EnumerableValueProviderTest
{
protected override IEnumerableValueProvider GetEnumerableValueProvider(
BindingSource bindingSource,
Dictionary<string, StringValues> values,
CultureInfo culture)
{
return new JQueryQueryStringValueProvider(bindingSource, values, culture);
}
}
}

View File

@ -5,25 +5,40 @@ using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Routing;
using Moq;
using Microsoft.Extensions.Primitives;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Test
{
public class QueryStringValueProviderFactoryTest
{
[Fact]
public async Task DoesNotCreateValueProvider_WhenQueryStringIsEmpty()
{
// Arrange
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
var factoryContext = new ValueProviderFactoryContext(actionContext);
var factory = new QueryStringValueProviderFactory();
// Act
await factory.CreateValueProviderAsync(factoryContext);
// Assert
Assert.Empty(factoryContext.ValueProviders);
}
[Fact]
public async Task GetValueProvider_ReturnsQueryStringValueProviderInstanceWithInvariantCulture()
{
// Arrange
var request = new Mock<HttpRequest>();
request.SetupGet(f => f.Query).Returns(Mock.Of<IQueryCollection>());
var context = new Mock<HttpContext>();
context.SetupGet(c => c.Items).Returns(new Dictionary<object, object>());
context.SetupGet(c => c.Request).Returns(request.Object);
var actionContext = new ActionContext(context.Object, new RouteData(), new ActionDescriptor());
var queryValues = new Dictionary<string, StringValues>();
queryValues.Add("foo", "bar");
var context = new DefaultHttpContext();
context.Request.Query = new QueryCollection(queryValues);
var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor());
var factoryContext = new ValueProviderFactoryContext(actionContext);
var factory = new QueryStringValueProviderFactory();

View File

@ -0,0 +1,114 @@
// 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.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.IntegrationTests
{
public class JQueryFormatModelBindingIntegrationTest
{
[Fact]
public async Task BindsJQueryFormatData_FromQuery()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor()
{
Name = "parameter",
ParameterType = typeof(Customer)
};
var testContext = ModelBindingTestHelper.GetTestContext(
request =>
{
request.QueryString = new QueryString(
"?Name=James&Address[0][City]=Redmond&Address[0][State][ShortName]=WA&Address[0][State][LongName]=Washington");
},
options =>
{
options.ValueProviderFactories.Add(new JQueryQueryStringValueProviderFactory());
});
var modelState = testContext.ModelState;
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(modelBindingResult.IsModelSet);
var model = Assert.IsType<Customer>(modelBindingResult.Model);
Assert.Equal("James", model.Name);
Assert.NotNull(model.Address);
var address = Assert.Single(model.Address);
Assert.Equal("Redmond", address.City);
Assert.NotNull(address.State);
Assert.Equal("WA", address.State.ShortName);
Assert.Equal("Washington", address.State.LongName);
Assert.True(modelState.IsValid);
}
[Fact]
public async Task BindsJQueryFormatData_FromRequestBody()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor()
{
Name = "parameter",
ParameterType = typeof(Customer)
};
var testContext = ModelBindingTestHelper.GetTestContext(
request =>
{
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(
"Name=James&Address[0][City]=Redmond&Address[0][State][ShortName]=WA&Address[0][State][LongName]=Washington"));
request.ContentType = "application/x-www-form-urlencoded;charset=utf-8";
});
var modelState = testContext.ModelState;
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(modelBindingResult.IsModelSet);
var model = Assert.IsType<Customer>(modelBindingResult.Model);
Assert.Equal("James", model.Name);
Assert.NotNull(model.Address);
var address = Assert.Single(model.Address);
Assert.Equal("Redmond", address.City);
Assert.NotNull(address.State);
Assert.Equal("WA", address.State.ShortName);
Assert.Equal("Washington", address.State.LongName);
Assert.True(modelState.IsValid);
}
private class Customer
{
public string Name { get; set; }
public List<Address> Address { get; set; }
}
private class Address
{
public string City { get; set; }
public State State { get; set; }
}
private class State
{
public string ShortName { get; set; }
public string LongName { get; set; }
}
}
}