SerializerSettings refactor

Add SerializerSettings to MvcOptions and pass those options to the JsonInputFormatter and JsonOutputFormatter.
Remove custom contract resolver.

PR feedback
Pass JsonSerializerSettings to JsonPatchInputFormatter

PR feedback
Make DI JsonOutputFormatter formatter use MvcOptions SerializerSettings

Fix JsonPatchInputFormatter using null ContractResolver

Fix tests
This commit is contained in:
James Newton-King 2015-05-09 06:25:14 +00:00 committed by Doug Bunting
parent 3fbead0ce8
commit de630754bf
19 changed files with 142 additions and 379 deletions

View File

@ -43,7 +43,7 @@ namespace Microsoft.AspNet.Mvc
/// <param name="serializerSettings">The <see cref="JsonSerializerSettings"/> to be used by
/// the formatter.</param>
public JsonResult(object value, [NotNull] JsonSerializerSettings serializerSettings)
: this(value, formatter: new JsonOutputFormatter { SerializerSettings = serializerSettings })
: this(value, formatter: new JsonOutputFormatter(serializerSettings))
{
}

View File

@ -1,43 +0,0 @@
// 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.ComponentModel.DataAnnotations;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Microsoft.AspNet.Mvc
{
/// <summary>
/// The default <see cref="IContractResolver"/> for <see cref="JsonInputFormatter"/>.
/// It determines if a value type member has <see cref="RequiredAttribute"/> and sets the appropriate
/// JsonProperty settings.
/// </summary>
public class JsonContractResolver : DefaultContractResolver
{
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
var property = base.CreateProperty(member, memberSerialization);
var required = member.GetCustomAttribute(typeof(RequiredAttribute), inherit: true);
if (required != null)
{
var propertyType = ((PropertyInfo)member).PropertyType;
// DefaultObjectValidator does required attribute validation on properties based on the property
// value being null. Since this is not possible in case of value types, we depend on the formatters
// to handle value type validation.
// With the following settings here, if a value is not present on the wire for value types
// like primitive, struct etc., Json.net's serializer would throw exception which we catch
// and add it to model state.
if (propertyType.GetTypeInfo().IsValueType && !TypeHelper.IsNullableValueType(propertyType))
{
property.Required = Required.AllowNull;
}
}
return property;
}
}
}

View File

@ -5,6 +5,7 @@ using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNet.Mvc.Core.Internal;
using Microsoft.Framework.Internal;
using Microsoft.Net.Http.Headers;
using Newtonsoft.Json;
@ -13,31 +14,22 @@ namespace Microsoft.AspNet.Mvc
{
public class JsonInputFormatter : InputFormatter
{
private const int DefaultMaxDepth = 32;
private JsonSerializerSettings _jsonSerializerSettings;
private JsonSerializerSettings _serializerSettings;
public JsonInputFormatter()
: this(SerializerSettingsProvider.CreateSerializerSettings())
{
}
public JsonInputFormatter([NotNull] JsonSerializerSettings serializerSettings)
{
_serializerSettings = serializerSettings;
SupportedEncodings.Add(Encodings.UTF8EncodingWithoutBOM);
SupportedEncodings.Add(Encodings.UTF16EncodingLittleEndian);
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json"));
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/json"));
_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
};
_jsonSerializerSettings.ContractResolver = new JsonContractResolver();
}
/// <summary>
@ -45,15 +37,14 @@ namespace Microsoft.AspNet.Mvc
/// </summary>
public JsonSerializerSettings SerializerSettings
{
get { return _jsonSerializerSettings; }
get
{
return _serializerSettings;
}
[param: NotNull]
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_jsonSerializerSettings = value;
_serializerSettings = value;
}
}

View File

@ -4,6 +4,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNet.Mvc.Core.Internal;
using Microsoft.AspNet.Mvc.Internal;
using Microsoft.Framework.Internal;
using Microsoft.Net.Http.Headers;
@ -16,13 +17,18 @@ namespace Microsoft.AspNet.Mvc
private JsonSerializerSettings _serializerSettings;
public JsonOutputFormatter()
: this(SerializerSettingsProvider.CreateSerializerSettings())
{
}
public JsonOutputFormatter([NotNull] JsonSerializerSettings serializerSettings)
{
_serializerSettings = serializerSettings;
SupportedEncodings.Add(Encodings.UTF8EncodingWithoutBOM);
SupportedEncodings.Add(Encodings.UTF16EncodingLittleEndian);
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json"));
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/json"));
_serializerSettings = new JsonSerializerSettings();
}
/// <summary>
@ -30,14 +36,13 @@ namespace Microsoft.AspNet.Mvc
/// </summary>
public JsonSerializerSettings SerializerSettings
{
get { return _serializerSettings; }
get
{
return _serializerSettings;
}
[param: NotNull]
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_serializerSettings = value;
}
}

