diff --git a/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs index 170ebbbbe38e..45990e19ff48 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs @@ -258,7 +258,7 @@ await Client.HubConnection.InvokeAsync( await ValidateClientKeepsWorking(Client, batches); } - [Fact] + [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/12962")] public async Task LogsJSInteropCompletionsCallbacksAndContinuesWorkingInAllSituations() { // Arrange diff --git a/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs b/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs index 9f3472518dc4..edcfcd4271c5 100644 --- a/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs +++ b/src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs @@ -2560,6 +2560,17 @@ public partial class EmptyModelMetadataProvider : Microsoft.AspNetCore.Mvc.Model { public EmptyModelMetadataProvider() : base (default(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ICompositeMetadataDetailsProvider)) { } } + public sealed partial class FormFileValueProvider : Microsoft.AspNetCore.Mvc.ModelBinding.IValueProvider + { + public FormFileValueProvider(Microsoft.AspNetCore.Http.IFormFileCollection files) { } + public bool ContainsPrefix(string prefix) { throw null; } + public Microsoft.AspNetCore.Mvc.ModelBinding.ValueProviderResult GetValue(string key) { throw null; } + } + public sealed partial class FormFileValueProviderFactory : Microsoft.AspNetCore.Mvc.ModelBinding.IValueProviderFactory + { + public FormFileValueProviderFactory() { } + public System.Threading.Tasks.Task CreateValueProviderAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ValueProviderFactoryContext context) { throw null; } + } public partial class FormValueProvider : Microsoft.AspNetCore.Mvc.ModelBinding.BindingSourceValueProvider, Microsoft.AspNetCore.Mvc.ModelBinding.IEnumerableValueProvider, Microsoft.AspNetCore.Mvc.ModelBinding.IValueProvider { public FormValueProvider(Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource bindingSource, Microsoft.AspNetCore.Http.IFormCollection values, System.Globalization.CultureInfo culture) : base (default(Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource)) { } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs index 1f246bdbd5f1..a2c0dbdcaebb 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs @@ -96,6 +96,7 @@ public void Configure(MvcOptions options) options.ValueProviderFactories.Add(new RouteValueProviderFactory()); options.ValueProviderFactories.Add(new QueryStringValueProviderFactory()); options.ValueProviderFactories.Add(new JQueryFormValueProviderFactory()); + options.ValueProviderFactories.Add(new FormFileValueProviderFactory()); // Set up metadata providers ConfigureAdditionalModelMetadataDetailsProviders(options.ModelMetadataDetailsProviders); diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/FormFileValueProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/FormFileValueProvider.cs new file mode 100644 index 000000000000..bf976f5b03e3 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/FormFileValueProvider.cs @@ -0,0 +1,68 @@ +// 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 Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding +{ + /// + /// An adapter for data stored in an . + /// + /// + /// Unlike most instances, does not provide any values, but + /// specifically responds to queries. This allows the model binding system to + /// recurse in to deeply nested object graphs with only values for form files. + /// + public sealed class FormFileValueProvider : IValueProvider + { + private readonly IFormFileCollection _files; + private PrefixContainer _prefixContainer; + + /// + /// Creates a value provider for . + /// + /// The . + public FormFileValueProvider(IFormFileCollection files) + { + _files = files ?? throw new ArgumentNullException(nameof(files)); + } + + private PrefixContainer PrefixContainer + { + get + { + _prefixContainer ??= CreatePrefixContainer(_files); + return _prefixContainer; + } + } + + private static PrefixContainer CreatePrefixContainer(IFormFileCollection formFiles) + { + var fileNames = new List(); + var count = formFiles.Count; + for (var i = 0; i < count; i++) + { + var file = formFiles[i]; + + // If there is an in the form and is left blank. + // This matches the filtering behavior from FormFileModelBinder + if (file.Length == 0 && string.IsNullOrEmpty(file.FileName)) + { + continue; + } + + fileNames.Add(file.Name); + } + + return new PrefixContainer(fileNames); + } + + /// + public bool ContainsPrefix(string prefix) => PrefixContainer.ContainsPrefix(prefix); + + /// + public ValueProviderResult GetValue(string key) => ValueProviderResult.None; + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/FormFileValueProviderFactory.cs b/src/Mvc/Mvc.Core/src/ModelBinding/FormFileValueProviderFactory.cs new file mode 100644 index 000000000000..0f3ae8b8ff89 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/FormFileValueProviderFactory.cs @@ -0,0 +1,43 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding +{ + /// + /// A for . + /// + public sealed class FormFileValueProviderFactory : IValueProviderFactory + { + /// + public Task CreateValueProviderAsync(ValueProviderFactoryContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var request = context.ActionContext.HttpContext.Request; + if (request.HasFormContentType) + { + // Allocating a Task only when the body is multipart form. + return AddValueProviderAsync(context, request); + } + + return Task.CompletedTask; + } + + private static async Task AddValueProviderAsync(ValueProviderFactoryContext context, HttpRequest request) + { + var formCollection = await request.ReadFormAsync(); + if (formCollection.Files.Count > 0) + { + var valueProvider = new FormFileValueProvider(formCollection.Files); + context.ValueProviders.Add(valueProvider); + } + } + } +} diff --git a/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj b/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj index 39df066d8802..1552b9c6e49d 100644 --- a/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj +++ b/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj @@ -2,6 +2,7 @@ netcoreapp3.0 + Microsoft.AspNetCore.Mvc diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/FormFileValueProviderFactoryTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/FormFileValueProviderFactoryTest.cs new file mode 100644 index 000000000000..c0030a0ba522 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/FormFileValueProviderFactoryTest.cs @@ -0,0 +1,73 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding +{ + public class FormFileValueProviderFactoryTest + { + [Fact] + public async Task CreateValueProviderAsync_DoesNotAddValueProvider_IfRequestDoesNotHaveFormContent() + { + // Arrange + var factory = new FormFileValueProviderFactory(); + var context = CreateContext("application/json"); + + // Act + await factory.CreateValueProviderAsync(context); + + // Assert + Assert.Empty(context.ValueProviders); + } + + [Fact] + public async Task CreateValueProviderAsync_DoesNotAddValueProvider_IfFileCollectionIsEmpty() + { + // Arrange + var factory = new FormFileValueProviderFactory(); + var context = CreateContext("multipart/form-data"); + + // Act + await factory.CreateValueProviderAsync(context); + + // Assert + Assert.Empty(context.ValueProviders); + } + + [Fact] + public async Task CreateValueProviderAsync_AddsValueProvider() + { + // Arrange + var factory = new FormFileValueProviderFactory(); + var context = CreateContext("multipart/form-data; boundary=----WebKitFormBoundarymx2fSWqWSd0OxQqq"); + var files = (FormFileCollection)context.ActionContext.HttpContext.Request.Form.Files; + files.Add(new FormFile(Stream.Null, 0, 10, "some-name", "some-name")); + + // Act + await factory.CreateValueProviderAsync(context); + + // Assert + Assert.Collection( + context.ValueProviders, + v => Assert.IsType(v)); + } + + private static ValueProviderFactoryContext CreateContext(string contentType) + { + var context = new DefaultHttpContext(); + context.Request.ContentType = contentType; + context.Request.Form = new FormCollection(new Dictionary(), new FormFileCollection()); + var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor()); + + return new ValueProviderFactoryContext(actionContext); + } + } +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/FormFileValueProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/FormFileValueProviderTest.cs new file mode 100644 index 000000000000..7be36b91ff97 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/FormFileValueProviderTest.cs @@ -0,0 +1,71 @@ +// 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 Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding +{ + public class FormFileValueProviderTest + { + [Fact] + public void ContainsPrefix_ReturnsFalse_IfFileIs0LengthAndFileNameIsEmpty() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = "multipart/form-data"; + var formFiles = new FormFileCollection(); + formFiles.Add(new FormFile(Stream.Null, 0, 0, "file", fileName: null)); + httpContext.Request.Form = new FormCollection(new Dictionary(), formFiles); + + var valueProvider = new FormFileValueProvider(formFiles); + + // Act + var result = valueProvider.ContainsPrefix("file"); + + // Assert + Assert.False(result); + } + + [Fact] + public void ContainsPrefix_ReturnsTrue_IfFileExists() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = "multipart/form-data"; + var formFiles = new FormFileCollection(); + formFiles.Add(new FormFile(Stream.Null, 0, 10, "file", "file")); + httpContext.Request.Form = new FormCollection(new Dictionary(), formFiles); + + var valueProvider = new FormFileValueProvider(formFiles); + + // Act + var result = valueProvider.ContainsPrefix("file"); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetValue_ReturnsNoneResult() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = "multipart/form-data"; + var formFiles = new FormFileCollection(); + formFiles.Add(new FormFile(Stream.Null, 0, 10, "file", "file")); + httpContext.Request.Form = new FormCollection(new Dictionary(), formFiles); + + var valueProvider = new FormFileValueProvider(formFiles); + + // Act + var result = valueProvider.GetValue("file"); + + // Assert + Assert.Equal(ValueProviderResult.None, result); + } + } +} diff --git a/src/Mvc/Mvc.ViewFeatures/src/Rendering/SystemTextJsonHelper.cs b/src/Mvc/Mvc.ViewFeatures/src/Rendering/SystemTextJsonHelper.cs index a615d9267513..48f74b2113ea 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Rendering/SystemTextJsonHelper.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/Rendering/SystemTextJsonHelper.cs @@ -1,5 +1,4 @@ - -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Text.Encodings.Web; diff --git a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs index 00eda811ead3..6b686831183b 100644 --- a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs +++ b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs @@ -82,7 +82,8 @@ public void Setup_SetsUpValueProviders() provider => Assert.IsType(provider), provider => Assert.IsType(provider), provider => Assert.IsType(provider), - provider => Assert.IsType(provider)); + provider => Assert.IsType(provider), + provider => Assert.IsType(provider)); } [Fact] diff --git a/src/Mvc/test/Mvc.IntegrationTests/FormFileModelBindingIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/FormFileModelBindingIntegrationTest.cs index a7ff6eeba30d..92ea35e3193f 100644 --- a/src/Mvc/test/Mvc.IntegrationTests/FormFileModelBindingIntegrationTest.cs +++ b/src/Mvc/test/Mvc.IntegrationTests/FormFileModelBindingIntegrationTest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -75,6 +76,397 @@ public async Task BindProperty_WithData_WithEmptyPrefix_GetsBound() Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState); } + [Fact] + public async Task BindProperty_WithOnlyFormFile_WithEmptyPrefix() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "Parameter1", + BindingInfo = new BindingInfo(), + ParameterType = typeof(Person) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + UpdateRequest(request, data, "Address.File"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var boundPerson = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(boundPerson.Address); + var file = Assert.IsAssignableFrom(boundPerson.Address.File); + Assert.Equal("form-data; name=Address.File; filename=text.txt", file.ContentDisposition); + using var reader = new StreamReader(boundPerson.Address.File.OpenReadStream()); + Assert.Equal(data, reader.ReadToEnd()); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Collection( + modelState.OrderBy(kvp => kvp.Key), + kvp => + { + var (key, value) = kvp; + Assert.Equal("Address.File", kvp.Key); + Assert.Null(value.RawValue); + Assert.Empty(value.Errors); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }); + } + + [Fact] + public async Task BindProperty_WithOnlyFormFile_WithPrefix() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "Parameter1", + BindingInfo = new BindingInfo(), + ParameterType = typeof(Person) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + UpdateRequest(request, data, "Parameter1.Address.File"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var boundPerson = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(boundPerson.Address); + var file = Assert.IsAssignableFrom(boundPerson.Address.File); + Assert.Equal("form-data; name=Parameter1.Address.File; filename=text.txt", file.ContentDisposition); + using var reader = new StreamReader(boundPerson.Address.File.OpenReadStream()); + Assert.Equal(data, reader.ReadToEnd()); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Collection( + modelState.OrderBy(kvp => kvp.Key), + kvp => + { + var (key, value) = kvp; + Assert.Equal("Parameter1.Address.File", kvp.Key); + Assert.Null(value.RawValue); + Assert.Empty(value.Errors); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }); + } + + private class Group + { + public string GroupName { get; set; } + + public Person Person { get; set; } + } + + [Fact] + public async Task BindProperty_OnFormFileInNestedSubClass_AtSecondLevel_WhenSiblingPropertyIsSpecified() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "Parameter1", + BindingInfo = new BindingInfo(), + ParameterType = typeof(Group) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = QueryString.Create("Person.Address.Zip", "98056"); + UpdateRequest(request, data, "Person.Address.File"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var group = Assert.IsType(modelBindingResult.Model); + Assert.Null(group.GroupName); + var boundPerson = group.Person; + Assert.NotNull(boundPerson); + Assert.NotNull(boundPerson.Address); + var file = Assert.IsAssignableFrom(boundPerson.Address.File); + Assert.Equal("form-data; name=Person.Address.File; filename=text.txt", file.ContentDisposition); + using var reader = new StreamReader(boundPerson.Address.File.OpenReadStream()); + Assert.Equal(data, reader.ReadToEnd()); + Assert.Equal(98056, boundPerson.Address.Zip); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Collection( + modelState.OrderBy(kvp => kvp.Key), + kvp => + { + var (key, value) = kvp; + Assert.Equal("Person.Address.File", kvp.Key); + Assert.Null(value.RawValue); + Assert.Empty(value.Errors); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }, + kvp => + { + var (key, value) = kvp; + Assert.Equal("Person.Address.Zip", kvp.Key); + Assert.Equal("98056", value.RawValue); + Assert.Empty(value.Errors); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }); + } + + private class Fleet + { + public int? Id { get; set; } + + public FleetGarage Garage { get; set; } + } + + public class FleetGarage + { + public string Name { get; set; } + + public FleetVehicle[] Vehicles { get; set; } + } + + public class FleetVehicle + { + public string Name { get; set; } + + public IFormFile Spec { get; set; } + + public FleetVehicle BackupVehicle { get; set; } + } + + [Fact] + public async Task BindProperty_OnFormFileInNestedSubClass_AtSecondLevel_RecursiveModel() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "fleet", + BindingInfo = new BindingInfo(), + ParameterType = typeof(Fleet) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = QueryString.Create("fleet.Garage.Name", "WestEnd"); + UpdateRequest(request, data, "fleet.Garage.Vehicles[0].Spec"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var fleet = Assert.IsType(modelBindingResult.Model); + Assert.Null(fleet.Id); + + Assert.NotNull(fleet.Garage); + Assert.NotNull(fleet.Garage.Vehicles); + + var vehicle = Assert.Single(fleet.Garage.Vehicles); + var file = Assert.IsAssignableFrom(vehicle.Spec); + + using var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data, reader.ReadToEnd()); + Assert.Null(vehicle.Name); + Assert.Null(vehicle.BackupVehicle); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Collection( + modelState.OrderBy(kvp => kvp.Key), + kvp => + { + var (key, value) = kvp; + Assert.Equal("fleet.Garage.Name", kvp.Key); + Assert.Equal("WestEnd", value.RawValue); + Assert.Empty(value.Errors); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }, + kvp => + { + var (key, value) = kvp; + Assert.Equal("fleet.Garage.Vehicles[0].Spec", kvp.Key); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }); + } + + [Fact] + public async Task BindProperty_OnFormFileInNestedSubClass_AtThirdLevel_RecursiveModel() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "fleet", + BindingInfo = new BindingInfo(), + ParameterType = typeof(Fleet) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = QueryString.Create("fleet.Garage.Name", "WestEnd"); + UpdateRequest(request, data, "fleet.Garage.Vehicles[0].BackupVehicle.Spec"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var fleet = Assert.IsType(modelBindingResult.Model); + Assert.Null(fleet.Id); + + Assert.NotNull(fleet.Garage); + Assert.NotNull(fleet.Garage.Vehicles); + + var vehicle = Assert.Single(fleet.Garage.Vehicles); + Assert.Null(vehicle.Spec); + Assert.NotNull(vehicle.BackupVehicle); + var file = Assert.IsAssignableFrom(vehicle.BackupVehicle.Spec); + + using var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data, reader.ReadToEnd()); + Assert.Null(vehicle.Name); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Collection( + modelState.OrderBy(kvp => kvp.Key), + kvp => + { + var (key, value) = kvp; + Assert.Equal("fleet.Garage.Name", kvp.Key); + Assert.Equal("WestEnd", value.RawValue); + Assert.Empty(value.Errors); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }, + kvp => + { + var (key, value) = kvp; + Assert.Equal("fleet.Garage.Vehicles[0].BackupVehicle.Spec", kvp.Key); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }); + } + + [Fact] + public async Task BindProperty_OnFormFileInNestedSubClass_AtSecondLevel_WhenSiblingPropertiesAreNotSpecified() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "Parameter1", + BindingInfo = new BindingInfo(), + ParameterType = typeof(Group) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = QueryString.Create("GroupName", "TestGroup"); + UpdateRequest(request, data, "Person.Address.File"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var group = Assert.IsType(modelBindingResult.Model); + Assert.Equal("TestGroup", group.GroupName); + var boundPerson = group.Person; + Assert.NotNull(boundPerson); + Assert.NotNull(boundPerson.Address); + var file = Assert.IsAssignableFrom(boundPerson.Address.File); + Assert.Equal("form-data; name=Person.Address.File; filename=text.txt", file.ContentDisposition); + using var reader = new StreamReader(boundPerson.Address.File.OpenReadStream()); + Assert.Equal(data, reader.ReadToEnd()); + Assert.Equal(0, boundPerson.Address.Zip); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Collection( + modelState.OrderBy(kvp => kvp.Key), + kvp => + { + var (key, value) = kvp; + Assert.Equal("GroupName", kvp.Key); + Assert.Equal("TestGroup", value.RawValue); + Assert.Empty(value.Errors); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }, + kvp => + { + var (key, value) = kvp; + Assert.Equal("Person.Address.File", kvp.Key); + Assert.Null(value.RawValue); + Assert.Empty(value.Errors); + Assert.Equal(ModelValidationState.Valid, value.ValidationState); + }); + } + private class ListContainer1 { [ModelBinder(Name = "files")] @@ -354,15 +746,526 @@ public async Task BindProperty_WithData_WithPrefix_GetsBound() Assert.Single(modelState, e => e.Key == "p.Specs"); } - private void UpdateRequest(HttpRequest request, string data, string name) + private class House { - const string fileName = "text.txt"; - var fileCollection = new FormFileCollection(); - var formCollection = new FormCollection(new Dictionary(), fileCollection); + public Garage Garage { get; set; } + } + + private class Garage + { + public List Cars { get; set; } + } + + [Fact] + public async Task BindProperty_FormFileCollectionInCollection_WithPrefix() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "house", + BindingInfo = new BindingInfo(), + ParameterType = typeof(House) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = QueryString.Create("house.Garage.Cars[0].Name", "Accord"); + UpdateRequest(request, data + 1, "house.Garage.Cars[0].Specs"); + AddFormFile(request, data + 2, "house.Garage.Cars[1].Specs"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var house = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(house.Garage); + Assert.NotNull(house.Garage.Cars); + Assert.Collection( + house.Garage.Cars, + car => + { + Assert.Equal("Accord", car.Name); + + var file = Assert.Single(car.Specs); + using var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data + 1, reader.ReadToEnd()); + }, + car => + { + Assert.Null(car.Name); + + var file = Assert.Single(car.Specs); + using var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data + 2, reader.ReadToEnd()); + }); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Equal(3, modelState.Count); + + var entry = Assert.Single(modelState, e => e.Key == "house.Garage.Cars[0].Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + Assert.Single(modelState, e => e.Key == "house.Garage.Cars[0].Specs"); + Assert.Single(modelState, e => e.Key == "house.Garage.Cars[1].Specs"); + } + + [Fact] + public async Task BindProperty_FormFileCollectionInCollection_OnlyFiles() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "house", + BindingInfo = new BindingInfo(), + ParameterType = typeof(House) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + UpdateRequest(request, data + 1, "house.Garage.Cars[0].Specs"); + AddFormFile(request, data + 2, "house.Garage.Cars[1].Specs"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var house = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(house.Garage); + Assert.NotNull(house.Garage.Cars); + Assert.Collection( + house.Garage.Cars, + car => + { + Assert.Null(car.Name); + + var file = Assert.Single(car.Specs); + using var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data + 1, reader.ReadToEnd()); + }, + car => + { + Assert.Null(car.Name); + + var file = Assert.Single(car.Specs); + using var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data + 2, reader.ReadToEnd()); + }); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Equal(2, modelState.Count); + + Assert.Single(modelState, e => e.Key == "house.Garage.Cars[0].Specs"); + Assert.Single(modelState, e => e.Key == "house.Garage.Cars[1].Specs"); + } + + [Fact] + public async Task BindProperty_FormFileCollectionInCollection_OutOfOrderFile() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "house", + BindingInfo = new BindingInfo(), + ParameterType = typeof(House) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + UpdateRequest(request, data + 1, "house.Garage.Cars[800].Specs"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var house = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(house.Garage); + Assert.Empty(house.Garage.Cars); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Empty(modelState); + } + + [Fact] + public async Task BindProperty_FormFileCollectionInCollection_MultipleFiles() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "house", + BindingInfo = new BindingInfo(), + ParameterType = typeof(House) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + UpdateRequest(request, data + 1, "house.Garage.Cars[0].Specs"); + AddFormFile(request, data + 2, "house.Garage.Cars[0].Specs"); + }); + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var house = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(house.Garage); + Assert.NotNull(house.Garage.Cars); + Assert.Collection( + house.Garage.Cars, + car => + { + Assert.Null(car.Name); + Assert.Collection( + car.Specs, + file => + { + using var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data + 1, reader.ReadToEnd()); + + }, + file => + { + using var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data + 2, reader.ReadToEnd()); + + }); + }); + + // ModelState + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + + Assert.Equal("house.Garage.Cars[0].Specs", kvp.Key); + } + + [Fact] + public async Task BindProperty_FormFile_AsAPropertyOnNestedColection() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "p", + BindingInfo = new BindingInfo(), + ParameterType = typeof(Car1) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = QueryString.Create("p.Name", "Accord"); + UpdateRequest(request, data, "p.Specs"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var car = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(car.Specs); + var file = Assert.Single(car.Specs); + Assert.Equal("form-data; name=p.Specs; filename=text.txt", file.ContentDisposition); + var reader = new StreamReader(file.OpenReadStream()); + Assert.Equal(data, reader.ReadToEnd()); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Equal(2, modelState.Count); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + Assert.Single(modelState, e => e.Key == "p.Specs"); + } + + public class MultiDimensionalFormFileContainer + { + public IFormFile[][] FormFiles { get; set; } + } + + [Fact] + public async Task BindModelAsync_MultiDimensionalFormFile_Works() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "p", + BindingInfo = new BindingInfo(), + ParameterType = typeof(MultiDimensionalFormFileContainer) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + UpdateRequest(request, data + 1, "FormFiles[0]"); + AddFormFile(request, data + 2, "FormFiles[1]"); + AddFormFile(request, data + 3, "FormFiles[1]"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var container = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(container.FormFiles); + Assert.Collection( + container.FormFiles, + item => + { + Assert.Collection( + item, + file => Assert.Equal(data + 1, ReadFormFile(file))); + }, + item => + { + Assert.Collection( + item, + file => Assert.Equal(data + 2, ReadFormFile(file)), + file => Assert.Equal(data + 3, ReadFormFile(file))); + }); + } + + [Fact] + public async Task BindModelAsync_MultiDimensionalFormFile_WithArrayNotation() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "p", + BindingInfo = new BindingInfo(), + ParameterType = typeof(MultiDimensionalFormFileContainer) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + UpdateRequest(request, data + 1, "FormFiles[0][0]"); + AddFormFile(request, data + 2, "FormFiles[1][0]"); + AddFormFile(request, data + 3, "FormFiles[1][0]"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + var container = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(container.FormFiles); + Assert.Empty(container.FormFiles); + } + + public class MultiDimensionalFormFileContainerLevel2 + { + public MultiDimensionalFormFileContainerLevel1 Level1 { get; set; } + } + + public class MultiDimensionalFormFileContainerLevel1 + { + public MultiDimensionalFormFileContainer Container { get; set; } + } + + [Fact] + public async Task BindModelAsync_DeeplyNestedMultiDimensionalFormFile_Works() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "p", + BindingInfo = new BindingInfo(), + ParameterType = typeof(MultiDimensionalFormFileContainerLevel2) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + UpdateRequest(request, data + 1, "p.Level1.Container.FormFiles[0]"); + AddFormFile(request, data + 2, "p.Level1.Container.FormFiles[1]"); + AddFormFile(request, data + 3, "p.Level1.Container.FormFiles[1]"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var level2 = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(level2.Level1); + var container = level2.Level1.Container; + Assert.NotNull(container); + Assert.NotNull(container.FormFiles); + Assert.Collection( + container.FormFiles, + item => + { + Assert.Collection( + item, + file => Assert.Equal(data + 1, ReadFormFile(file))); + }, + item => + { + Assert.Collection( + item, + file => Assert.Equal(data + 2, ReadFormFile(file)), + file => Assert.Equal(data + 3, ReadFormFile(file))); + }); + } + + public class DictionaryContainer + { + public Dictionary Dictionary { get; set; } + } + + [Fact] + public async Task BindModelAsync_DictionaryOfFormFiles() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "p", + BindingInfo = new BindingInfo(), + ParameterType = typeof(DictionaryContainer) + }; + + var data = "Some Data Is Better Than No Data."; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = QueryString.Create(new Dictionary + { + { "p.Dictionary[0].Key", "key0" }, + { "p.Dictionary[1].Key", "key1" }, + { "p.Dictionary[4000].Key", "key1" }, + }); + UpdateRequest(request, data + 1, "p.Dictionary[0].Value"); + AddFormFile(request, data + 2, "p.Dictionary[1].Value"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var container = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(container.Dictionary); + Assert.Collection( + container.Dictionary.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("key0", kvp.Key); + Assert.Equal(data + 1, ReadFormFile(kvp.Value)); + }, + kvp => + { + Assert.Equal("key1", kvp.Key); + Assert.Equal(data + 2, ReadFormFile(kvp.Value)); + }); + } + + private static string ReadFormFile(IFormFile file) + { + using var reader = new StreamReader(file.OpenReadStream()); + return reader.ReadToEnd(); + } + + private void UpdateRequest(HttpRequest request, string data, string name) + { + var formCollection = new FormCollection(new Dictionary(), new FormFileCollection()); request.Form = formCollection; + request.ContentType = "multipart/form-data; boundary=----WebKitFormBoundarymx2fSWqWSd0OxQqq"; + AddFormFile(request, data, name); + } + + private void AddFormFile(HttpRequest request, string data, string name) + { + const string fileName = "text.txt"; + if (string.IsNullOrEmpty(data) || string.IsNullOrEmpty(name)) { // Leave the submission empty. @@ -371,6 +1274,7 @@ private void UpdateRequest(HttpRequest request, string data, string name) request.Headers["Content-Disposition"] = $"form-data; name={name}; filename={fileName}"; + var fileCollection = (FormFileCollection)request.Form.Files; var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(data)); fileCollection.Add(new FormFile(memoryStream, 0, data.Length, name, fileName) { @@ -378,4 +1282,4 @@ private void UpdateRequest(HttpRequest request, string data, string name) }); } } -} \ No newline at end of file +}