View File

@ -6,12 +6,20 @@ using System.Threading.Tasks;
using Microsoft.AspNet.JsonPatch;
using Microsoft.Framework.Internal;
using Microsoft.Net.Http.Headers;
using Microsoft.AspNet.Mvc.Core.Internal;
using Newtonsoft.Json;
namespace Microsoft.AspNet.Mvc
{
public class JsonPatchInputFormatter : JsonInputFormatter
{
public JsonPatchInputFormatter()
: this(SerializerSettingsProvider.CreateSerializerSettings())
{
}
public JsonPatchInputFormatter([NotNull] JsonSerializerSettings serializerSettings)
: base(serializerSettings)
{
// Clear all values and only include json-patch+json value.
SupportedMediaTypes.Clear();
@ -23,7 +31,7 @@ namespace Microsoft.AspNet.Mvc
public async override Task<object> ReadRequestBodyAsync([NotNull] InputFormatterContext context)
{
var jsonPatchDocument = (IJsonPatchDocument)(await base.ReadRequestBodyAsync(context));
if (jsonPatchDocument != null)
if (jsonPatchDocument != null && SerializerSettings.ContractResolver != null)
{
jsonPatchDocument.ContractResolver = SerializerSettings.ContractResolver;
}

View File

@ -0,0 +1,35 @@
// 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 Newtonsoft.Json;
namespace Microsoft.AspNet.Mvc.Core.Internal
{
/// <summary>
/// Helper class which provides <see cref="JsonSerializerSettings"/>.
/// </summary>
internal static class SerializerSettingsProvider
{
private const int DefaultMaxDepth = 32;
/// <summary>
/// Creates default <see cref="JsonSerializerSettings"/>.
/// </summary>
/// <returns>Default <see cref="JsonSerializerSettings"/>.</returns>
public static JsonSerializerSettings CreateSerializerSettings()
{
return 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
};
}
}
}

View File

@ -5,9 +5,11 @@ using System;
using System.Collections.Generic;
using Microsoft.AspNet.Mvc.ApplicationModels;
using Microsoft.AspNet.Mvc.Core;
using Microsoft.AspNet.Mvc.Core.Internal;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.ModelBinding.Metadata;
using Microsoft.AspNet.Mvc.ModelBinding.Validation;
using Newtonsoft.Json;
namespace Microsoft.AspNet.Mvc
{
@ -34,6 +36,7 @@ namespace Microsoft.AspNet.Mvc
ModelValidatorProviders = new List<IModelValidatorProvider>();
ClientModelValidatorProviders = new List<IClientModelValidatorProvider>();
CacheProfiles = new Dictionary<string, CacheProfile>(StringComparer.OrdinalIgnoreCase);
SerializerSettings = SerializerSettingsProvider.CreateSerializerSettings();
}
/// <summary>
@ -81,6 +84,11 @@ namespace Microsoft.AspNet.Mvc
/// </summary>
public IList<IInputFormatter> InputFormatters { get; }
/// <summary>
/// Gets the <see cref="JsonSerializerSettings"/> that are used by this application.
/// </summary>
public JsonSerializerSettings SerializerSettings { get; }
/// <summary>
/// Gets a list of <see cref="IExcludeTypeValidationFilter"/>s that are used by this application.
/// </summary>

View File

@ -34,10 +34,7 @@ namespace Microsoft.AspNet.Mvc.Rendering
/// <inheritdoc />
public HtmlString Serialize(object value, [NotNull] JsonSerializerSettings serializerSettings)
{
var jsonOutputFormatter = new JsonOutputFormatter
{
SerializerSettings = serializerSettings
};
var jsonOutputFormatter = new JsonOutputFormatter(serializerSettings);
return SerializeInternal(jsonOutputFormatter, value);
}

View File

@ -257,10 +257,7 @@ namespace System.Web.Http
[NonAction]
public virtual JsonResult Json<T>([NotNull] T content, [NotNull] JsonSerializerSettings serializerSettings)
{
var formatter = new JsonOutputFormatter()
{
SerializerSettings = serializerSettings,
};
var formatter = new JsonOutputFormatter(serializerSettings);
return new JsonResult(content, formatter);
}
@ -279,10 +276,7 @@ namespace System.Web.Http
[NotNull] JsonSerializerSettings serializerSettings,
[NotNull] Encoding encoding)
{
var formatter = new JsonOutputFormatter()
{
SerializerSettings = serializerSettings,
};
var formatter = new JsonOutputFormatter(serializerSettings);
formatter.SupportedEncodings.Clear();
formatter.SupportedEncodings.Add(encoding);

View File

@ -48,14 +48,14 @@ namespace Microsoft.AspNet.Mvc
options.OutputFormatters.Add(new HttpNoContentOutputFormatter());
options.OutputFormatters.Add(new StringOutputFormatter());
options.OutputFormatters.Add(new StreamOutputFormatter());
options.OutputFormatters.Add(new JsonOutputFormatter());
options.OutputFormatters.Add(new JsonOutputFormatter(options.SerializerSettings));
// Set up default mapping for json extensions to content type
options.FormatterMappings.SetMediaTypeMappingForFormat("json", MediaTypeHeaderValue.Parse("application/json"));
// Set up default input formatters.
options.InputFormatters.Add(new JsonInputFormatter());
options.InputFormatters.Add(new JsonPatchInputFormatter());
options.InputFormatters.Add(new JsonInputFormatter(options.SerializerSettings));
options.InputFormatters.Add(new JsonPatchInputFormatter(options.SerializerSettings));
// Set up ValueProviders
options.ValueProviderFactories.Add(new RouteValueValueProviderFactory());

View File

@ -102,7 +102,12 @@ namespace Microsoft.Framework.DependencyInjection
return new DefaultCompositeMetadataDetailsProvider(options.ModelMetadataDetailsProviders);
}));
services.TryAdd(ServiceDescriptor.Instance(typeof(JsonOutputFormatter), new JsonOutputFormatter()));
// JsonOutputFormatter should use the SerializerSettings on MvcOptions
services.TryAdd(ServiceDescriptor.Singleton<JsonOutputFormatter>(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<MvcOptions>>().Options;
return new JsonOutputFormatter(options.SerializerSettings);
}));
// Razor, Views and runtime compilation

View File

@ -166,6 +166,18 @@ namespace Microsoft.AspNet.Mvc
Assert.NotNull(jsonFormatter.SerializerSettings);
}
[Fact]
public void Constructor_UsesSerializerSettings()
{
// Arrange
// Act
var serializerSettings = new JsonSerializerSettings();
var jsonFormatter = new JsonInputFormatter(serializerSettings);
// Assert
Assert.Same(serializerSettings, jsonFormatter.SerializerSettings);
}
[Fact]
public async Task ChangesTo_DefaultSerializerSettings_TakesEffect()
{
@ -219,83 +231,6 @@ namespace Microsoft.AspNet.Mvc
Assert.Contains("Required property 'Password' not found in JSON", modelErrorMessage);
}
[Fact]
public async Task ThrowsException_OnSupplyingNull_ForRequiredValueType()
{
// Arrange
var contentBytes = Encoding.UTF8.GetBytes("{\"Id\":\"null\",\"Name\":\"Programming C#\"}");
var jsonFormatter = new JsonInputFormatter();
var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8");
var metadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(Book));
var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType);
// Act
var obj = await jsonFormatter.ReadAsync(inputFormatterContext);
// Assert
var book = obj as Book;
Assert.NotNull(book);
Assert.Equal(0, book.Id);
Assert.Equal("Programming C#", book.Name);
Assert.False(actionContext.ModelState.IsValid);
Assert.Equal(1, actionContext.ModelState.Values.First().Errors.Count);
var modelErrorMessage = actionContext.ModelState.Values.First().Errors[0].Exception.Message;
Assert.Contains("Could not convert string to integer: null. Path 'Id'", modelErrorMessage);
}
[Theory]
[InlineData(typeof(Book))]
[InlineData(typeof(EBook))]
public async Task Validates_RequiredAttribute_OnRegularAndInheritedProperties(Type type)
{
// Arrange
var contentBytes = Encoding.UTF8.GetBytes("{ \"Name\" : \"Programming C#\"}");
var jsonFormatter = new JsonInputFormatter();
var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8");
var metadata = new EmptyModelMetadataProvider().GetMetadataForType(type);
var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType);
// Act
var obj = await jsonFormatter.ReadAsync(inputFormatterContext);
// Assert
Assert.False(actionContext.ModelState.IsValid);
Assert.Equal(1, actionContext.ModelState.Count);
var modelErrorMessage = actionContext.ModelState.Values.First().Errors[0].Exception.Message;
Assert.Contains("Required property 'Id' not found in JSON", modelErrorMessage);
}
[Fact]
public async Task Validates_RequiredAttributeOnStructTypes()
{
// Arrange
var contentBytes = Encoding.UTF8.GetBytes("{\"Longitude\":{}}");
var jsonFormatter = new JsonInputFormatter();
var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8");
var metadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(GpsCoordinate));
var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType);
// Act
var obj = await jsonFormatter.ReadAsync(inputFormatterContext);
// Assert
Assert.False(actionContext.ModelState.IsValid);
Assert.Equal(2, actionContext.ModelState.Count);
var errorMessages = GetModelStateErrorMessages(actionContext.ModelState);
Assert.Equal(3, errorMessages.Count());
Assert.Contains(
errorMessages,
(errorMessage) => errorMessage.Contains("Required property 'Latitude' not found in JSON"));
Assert.Contains(
errorMessages,
(errorMessage) => errorMessage.Contains("Required property 'X' not found in JSON"));
Assert.Contains(
errorMessages,
(errorMessage) => errorMessage.Contains("Required property 'Y' not found in JSON"));
}
[Fact]
public async Task Validation_DoesNotHappen_ForNonRequired_ValueTypeProperties()
{
@ -317,28 +252,6 @@ namespace Microsoft.AspNet.Mvc
Assert.Equal("Seattle", location.Name);
}
[Fact]
public async Task Validation_DoesNotHappen_OnNullableValueTypeProperties()
{
// Arrange
var contentBytes = Encoding.UTF8.GetBytes("{}");
var jsonFormatter = new JsonInputFormatter();
var actionContext = GetActionContext(contentBytes, "application/json;charset=utf-8");
var metadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(Venue));
var inputFormatterContext = new InputFormatterContext(actionContext, metadata.ModelType);
// Act
var obj = await jsonFormatter.ReadAsync(inputFormatterContext);
// Assert
Assert.True(actionContext.ModelState.IsValid);
var venue = obj as Venue;
Assert.NotNull(venue);
Assert.Null(venue.Location);
Assert.Null(venue.NearByLocations);
Assert.Null(venue.Name);
}
private static ActionContext GetActionContext(byte[] contentBytes,
string contentType = "application/xml")
{
@ -407,55 +320,12 @@ namespace Microsoft.AspNet.Mvc
public string Password { get; set; }
}
private class Book
{
[Required]
public int Id { get; set; }
[Required]
public string Name { get; set; }
}
private class EBook : Book
{
}
private struct Point
{
[Required]
public int X { get; set; }
[Required]
public int Y { get; set; }
}
private class GpsCoordinate
{
[Required]
public Point Latitude { get; set; }
[Required]
public Point Longitude { get; set; }
}
private class Location
{
public int Id { get; set; }
public string Name { get; set; }
}
private class Venue
{
[Required]
public string Name { get; set; }
[Required]
public Point? Location { get; set; }
[Required]
public List<Point> NearByLocations { get; set; }
}
}
}
#endif

View File

@ -30,6 +30,17 @@ namespace Microsoft.AspNet.Mvc.Core.Test.Formatters
Assert.NotNull(jsonFormatter.SerializerSettings);
}
[Fact]
public void Constructor_UsesSerializerSettings()
{
// Arrange
// Act
var serializerSettings = new JsonSerializerSettings();
var jsonFormatter = new JsonInputFormatter(serializerSettings);
// Assert
Assert.Same(serializerSettings, jsonFormatter.SerializerSettings);
}
[Fact]
public async Task ChangesTo_DefaultSerializerSettings_TakesEffect()

View File

@ -133,6 +133,7 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
// Assert
var body = await response.Content.ReadAsStringAsync();
var customer = JsonConvert.DeserializeObject<Customer>(body);
Assert.Equal("Order0", customer.Orders[1].OrderName);
Assert.Null(customer.Orders[0].OrderName);

View File

@ -1906,75 +1906,6 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
Assert.Equal("March", user.RegisterationMonth);
}
public static TheoryData<string, string[]> ModelStateHasErrorsForValueAndReferenceTypesData
{
get
{
return new TheoryData<string, string[]>()
{
{
"{}",
new[]
{
":Required property 'Id' not found in JSON",
"Lines:The Lines field is required."
}
},
{
"{\"Id\":10}",
new[]
{
"Lines:The Lines field is required."
}
},
{
"{\"Id\":10,\"Lines\":[{}]}",
new []
{
"Lines[0]:Required property 'Start' not found in JSON",
"Lines[0]:Required property 'End' not found in JSON"
}
},
{
"{\"Id\":10,\"Lines\":[{\"Start\":{\"X\":10,\"Y\":10},\"End\":{\"X\":10}}]}",
new []
{
"Lines[0].End:Required property 'Y' not found in JSON"
}
}
};
}
}
[Theory]
[MemberData(nameof(ModelStateHasErrorsForValueAndReferenceTypesData))]
public async Task ModelState_HasErrors_ForValueAndReferenceTypes(
string input,
IEnumerable<string> expectedModelStateErrorMessages)
{
// Arrange
var server = TestHelper.CreateServer(_app, SiteName, _configureServices);
var client = server.CreateClient();
var content = new StringContent(input, Encoding.UTF8, "text/json");
// Act
var response = await client.PostAsync(
"http://localhost/Validation/CreateRectangle",
content);
// Assert
var data = await response.Content.ReadAsStringAsync();
var actualModelStateErrorMessages = JsonConvert.DeserializeObject<IEnumerable<string>>(data);
Assert.NotNull(actualModelStateErrorMessages);
Assert.Equal(expectedModelStateErrorMessages.Count(), actualModelStateErrorMessages.Count());
foreach (var expectedErrorMessage in expectedModelStateErrorMessages)
{
Assert.Contains(
actualModelStateErrorMessages,
(actualErrorMessage) => actualErrorMessage.StartsWith(expectedErrorMessage));
}
}
[Fact]
public async Task BindModelAsync_WithCollection()
{

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Linq;
using System.Xml.Linq;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.ModelBinding.Validation;
@ -207,5 +208,29 @@ namespace Microsoft.AspNet.Mvc
Assert.IsType<DefaultTypeNameBasedExcludeFilter>(mvcOptions.ValidationExcludeFilters[i++]);
Assert.Equal(xmlNodeFilter.ExcludedTypeName, "System.Xml.XmlNode");
}
[Fact]
public void Setup_JsonFormattersUseSerializerSettings()
{
// Arrange
var mvcOptions = new MvcOptions();
var setup = new MvcOptionsSetup();
// Act
setup.Configure(mvcOptions);
// Assert
var jsonInputFormatters = mvcOptions.InputFormatters.OfType<JsonInputFormatter>();
foreach (var jsonInputFormatter in jsonInputFormatters)
{
Assert.Same(mvcOptions.SerializerSettings, jsonInputFormatter.SerializerSettings);
}
var jsonOuputFormatters = mvcOptions.OutputFormatters.OfType<JsonOutputFormatter>();
foreach (var jsonOuputFormatter in jsonOuputFormatters)
{
Assert.Same(mvcOptions.SerializerSettings, jsonOuputFormatter.SerializerSettings);
}
}
}
}

View File

@ -28,51 +28,6 @@ namespace ModelBindingWebSite.Controllers
{
return ModelState.IsValid;
}
public IActionResult CreateRectangle([FromBody] Rectangle rectangle)
{
if (!ModelState.IsValid)
{
return new ObjectResult(GetModelStateErrorMessages(ModelState)) { StatusCode = 400 };
}
return new ObjectResult(rectangle);
}
private IEnumerable<string> GetModelStateErrorMessages(ModelStateDictionary modelStateDictionary)
{
var allErrorMessages = new List<string>();
foreach (var keyModelStatePair in modelStateDictionary)
{
var key = keyModelStatePair.Key;
var errors = keyModelStatePair.Value.Errors;
if (errors != null && errors.Count > 0)
{
string errorMessage = null;
foreach (var modelError in errors)
{
if (string.IsNullOrEmpty(modelError.ErrorMessage))
{
if (modelError.Exception != null)
{
errorMessage = modelError.Exception.Message;
}
}
else
{
errorMessage = modelError.ErrorMessage;
}
if (errorMessage != null)
{
allErrorMessages.Add(string.Format("{0}:{1}", key, errorMessage));
}
}
}
}
return allErrorMessages;
}
}
public class SelfishPerson

View File

@ -1,18 +0,0 @@
// 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.ComponentModel.DataAnnotations;
namespace ModelBindingWebSite
{
public class Drawing
{
[Required]
public int Id { get; set; }
[Required]
public List<Line> Lines { get; set; }
}
}

View File

@ -1,12 +0,0 @@
// 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;
namespace ModelBindingWebSite
{
public class Rectangle : Drawing
{
}
}