diff --git a/Mvc.NoFun.sln b/Mvc.NoFun.sln index 3995a5463a..d67abd6f60 100644 --- a/Mvc.NoFun.sln +++ b/Mvc.NoFun.sln @@ -117,6 +117,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Ap EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mvc.Api.Analyzers.Test", "test\Mvc.Api.Analyzers.Test\Mvc.Api.Analyzers.Test.csproj", "{71C626FC-6408-494B-A127-38CB64F71324}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-getdocument", "src\dotnet-getdocument\dotnet-getdocument.csproj", "{4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GetDocumentInsider", "src\GetDocumentInsider\GetDocumentInsider.csproj", "{2F683CF8-B055-46AE-BF83-9D1307F8D45F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.ApiDescription.Design", "src\Microsoft.Extensions.ApiDescription.Design\Microsoft.Extensions.ApiDescription.Design.csproj", "{34E3C302-B767-40C8-B538-3EE2BD4000C4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -575,6 +581,42 @@ Global {71C626FC-6408-494B-A127-38CB64F71324}.Release|Mixed Platforms.Build.0 = Release|Any CPU {71C626FC-6408-494B-A127-38CB64F71324}.Release|x86.ActiveCfg = Release|Any CPU {71C626FC-6408-494B-A127-38CB64F71324}.Release|x86.Build.0 = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|x86.ActiveCfg = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|x86.Build.0 = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|Any CPU.Build.0 = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|x86.ActiveCfg = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|x86.Build.0 = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|x86.Build.0 = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|Any CPU.Build.0 = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|x86.ActiveCfg = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|x86.Build.0 = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|x86.Build.0 = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|Any CPU.Build.0 = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|x86.ActiveCfg = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -619,6 +661,9 @@ Global {92D959F2-66B8-490A-BA33-DA4421EBC948} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} {1B398182-9EAE-400B-A2BD-EFFAC0168A36} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} {71C626FC-6408-494B-A127-38CB64F71324} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} + {2F683CF8-B055-46AE-BF83-9D1307F8D45F} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} + {34E3C302-B767-40C8-B538-3EE2BD4000C4} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D003597F-372F-4068-A2F0-353BE3C3B39A} diff --git a/Mvc.sln b/Mvc.sln index 775683a5fa..dcd83f09ca 100644 --- a/Mvc.sln +++ b/Mvc.sln @@ -178,6 +178,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Ap EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorRendering", "benchmarkapps\RazorRendering\RazorRendering.csproj", "{D7C6A696-F232-4288-BCCD-367407E4A934}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-getdocument", "src\dotnet-getdocument\dotnet-getdocument.csproj", "{4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GetDocumentInsider", "src\GetDocumentInsider\GetDocumentInsider.csproj", "{2F683CF8-B055-46AE-BF83-9D1307F8D45F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.ApiDescription.Design", "src\Microsoft.Extensions.ApiDescription.Design\Microsoft.Extensions.ApiDescription.Design.csproj", "{34E3C302-B767-40C8-B538-3EE2BD4000C4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -938,6 +944,42 @@ Global {D7C6A696-F232-4288-BCCD-367407E4A934}.Release|Mixed Platforms.Build.0 = Release|Any CPU {D7C6A696-F232-4288-BCCD-367407E4A934}.Release|x86.ActiveCfg = Release|Any CPU {D7C6A696-F232-4288-BCCD-367407E4A934}.Release|x86.Build.0 = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|x86.ActiveCfg = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Debug|x86.Build.0 = Debug|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|Any CPU.Build.0 = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|x86.ActiveCfg = Release|Any CPU + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6}.Release|x86.Build.0 = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Debug|x86.Build.0 = Debug|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|Any CPU.Build.0 = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|x86.ActiveCfg = Release|Any CPU + {2F683CF8-B055-46AE-BF83-9D1307F8D45F}.Release|x86.Build.0 = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Debug|x86.Build.0 = Debug|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|Any CPU.Build.0 = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|x86.ActiveCfg = Release|Any CPU + {34E3C302-B767-40C8-B538-3EE2BD4000C4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1010,6 +1052,9 @@ Global {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} {D7C6A696-F232-4288-BCCD-367407E4A934} = {2859F266-673A-45A2-9E3C-7B39C6DDD38E} + {4EDC489F-3EC5-4AE3-9841-A285F40F5FF6} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} + {2F683CF8-B055-46AE-BF83-9D1307F8D45F} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} + {34E3C302-B767-40C8-B538-3EE2BD4000C4} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {63D344F6-F86D-40E6-85B9-0AABBE338C4A} diff --git a/NuGetPackageVerifier.json b/NuGetPackageVerifier.json index b153ab1515..f551b83476 100644 --- a/NuGetPackageVerifier.json +++ b/NuGetPackageVerifier.json @@ -1,7 +1,31 @@ { - "Default": { - "rules": [ - "DefaultCompositeRule" - ] + "Default": { + "rules": [ + "DefaultCompositeRule" + ], + "packages": { + "Microsoft.Extensions.ApiDescription.Design": { + "Exclusions": { + "BUILD_ITEMS_FRAMEWORK": { + "*": "Package includes tool with different target frameworks." + }, + "SERVICING_ATTRIBUTE": { + "tools/Newtonsoft.Json.dll": "External assembly, not built as part of this process" + }, + "WRONG_PUBLICKEYTOKEN": { + "tools/Newtonsoft.Json.dll": "External assembly, not built as part of this process" + }, + "ASSEMBLY_INFORMATIONAL_VERSION_MISMATCH": { + "tools/Newtonsoft.Json.dll": "External assembly, not built as part of this process" + }, + "ASSEMBLY_FILE_VERSION_MISMATCH": { + "tools/Newtonsoft.Json.dll": "External assembly, not built as part of this process" + }, + "ASSEMBLY_VERSION_MISMATCH": { + "tools/Newtonsoft.Json.dll": "External assembly, not built as part of this process" + } + } + } } -} \ No newline at end of file + } +} diff --git a/benchmarkapps/BasicApi/Controllers/PetController.cs b/benchmarkapps/BasicApi/Controllers/PetController.cs index 8a30fb18c2..ed1ff8f5b9 100644 --- a/benchmarkapps/BasicApi/Controllers/PetController.cs +++ b/benchmarkapps/BasicApi/Controllers/PetController.cs @@ -132,6 +132,13 @@ namespace BasicApi.Controllers return new CreatedAtRouteResult("FindPetById", new { id = pet.Id }, pet); } + [Authorize("pet-store-writer")] + [HttpPost("add-pet")] + public ActionResult AddPetWithoutDb(Pet pet) + { + return pet; + } + [Authorize("pet-store-writer")] [HttpPut] public IActionResult EditPet(Pet pet) diff --git a/benchmarkapps/BasicApi/benchmarks.json b/benchmarkapps/BasicApi/benchmarks.json index 4a99d0bbd3..2ebc74dde2 100644 --- a/benchmarkapps/BasicApi/benchmarks.json +++ b/benchmarkapps/BasicApi/benchmarks.json @@ -44,5 +44,11 @@ "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/dev/benchmarkapps/BasicApi/postJsonWithToken.lua" }, "Path": "/pet" + }, + "BasicApi.PostWithoutDb": { + "Path": "/pet/add-pet", + "ClientProperties": { + "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/release/2.2/benchmarkapps/BasicApi/postJsonWithToken.lua" + } } } diff --git a/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorBenchmarkBase.cs b/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorBenchmarkBase.cs new file mode 100644 index 0000000000..cfa12d5f1b --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorBenchmarkBase.cs @@ -0,0 +1,75 @@ +// 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 BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc.Performance +{ + public abstract class ValidationVisitorBenchmarkBase + { + protected const int Iterations = 4; + + protected static readonly IModelValidatorProvider[] ValidatorProviders = new IModelValidatorProvider[] + { + new DefaultModelValidatorProvider(), + new DataAnnotationsModelValidatorProvider( + new ValidationAttributeAdapterProvider(), + Options.Create(new MvcDataAnnotationsLocalizationOptions()), + null), + }; + + protected static readonly CompositeModelValidatorProvider CompositeModelValidatorProvider = new CompositeModelValidatorProvider(ValidatorProviders); + + public abstract object Model { get; } + + public ModelMetadataProvider BaselineModelMetadataProvider { get; private set; } + public ModelMetadataProvider ModelMetadataProvider { get; private set; } + public ModelMetadata BaselineModelMetadata { get; private set; } + public ModelMetadata ModelMetadata { get; private set; } + public ActionContext ActionContext { get; private set; } + public ValidatorCache ValidatorCache { get; private set; } + + [GlobalSetup] + public void Setup() + { + BaselineModelMetadataProvider = CreateModelMetadataProvider(addHasValidatorsProvider: false); + ModelMetadataProvider = CreateModelMetadataProvider(addHasValidatorsProvider: true); + + BaselineModelMetadata = BaselineModelMetadataProvider.GetMetadataForType(Model.GetType()); + ModelMetadata = ModelMetadataProvider.GetMetadataForType(Model.GetType()); + ActionContext = GetActionContext(); + ValidatorCache = new ValidatorCache(); + } + + protected static ModelMetadataProvider CreateModelMetadataProvider(bool addHasValidatorsProvider) + { + var detailsProviders = new List + { + new DefaultValidationMetadataProvider(), + }; + + if (addHasValidatorsProvider) + { + detailsProviders.Add(new HasValidatorsValidationMetadataProvider(ValidatorProviders)); + } + + var compositeDetailsProvider = new DefaultCompositeMetadataDetailsProvider(detailsProviders); + return new DefaultModelMetadataProvider(compositeDetailsProvider, Options.Create(new MvcOptions())); + } + + protected static ActionContext GetActionContext() + { + return new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + } + } +} diff --git a/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorByteArrayBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorByteArrayBenchmark.cs new file mode 100644 index 0000000000..8880aa5a05 --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorByteArrayBenchmark.cs @@ -0,0 +1,42 @@ +// 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 BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; + +namespace Microsoft.AspNetCore.Mvc.Performance +{ + public class ValidationVisitorByteArrayBenchmark : ValidationVisitorBenchmarkBase + { + public override object Model { get; } = new byte[30]; + + [Benchmark(Baseline = true, Description = "validation for byte arrays baseline", OperationsPerInvoke = Iterations)] + public void Baseline() + { + // Baseline for validating a byte array of size 30, without the ModelMetadata.HasValidators optimization. + // This is the behavior as of 2.1. + var validationVisitor = new ValidationVisitor( + ActionContext, + CompositeModelValidatorProvider, + ValidatorCache, + BaselineModelMetadataProvider, + new ValidationStateDictionary()); + + validationVisitor.Validate(BaselineModelMetadata, "key", Model); + } + + [Benchmark(Description = "validation for byte arrays", OperationsPerInvoke = Iterations)] + public void HasValidators() + { + // Validating a byte array of size 30, with the ModelMetadata.HasValidators optimization. + var validationVisitor = new ValidationVisitor( + ActionContext, + CompositeModelValidatorProvider, + ValidatorCache, + ModelMetadataProvider, + new ValidationStateDictionary()); + + validationVisitor.Validate(ModelMetadata, "key", Model); + } + } +} diff --git a/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorModelWithValidatedProperties.cs b/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorModelWithValidatedProperties.cs new file mode 100644 index 0000000000..58c4127232 --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorModelWithValidatedProperties.cs @@ -0,0 +1,92 @@ +// 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.ComponentModel.DataAnnotations; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; + +namespace Microsoft.AspNetCore.Mvc.Performance +{ + public class ValidationVisitorModelWithValidatedProperties : ValidationVisitorBenchmarkBase + { + public class Person + { + [Required] + public int Id { get; set; } + + [Required] + [StringLength(20)] + public string Name { get; set; } + + public string Description { get; set; } + + public IList
Address { get; set; } + } + + public class Address + { + [Required] + public string Street { get; set; } + + public string Street2 { get; set; } + + public string Type { get; set; } + + [Required] + public string Zip { get; set; } + } + + public override object Model { get; } = new Person + { + Id = 10, + Name = "Test", + Address = new List
+ { + new Address + { + Street = "1 Microsoft Way", + Type = "Work", + Zip = "98056", + }, + new Address + { + Street = "15701 NE 39th St", + Type = "Home", + Zip = "98052", + } + }, + }; + + [Benchmark(Baseline = true, Description = "validation for a model with some validated properties - baseline", OperationsPerInvoke = Iterations)] + public void Visit_TypeWithSomeValidatedProperties_Baseline() + { + // Baseline for validating a typical model with some properties that require validation. + // This executes without the ModelMetadata.HasValidators optimization. + + var validationVisitor = new ValidationVisitor( + ActionContext, + CompositeModelValidatorProvider, + ValidatorCache, + BaselineModelMetadataProvider, + new ValidationStateDictionary()); + + validationVisitor.Validate(BaselineModelMetadata, "key", Model); + } + + [Benchmark(Description = "validation for a model with some validated properties", OperationsPerInvoke = Iterations)] + public void Visit_TypeWithSomeValidatedProperties() + { + // Validating a typical model with some properties that require validation. + // This executes with the ModelMetadata.HasValidators optimization. + var validationVisitor = new ValidationVisitor( + ActionContext, + CompositeModelValidatorProvider, + ValidatorCache, + ModelMetadataProvider, + new ValidationStateDictionary()); + + validationVisitor.Validate(ModelMetadata, "key", Model); + } + } +} diff --git a/build/dependencies.props b/build/dependencies.props index 7a79c5aa00..a2f537f41d 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -16,92 +16,96 @@ 0.43.0 2.1.1.1 2.1.1 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-a-alpha1-address-scheme-17061 - 3.0.0-a-alpha1-address-scheme-17061 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-a-alpha1-master-16559 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 2.0.0 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 5.2.6 + 15.6.82 2.8.0 2.8.0 - 3.0.0-alpha1-10549 + 3.0.0-alpha1-10617 1.7.0 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 3.0.0-preview1-26907-05 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 2.0.9 2.1.3 2.2.0-preview2-26905-02 - 3.0.0-alpha1-10549 - 3.0.0-alpha1-10549 + 3.0.0-alpha1-10617 + 3.0.0-alpha1-10617 15.6.1 - 4.9.0 + 4.10.0 2.0.3 1.0.1 + 11.0.2 4.6.0-preview1-26907-04 4.6.0-preview1-26907-04 + 4.3.2 4.6.0-preview1-26907-04 0.10.0 2.3.1 diff --git a/global.json b/global.json index 484f835d29..ffa7db4c53 100644 --- a/global.json +++ b/global.json @@ -3,6 +3,6 @@ "version": "2.2.100-preview2-009404" }, "msbuild-sdks": { - "Internal.AspNetCore.Sdk": "3.0.0-alpha1-20181004.5" + "Internal.AspNetCore.Sdk": "3.0.0-alpha1-20181011.11" } } diff --git a/korebuild-lock.txt b/korebuild-lock.txt index f01f6571b0..27b605e3c6 100644 --- a/korebuild-lock.txt +++ b/korebuild-lock.txt @@ -1,2 +1,2 @@ -version:3.0.0-alpha1-20181004.5 -commithash:8725f7194e9976f75f90f56afc0fba6116ac597c +version:3.0.0-alpha1-20181011.11 +commithash:f57aa8ddda0abdd74ada55853587bedb4f364065 diff --git a/src/GetDocumentInsider/AnsiConsole.cs b/src/GetDocumentInsider/AnsiConsole.cs new file mode 100644 index 0000000000..306b4ff452 --- /dev/null +++ b/src/GetDocumentInsider/AnsiConsole.cs @@ -0,0 +1,15 @@ +// 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 Microsoft.Extensions.ApiDescription.Tool +{ + internal class AnsiConsole + { + public static readonly AnsiTextWriter _out = new AnsiTextWriter(Console.Out); + + public static void WriteLine(string text) + => _out.WriteLine(text); + } +} diff --git a/src/GetDocumentInsider/AnsiConstants.cs b/src/GetDocumentInsider/AnsiConstants.cs new file mode 100644 index 0000000000..b54e15b751 --- /dev/null +++ b/src/GetDocumentInsider/AnsiConstants.cs @@ -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. + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal static class AnsiConstants + { + public const string Reset = "\x1b[22m\x1b[39m"; + public const string Bold = "\x1b[1m"; + public const string Dark = "\x1b[22m"; + public const string Black = "\x1b[30m"; + public const string Red = "\x1b[31m"; + public const string Green = "\x1b[32m"; + public const string Yellow = "\x1b[33m"; + public const string Blue = "\x1b[34m"; + public const string Magenta = "\x1b[35m"; + public const string Cyan = "\x1b[36m"; + public const string Gray = "\x1b[37m"; + } +} diff --git a/src/GetDocumentInsider/AnsiTextWriter.cs b/src/GetDocumentInsider/AnsiTextWriter.cs new file mode 100644 index 0000000000..066f711e27 --- /dev/null +++ b/src/GetDocumentInsider/AnsiTextWriter.cs @@ -0,0 +1,131 @@ +// 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.Diagnostics; +using System.IO; +using System.Text.RegularExpressions; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal class AnsiTextWriter + { + private readonly TextWriter _writer; + + public AnsiTextWriter(TextWriter writer) => _writer = writer; + + public void WriteLine(string text) + { + Interpret(text); + _writer.Write(Environment.NewLine); + } + + private void Interpret(string value) + { + var matches = Regex.Matches(value, "\x1b\\[([0-9]+)?m"); + + var start = 0; + foreach (Match match in matches) + { + var length = match.Index - start; + if (length != 0) + { + _writer.Write(value.Substring(start, length)); + } + + Apply(match.Groups[1].Value); + + start = match.Index + match.Length; + } + + if (start != value.Length) + { + _writer.Write(value.Substring(start)); + } + } + + private static void Apply(string parameter) + { + switch (parameter) + { + case "1": + ApplyBold(); + break; + + case "22": + ResetBold(); + break; + + case "30": + ApplyColor(ConsoleColor.Black); + break; + + case "31": + ApplyColor(ConsoleColor.DarkRed); + break; + + case "32": + ApplyColor(ConsoleColor.DarkGreen); + break; + + case "33": + ApplyColor(ConsoleColor.DarkYellow); + break; + + case "34": + ApplyColor(ConsoleColor.DarkBlue); + break; + + case "35": + ApplyColor(ConsoleColor.DarkMagenta); + break; + + case "36": + ApplyColor(ConsoleColor.DarkCyan); + break; + + case "37": + ApplyColor(ConsoleColor.Gray); + break; + + case "39": + ResetColor(); + break; + + default: + Debug.Fail("Unsupported parameter: " + parameter); + break; + } + } + + private static void ApplyBold() + => Console.ForegroundColor = (ConsoleColor)((int)Console.ForegroundColor | 8); + + private static void ResetBold() + => Console.ForegroundColor = (ConsoleColor)((int)Console.ForegroundColor & 7); + + private static void ApplyColor(ConsoleColor color) + { + var wasBold = ((int)Console.ForegroundColor & 8) != 0; + + Console.ForegroundColor = color; + + if (wasBold) + { + ApplyBold(); + } + } + + private static void ResetColor() + { + var wasBold = ((int)Console.ForegroundColor & 8) != 0; + + Console.ResetColor(); + + if (wasBold) + { + ApplyBold(); + } + } + } +} diff --git a/src/GetDocumentInsider/CommandException.cs b/src/GetDocumentInsider/CommandException.cs new file mode 100644 index 0000000000..c1437b038b --- /dev/null +++ b/src/GetDocumentInsider/CommandException.cs @@ -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; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal class CommandException : Exception + { + public CommandException(string message) + : base(message) + { + } + + public CommandException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/GetDocumentInsider/CommandLineUtils/CommandArgument.cs b/src/GetDocumentInsider/CommandLineUtils/CommandArgument.cs new file mode 100644 index 0000000000..3346ea0ecb --- /dev/null +++ b/src/GetDocumentInsider/CommandLineUtils/CommandArgument.cs @@ -0,0 +1,19 @@ +// 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.Linq; + +namespace Microsoft.DotNet.Cli.CommandLine +{ + internal class CommandArgument + { + public CommandArgument() => Values = new List(); + + public string Name { get; set; } + public string Description { get; set; } + public List Values { get; private set; } + public bool MultipleValues { get; set; } + public string Value => Values.FirstOrDefault(); + } +} diff --git a/src/GetDocumentInsider/CommandLineUtils/CommandLineApplication.cs b/src/GetDocumentInsider/CommandLineUtils/CommandLineApplication.cs new file mode 100644 index 0000000000..facbb68ad0 --- /dev/null +++ b/src/GetDocumentInsider/CommandLineUtils/CommandLineApplication.cs @@ -0,0 +1,604 @@ +// 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; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Cli.CommandLine +{ + internal class CommandLineApplication + { + private enum ParseOptionResult + { + Succeeded, + ShowHelp, + ShowVersion, + UnexpectedArgs, + } + + // Indicates whether the parser should throw an exception when it runs into an unexpected argument. + // If this field is set to false, the parser will stop parsing when it sees an unexpected argument, and all + // remaining arguments, including the first unexpected argument, will be stored in RemainingArguments property. + private readonly bool _throwOnUnexpectedArg; + + public CommandLineApplication(bool throwOnUnexpectedArg = true) + { + _throwOnUnexpectedArg = throwOnUnexpectedArg; + Options = new List(); + Arguments = new List(); + Commands = new List(); + RemainingArguments = new List(); + Invoke = () => 0; + } + + public CommandLineApplication Parent { get; set; } + public string Name { get; set; } + public string FullName { get; set; } + public string Syntax { get; set; } + public string Description { get; set; } + public List Options { get; private set; } + public CommandOption OptionHelp { get; private set; } + public CommandOption OptionVersion { get; private set; } + public List Arguments { get; private set; } + public List RemainingArguments { get; private set; } + public bool IsShowingInformation { get; protected set; } // Is showing help or version? + public Func Invoke { get; set; } + public Func LongVersionGetter { get; set; } + public Func ShortVersionGetter { get; set; } + public List Commands { get; private set; } + public bool HandleResponseFiles { get; set; } + public bool AllowArgumentSeparator { get; set; } + public bool HandleRemainingArguments { get; set; } + public string ArgumentSeparatorHelpText { get; set; } + + public CommandLineApplication Command(string name, bool throwOnUnexpectedArg = true) + => Command(name, _ => { }, throwOnUnexpectedArg); + + public CommandLineApplication Command(string name, Action configuration, + bool throwOnUnexpectedArg = true) + { + var command = new CommandLineApplication(throwOnUnexpectedArg) { Name = name, Parent = this }; + Commands.Add(command); + configuration(command); + return command; + } + + public CommandOption Option(string template, string description, CommandOptionType optionType) + => Option(template, description, optionType, _ => { }); + + public CommandOption Option(string template, string description, CommandOptionType optionType, Action configuration) + { + var option = new CommandOption(template, optionType) { Description = description }; + Options.Add(option); + configuration(option); + return option; + } + + public CommandArgument Argument(string name, string description, bool multipleValues = false) + => Argument(name, description, _ => { }, multipleValues); + + public CommandArgument Argument(string name, string description, Action configuration, bool multipleValues = false) + { + var lastArg = Arguments.LastOrDefault(); + if (lastArg != null && lastArg.MultipleValues) + { + var message = string.Format("The last argument '{0}' accepts multiple values. No more argument can be added.", + lastArg.Name); + throw new InvalidOperationException(message); + } + + var argument = new CommandArgument { Name = name, Description = description, MultipleValues = multipleValues }; + Arguments.Add(argument); + configuration(argument); + return argument; + } + + public void OnExecute(Func invoke) => Invoke = invoke; + + public void OnExecute(Func> invoke) => Invoke = () => invoke().Result; + + public int Execute(params string[] args) + { + var command = this; + IEnumerator arguments = null; + + if (HandleResponseFiles) + { + args = ExpandResponseFiles(args).ToArray(); + } + + for (var index = 0; index < args.Length; index++) + { + var arg = args[index]; + + var isLongOption = arg.StartsWith("--"); + if (isLongOption || arg.StartsWith("-")) + { + var result = ParseOption(isLongOption, command, args, ref index, out var option); + if (result == ParseOptionResult.ShowHelp) + { + command.ShowHelp(); + return 0; + } + else if (result == ParseOptionResult.ShowVersion) + { + command.ShowVersion(); + return 0; + } + } + else + { + var subcommand = ParseSubCommand(arg, command); + if (subcommand != null) + { + command = subcommand; + } + else + { + if (arguments == null) + { + arguments = new CommandArgumentEnumerator(command.Arguments.GetEnumerator()); + } + + if (arguments.MoveNext()) + { + arguments.Current.Values.Add(arg); + } + else + { + HandleUnexpectedArg(command, args, index, argTypeName: "command or argument"); + } + } + } + } + + return command.Invoke(); + } + + private ParseOptionResult ParseOption( + bool isLongOption, + CommandLineApplication command, + string[] args, + ref int index, + out CommandOption option) + { + option = null; + var result = ParseOptionResult.Succeeded; + var arg = args[index]; + + var optionPrefixLength = isLongOption ? 2 : 1; + var optionComponents = arg.Substring(optionPrefixLength).Split(new[] { ':', '=' }, 2); + var optionName = optionComponents[0]; + + if (isLongOption) + { + option = command.Options.SingleOrDefault( + opt => string.Equals(opt.LongName, optionName, StringComparison.Ordinal)); + } + else + { + option = command.Options.SingleOrDefault( + opt => string.Equals(opt.ShortName, optionName, StringComparison.Ordinal)); + + if (option == null) + { + option = command.Options.SingleOrDefault( + opt => string.Equals(opt.SymbolName, optionName, StringComparison.Ordinal)); + } + } + + if (option == null) + { + if (isLongOption && string.IsNullOrEmpty(optionName) && + !command._throwOnUnexpectedArg && AllowArgumentSeparator) + { + // a stand-alone "--" is the argument separator, so skip it and + // handle the rest of the args as unexpected args + index++; + } + + HandleUnexpectedArg(command, args, index, argTypeName: "option"); + result = ParseOptionResult.UnexpectedArgs; + } + else if (command.OptionHelp == option) + { + result = ParseOptionResult.ShowHelp; + } + else if (command.OptionVersion == option) + { + result = ParseOptionResult.ShowVersion; + } + else + { + if (optionComponents.Length == 2) + { + if (!option.TryParse(optionComponents[1])) + { + command.ShowHint(); + throw new CommandParsingException(command, + $"Unexpected value '{optionComponents[1]}' for option '{optionName}'"); + } + } + else + { + if (option.OptionType == CommandOptionType.NoValue || + option.OptionType == CommandOptionType.BoolValue) + { + // No value is needed for this option + option.TryParse(null); + } + else + { + index++; + arg = args[index]; + if (!option.TryParse(arg)) + { + command.ShowHint(); + throw new CommandParsingException(command, $"Unexpected value '{arg}' for option '{optionName}'"); + + } + } + } + } + + return result; + } + + private static CommandLineApplication ParseSubCommand(string arg, CommandLineApplication command) + { + foreach (var subcommand in command.Commands) + { + if (string.Equals(subcommand.Name, arg, StringComparison.OrdinalIgnoreCase)) + { + return subcommand; + } + } + + return null; + } + + // Helper method that adds a help option + public CommandOption HelpOption(string template) + { + // Help option is special because we stop parsing once we see it + // So we store it separately for further use + OptionHelp = Option(template, "Show help information", CommandOptionType.NoValue); + + return OptionHelp; + } + + public CommandOption VersionOption(string template, + string shortFormVersion, + string longFormVersion = null) + { + if (longFormVersion == null) + { + return VersionOption(template, () => shortFormVersion); + } + else + { + return VersionOption(template, () => shortFormVersion, () => longFormVersion); + } + } + + // Helper method that adds a version option + public CommandOption VersionOption(string template, + Func shortFormVersionGetter, + Func longFormVersionGetter = null) + { + // Version option is special because we stop parsing once we see it + // So we store it separately for further use + OptionVersion = Option(template, "Show version information", CommandOptionType.NoValue); + ShortVersionGetter = shortFormVersionGetter; + LongVersionGetter = longFormVersionGetter ?? shortFormVersionGetter; + + return OptionVersion; + } + + // Show short hint that reminds users to use help option + public void ShowHint() + { + if (OptionHelp != null) + { + Console.WriteLine(string.Format("Specify --{0} for a list of available options and commands.", OptionHelp.LongName)); + } + } + + // Show full help + public void ShowHelp(string commandName = null) + { + var headerBuilder = new StringBuilder("Usage:"); + var usagePrefixLength = headerBuilder.Length; + for (var cmd = this; cmd != null; cmd = cmd.Parent) + { + cmd.IsShowingInformation = true; + if (cmd != this && cmd.Arguments.Any()) + { + var args = string.Join(" ", cmd.Arguments.Select(arg => arg.Name)); + headerBuilder.Insert(usagePrefixLength, string.Format(" {0} {1}", cmd.Name, args)); + } + else + { + headerBuilder.Insert(usagePrefixLength, string.Format(" {0}", cmd.Name)); + } + } + + CommandLineApplication target; + + if (commandName == null || string.Equals(Name, commandName, StringComparison.OrdinalIgnoreCase)) + { + target = this; + } + else + { + target = Commands.SingleOrDefault(cmd => string.Equals(cmd.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + if (target != null) + { + headerBuilder.AppendFormat(" {0}", commandName); + } + else + { + // The command name is invalid so don't try to show help for something that doesn't exist + target = this; + } + + } + + var optionsBuilder = new StringBuilder(); + var commandsBuilder = new StringBuilder(); + var argumentsBuilder = new StringBuilder(); + var argumentSeparatorBuilder = new StringBuilder(); + + var maxArgLen = 0; + for (var cmd = target; cmd != null; cmd = cmd.Parent) + { + if (cmd.Arguments.Any()) + { + if (cmd == target) + { + headerBuilder.Append(" [arguments]"); + } + + if (argumentsBuilder.Length == 0) + { + argumentsBuilder.AppendLine(); + argumentsBuilder.AppendLine("Arguments:"); + } + + maxArgLen = Math.Max(maxArgLen, MaxArgumentLength(cmd.Arguments)); + } + } + + for (var cmd = target; cmd != null; cmd = cmd.Parent) + { + if (cmd.Arguments.Any()) + { + var outputFormat = " {0}{1}"; + foreach (var arg in cmd.Arguments) + { + argumentsBuilder.AppendFormat( + outputFormat, + arg.Name.PadRight(maxArgLen + 2), + arg.Description); + argumentsBuilder.AppendLine(); + } + } + } + + if (target.Options.Any()) + { + headerBuilder.Append(" [options]"); + + optionsBuilder.AppendLine(); + optionsBuilder.AppendLine("Options:"); + var maxOptLen = MaxOptionTemplateLength(target.Options); + var outputFormat = string.Format(" {{0, -{0}}}{{1}}", maxOptLen + 2); + foreach (var opt in target.Options) + { + optionsBuilder.AppendFormat(outputFormat, opt.Template, opt.Description); + optionsBuilder.AppendLine(); + } + } + + if (target.Commands.Any()) + { + headerBuilder.Append(" [command]"); + + commandsBuilder.AppendLine(); + commandsBuilder.AppendLine("Commands:"); + var maxCmdLen = MaxCommandLength(target.Commands); + var outputFormat = string.Format(" {{0, -{0}}}{{1}}", maxCmdLen + 2); + foreach (var cmd in target.Commands.OrderBy(c => c.Name)) + { + commandsBuilder.AppendFormat(outputFormat, cmd.Name, cmd.Description); + commandsBuilder.AppendLine(); + } + + if (OptionHelp != null) + { + commandsBuilder.AppendLine(); + commandsBuilder.AppendFormat("Use \"{0} [command] --help\" for more information about a command.", Name); + commandsBuilder.AppendLine(); + } + } + + if (target.AllowArgumentSeparator || target.HandleRemainingArguments) + { + if (target.AllowArgumentSeparator) + { + headerBuilder.Append(" [[--] ...]]"); + } + else + { + headerBuilder.Append(" [args]"); + } + + if (!string.IsNullOrEmpty(target.ArgumentSeparatorHelpText)) + { + argumentSeparatorBuilder.AppendLine(); + argumentSeparatorBuilder.AppendLine("Args:"); + argumentSeparatorBuilder.AppendLine($" {target.ArgumentSeparatorHelpText}"); + argumentSeparatorBuilder.AppendLine(); + } + } + + headerBuilder.AppendLine(); + + var nameAndVersion = new StringBuilder(); + nameAndVersion.AppendLine(GetFullNameAndVersion()); + nameAndVersion.AppendLine(); + + Console.Write("{0}{1}{2}{3}{4}{5}", nameAndVersion, headerBuilder, argumentsBuilder, optionsBuilder, commandsBuilder, argumentSeparatorBuilder); + } + + public void ShowVersion() + { + for (var cmd = this; cmd != null; cmd = cmd.Parent) + { + cmd.IsShowingInformation = true; + } + + Console.WriteLine(FullName); + Console.WriteLine(LongVersionGetter()); + } + + public string GetFullNameAndVersion() + => ShortVersionGetter == null ? FullName : string.Format("{0} {1}", FullName, ShortVersionGetter()); + + public void ShowRootCommandFullNameAndVersion() + { + var rootCmd = this; + while (rootCmd.Parent != null) + { + rootCmd = rootCmd.Parent; + } + + Console.WriteLine(rootCmd.GetFullNameAndVersion()); + Console.WriteLine(); + } + + private static int MaxOptionTemplateLength(IEnumerable options) + { + var maxLen = 0; + foreach (var opt in options) + { + maxLen = opt.Template.Length > maxLen ? opt.Template.Length : maxLen; + } + return maxLen; + } + + private static int MaxCommandLength(IEnumerable commands) + { + var maxLen = 0; + foreach (var cmd in commands) + { + maxLen = cmd.Name.Length > maxLen ? cmd.Name.Length : maxLen; + } + return maxLen; + } + + private static int MaxArgumentLength(IEnumerable arguments) + { + var maxLen = 0; + foreach (var arg in arguments) + { + maxLen = arg.Name.Length > maxLen ? arg.Name.Length : maxLen; + } + return maxLen; + } + + private static void HandleUnexpectedArg(CommandLineApplication command, string[] args, int index, string argTypeName) + { + if (command._throwOnUnexpectedArg) + { + command.ShowHint(); + throw new CommandParsingException(command, $"Unrecognized {argTypeName} '{args[index]}'"); + } + else + { + command.RemainingArguments.Add(args[index]); + } + } + + private IEnumerable ExpandResponseFiles(IEnumerable args) + { + foreach (var arg in args) + { + if (!arg.StartsWith("@", StringComparison.Ordinal)) + { + yield return arg; + } + else + { + var fileName = arg.Substring(1); + + var responseFileArguments = ParseResponseFile(fileName); + + // ParseResponseFile can suppress expanding this response file by + // returning null. In that case, we'll treat the response + // file token as a regular argument. + + if (responseFileArguments == null) + { + yield return arg; + } + else + { + foreach (var responseFileArgument in responseFileArguments) + { + yield return responseFileArgument.Trim(); + } + } + } + } + } + + private IEnumerable ParseResponseFile(string fileName) + { + if (!HandleResponseFiles) + { + return null; + } + + if (!File.Exists(fileName)) + { + throw new InvalidOperationException($"Response file '{fileName}' doesn't exist."); + } + + return File.ReadLines(fileName); + } + + private class CommandArgumentEnumerator : IEnumerator + { + private readonly IEnumerator _enumerator; + + public CommandArgumentEnumerator(IEnumerator enumerator) => _enumerator = enumerator; + + public CommandArgument Current => _enumerator.Current; + + object IEnumerator.Current => Current; + + public void Dispose() => _enumerator.Dispose(); + + public bool MoveNext() + { + if (Current == null || !Current.MultipleValues) + { + return _enumerator.MoveNext(); + } + + // If current argument allows multiple values, we don't move forward and + // all later values will be added to current CommandArgument.Values + return true; + } + + public void Reset() => _enumerator.Reset(); + } + } +} diff --git a/src/GetDocumentInsider/CommandLineUtils/CommandLineApplicationExtensions.cs b/src/GetDocumentInsider/CommandLineUtils/CommandLineApplicationExtensions.cs new file mode 100644 index 0000000000..1c43455ee1 --- /dev/null +++ b/src/GetDocumentInsider/CommandLineUtils/CommandLineApplicationExtensions.cs @@ -0,0 +1,18 @@ +// 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. + +namespace Microsoft.DotNet.Cli.CommandLine +{ + internal static class CommandLineApplicationExtensions + { + public static CommandOption Option(this CommandLineApplication command, string template, string description) + => command.Option( + template, + description, + template.IndexOf('<') != -1 + ? template.EndsWith(">...") + ? CommandOptionType.MultipleValue + : CommandOptionType.SingleValue + : CommandOptionType.NoValue); + } +} diff --git a/src/GetDocumentInsider/CommandLineUtils/CommandOption.cs b/src/GetDocumentInsider/CommandLineUtils/CommandOption.cs new file mode 100644 index 0000000000..5ba4b78ae3 --- /dev/null +++ b/src/GetDocumentInsider/CommandLineUtils/CommandOption.cs @@ -0,0 +1,125 @@ +// 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.Linq; + +namespace Microsoft.DotNet.Cli.CommandLine +{ + internal class CommandOption + { + public CommandOption(string template, CommandOptionType optionType) + { + Template = template; + OptionType = optionType; + Values = new List(); + + foreach (var part in Template.Split(new[] { ' ', '|' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (part.StartsWith("--")) + { + LongName = part.Substring(2); + } + else if (part.StartsWith("-")) + { + var optName = part.Substring(1); + + // If there is only one char and it is not an English letter, it is a symbol option (e.g. "-?") + if (optName.Length == 1 && !IsEnglishLetter(optName[0])) + { + SymbolName = optName; + } + else + { + ShortName = optName; + } + } + else if (part.StartsWith("<") && part.EndsWith(">")) + { + ValueName = part.Substring(1, part.Length - 2); + } + else if (optionType == CommandOptionType.MultipleValue && part.StartsWith("<") && part.EndsWith(">...")) + { + ValueName = part.Substring(1, part.Length - 5); + } + else + { + throw new ArgumentException($"Invalid template pattern '{template}'", nameof(template)); + } + } + + if (string.IsNullOrEmpty(LongName) && string.IsNullOrEmpty(ShortName) && string.IsNullOrEmpty(SymbolName)) + { + throw new ArgumentException($"Invalid template pattern '{template}'", nameof(template)); + } + } + + public string Template { get; set; } + public string ShortName { get; set; } + public string LongName { get; set; } + public string SymbolName { get; set; } + public string ValueName { get; set; } + public string Description { get; set; } + public List Values { get; private set; } + public bool? BoolValue { get; private set; } + public CommandOptionType OptionType { get; private set; } + + public bool TryParse(string value) + { + switch (OptionType) + { + case CommandOptionType.MultipleValue: + Values.Add(value); + break; + case CommandOptionType.SingleValue: + if (Values.Any()) + { + return false; + } + Values.Add(value); + break; + case CommandOptionType.BoolValue: + if (Values.Any()) + { + return false; + } + + if (value == null) + { + // add null to indicate that the option was present, but had no value + Values.Add(null); + BoolValue = true; + } + else + { + if (!bool.TryParse(value, out var boolValue)) + { + return false; + } + + Values.Add(value); + BoolValue = boolValue; + } + break; + case CommandOptionType.NoValue: + if (value != null) + { + return false; + } + // Add a value to indicate that this option was specified + Values.Add("on"); + break; + default: + break; + } + return true; + } + + public bool HasValue() => Values.Any(); + + public string Value() => HasValue() ? Values[0] : null; + + private static bool IsEnglishLetter(char c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } +} diff --git a/src/GetDocumentInsider/CommandLineUtils/CommandOptionType.cs b/src/GetDocumentInsider/CommandLineUtils/CommandOptionType.cs new file mode 100644 index 0000000000..5f7d37f029 --- /dev/null +++ b/src/GetDocumentInsider/CommandLineUtils/CommandOptionType.cs @@ -0,0 +1,13 @@ +// 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. + +namespace Microsoft.DotNet.Cli.CommandLine +{ + internal enum CommandOptionType + { + MultipleValue, + SingleValue, + BoolValue, + NoValue + } +} diff --git a/src/GetDocumentInsider/CommandLineUtils/CommandParsingException.cs b/src/GetDocumentInsider/CommandLineUtils/CommandParsingException.cs new file mode 100644 index 0000000000..c735ecbf12 --- /dev/null +++ b/src/GetDocumentInsider/CommandLineUtils/CommandParsingException.cs @@ -0,0 +1,15 @@ +// 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 Microsoft.DotNet.Cli.CommandLine +{ + internal class CommandParsingException : Exception + { + public CommandParsingException(CommandLineApplication command, string message) + : base(message) => Command = command; + + public CommandLineApplication Command { get; } + } +} diff --git a/src/GetDocumentInsider/Commands/CommandBase.cs b/src/GetDocumentInsider/Commands/CommandBase.cs new file mode 100644 index 0000000000..ac9a4b1a37 --- /dev/null +++ b/src/GetDocumentInsider/Commands/CommandBase.cs @@ -0,0 +1,38 @@ +// 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 Microsoft.DotNet.Cli.CommandLine; + +namespace Microsoft.Extensions.ApiDescription.Tool.Commands +{ + internal abstract class CommandBase + { + public virtual void Configure(CommandLineApplication command) + { + var verbose = command.Option("-v|--verbose", Resources.VerboseDescription); + var noColor = command.Option("--no-color", Resources.NoColorDescription); + var prefixOutput = command.Option("--prefix-output", Resources.PrefixDescription); + + command.HandleResponseFiles = true; + + command.OnExecute( + () => + { + Reporter.IsVerbose = verbose.HasValue(); + Reporter.NoColor = noColor.HasValue(); + Reporter.PrefixOutput = prefixOutput.HasValue(); + + Validate(); + + return Execute(); + }); + } + + protected virtual void Validate() + { + } + + protected virtual int Execute() + => 0; + } +} diff --git a/src/GetDocumentInsider/Commands/GetDocumentCommand.cs b/src/GetDocumentInsider/Commands/GetDocumentCommand.cs new file mode 100644 index 0000000000..bf80df802b --- /dev/null +++ b/src/GetDocumentInsider/Commands/GetDocumentCommand.cs @@ -0,0 +1,167 @@ +// 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.IO; +using System.Linq; +using System.Reflection; +#if NETCOREAPP2_0 +using System.Runtime.Loader; +#endif +using Microsoft.DotNet.Cli.CommandLine; + +namespace Microsoft.Extensions.ApiDescription.Tool.Commands +{ + internal class GetDocumentCommand : ProjectCommandBase + { + internal const string FallbackDocumentName = "v1"; + internal const string FallbackMethod = "Generate"; + internal const string FallbackService = "Microsoft.Extensions.ApiDescription.IDocumentProvider"; + + private CommandOption _documentName; + private CommandOption _method; + private CommandOption _output; + private CommandOption _service; + + public override void Configure(CommandLineApplication command) + { + base.Configure(command); + + _documentName = command.Option( + "--documentName ", + Resources.FormatDocumentDescription(FallbackDocumentName)); + _method = command.Option("--method ", Resources.FormatMethodDescription(FallbackMethod)); + _output = command.Option("--output ", Resources.OutputDescription); + _service = command.Option("--service ", Resources.FormatServiceDescription(FallbackService)); + } + + protected override void Validate() + { + base.Validate(); + + if (!_output.HasValue()) + { + throw new CommandException(Resources.FormatMissingOption(_output.LongName)); + } + + if (_method.HasValue() && !_service.HasValue()) + { + throw new CommandException(Resources.FormatMissingOption(_service.LongName)); + } + + if (_service.HasValue() && !_method.HasValue()) + { + throw new CommandException(Resources.FormatMissingOption(_method.LongName)); + } + } + + protected override int Execute() + { + var thisAssembly = typeof(GetDocumentCommand).Assembly; + + var toolsDirectory = ToolsDirectory.Value(); + var packagedAssemblies = Directory + .EnumerateFiles(toolsDirectory, "*.dll") + .Except(new[] { Path.GetFullPath(thisAssembly.Location) }) + .ToDictionary(path => Path.GetFileNameWithoutExtension(path), path => new AssemblyInfo(path)); + + // Explicitly load all assemblies we need first to preserve target project as much as possible. This + // executable is always run in the target project's context (either through location or .deps.json file). + foreach (var keyValuePair in packagedAssemblies) + { + try + { + keyValuePair.Value.Assembly = Assembly.Load(new AssemblyName(keyValuePair.Key)); + } + catch + { + // Ignore all failures because missing assemblies should be loadable from tools directory. + } + } + +#if NETCOREAPP2_0 + AssemblyLoadContext.Default.Resolving += (loadContext, assemblyName) => + { + var name = assemblyName.Name; + if (!packagedAssemblies.TryGetValue(name, out var info)) + { + return null; + } + + var assemblyPath = info.Path; + if (!File.Exists(assemblyPath)) + { + throw new InvalidOperationException( + $"Referenced assembly '{name}' was not found in '{toolsDirectory}'."); + } + + return loadContext.LoadFromAssemblyPath(assemblyPath); + }; + +#elif NET461 + AppDomain.CurrentDomain.AssemblyResolve += (source, eventArgs) => + { + var assemblyName = new AssemblyName(eventArgs.Name); + var name = assemblyName.Name; + if (!packagedAssemblies.TryGetValue(name, out var info)) + { + return null; + } + + var assembly = info.Assembly; + if (assembly != null) + { + // Loaded already + return assembly; + } + + var assemblyPath = info.Path; + if (!File.Exists(assemblyPath)) + { + throw new InvalidOperationException( + $"Referenced assembly '{name}' was not found in '{toolsDirectory}'."); + } + + return Assembly.LoadFile(assemblyPath); + }; +#else +#error target frameworks need to be updated. +#endif + + // Now safe to reference the application's code. + try + { + var assemblyPath = AssemblyPath.Value(); + var context = new GetDocumentCommandContext + { + AssemblyPath = assemblyPath, + AssemblyDirectory = Path.GetDirectoryName(assemblyPath), + AssemblyName = Path.GetFileNameWithoutExtension(assemblyPath), + DocumentName = _documentName.Value(), + Method = _method.Value(), + Output = _output.Value(), + Service = _service.Value(), + }; + + return GetDocumentCommandWorker.Process(context); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.ToString()); + return 1; + } + } + + private class AssemblyInfo + { + public AssemblyInfo(string path) + { + Path = path; + } + + public string Path { get; } + + public Assembly Assembly { get; set; } + } + } +} diff --git a/src/GetDocumentInsider/Commands/GetDocumentCommandContext.cs b/src/GetDocumentInsider/Commands/GetDocumentCommandContext.cs new file mode 100644 index 0000000000..208139c12f --- /dev/null +++ b/src/GetDocumentInsider/Commands/GetDocumentCommandContext.cs @@ -0,0 +1,25 @@ +// 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 Microsoft.Extensions.ApiDescription.Tool.Commands +{ + [Serializable] + public class GetDocumentCommandContext + { + public string AssemblyDirectory { get; set; } + + public string AssemblyName { get; set; } + + public string AssemblyPath { get; set; } + + public string DocumentName { get; set; } + + public string Method { get; set; } + + public string Output { get; set; } + + public string Service { get; set; } + } +} diff --git a/src/GetDocumentInsider/Commands/GetDocumentCommandWorker.cs b/src/GetDocumentInsider/Commands/GetDocumentCommandWorker.cs new file mode 100644 index 0000000000..752d65861f --- /dev/null +++ b/src/GetDocumentInsider/Commands/GetDocumentCommandWorker.cs @@ -0,0 +1,167 @@ +// 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.IO; +using System.Reflection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.ApiDescription.Tool.Commands +{ + internal class GetDocumentCommandWorker + { + public static int Process(GetDocumentCommandContext context) + { + var assemblyName = new AssemblyName(context.AssemblyName); + var assembly = Assembly.Load(assemblyName); + var entryPointType = assembly.EntryPoint?.DeclaringType; + if (entryPointType == null) + { + Reporter.WriteError(Resources.FormatMissingEntryPoint(context.AssemblyPath)); + return 2; + } + + var services = GetServices(entryPointType, context.AssemblyPath, context.AssemblyName); + if (services == null) + { + return 3; + } + + var success = TryProcess(context, services); + if (!success) + { + // As part of the aspnet/Mvc#8425 fix, return 4 here. + return 0; + } + + return 0; + } + + public static bool TryProcess(GetDocumentCommandContext context, IServiceProvider services) + { + var documentName = string.IsNullOrEmpty(context.DocumentName) ? + GetDocumentCommand.FallbackDocumentName : + context.DocumentName; + var methodName = string.IsNullOrEmpty(context.Method) ? + GetDocumentCommand.FallbackMethod : + context.Method; + var serviceName = string.IsNullOrEmpty(context.Service) ? + GetDocumentCommand.FallbackService : + context.Service; + + Reporter.WriteInformation(Resources.FormatUsingDocument(documentName)); + Reporter.WriteInformation(Resources.FormatUsingMethod(methodName)); + Reporter.WriteInformation(Resources.FormatUsingService(serviceName)); + + try + { + var serviceType = Type.GetType(serviceName, throwOnError: true); + var method = serviceType.GetMethod(methodName, new[] { typeof(TextWriter), typeof(string) }); + var service = services.GetRequiredService(serviceType); + + var success = true; + using (var writer = File.CreateText(context.Output)) + { + if (method.ReturnType == typeof(bool)) + { + success = (bool)method.Invoke(service, new object[] { writer, documentName }); + } + else + { + method.Invoke(service, new object[] { writer, documentName }); + } + } + + if (!success) + { + // As part of the aspnet/Mvc#8425 fix, make this an error unless the file already exists. + var message = Resources.FormatMethodInvocationFailed(methodName, serviceName, documentName); + Reporter.WriteWarning(message); + } + + return success; + } + catch (Exception ex) + { + var message = FormatException(ex); + + // As part of the aspnet/Mvc#8425 fix, make this an error unless the file already exists. + Reporter.WriteWarning(message); + + return false; + } + } + + // TODO: Use Microsoft.AspNetCore.Hosting.WebHostBuilderFactory.Sources once we have dev feed available. + private static IServiceProvider GetServices(Type entryPointType, string assemblyPath, string assemblyName) + { + var args = new[] { Array.Empty() }; + var methodInfo = entryPointType.GetMethod("BuildWebHost"); + if (methodInfo != null) + { + // BuildWebHost (old style has highest priority) + var parameters = methodInfo.GetParameters(); + if (!methodInfo.IsStatic || + parameters.Length != 1 || + typeof(string[]) != parameters[0].ParameterType || + typeof(IWebHost) != methodInfo.ReturnType) + { + Reporter.WriteError( + "BuildWebHost method found in {assemblyPath} does not have expected signature."); + + return null; + } + + try + { + var webHost = (IWebHost)methodInfo.Invoke(obj: null, parameters: args); + + return webHost.Services; + } + catch (Exception ex) + { + Reporter.WriteError($"BuildWebHost method threw: {FormatException(ex)}"); + + return null; + } + } + + if ((methodInfo = entryPointType.GetMethod("CreateWebHostBuilder")) != null) + { + // CreateWebHostBuilder + var parameters = methodInfo.GetParameters(); + if (!methodInfo.IsStatic || + parameters.Length != 1 || + typeof(string[]) != parameters[0].ParameterType || + typeof(IWebHostBuilder) != methodInfo.ReturnType) + { + Reporter.WriteError( + "CreateWebHostBuilder method found in {assemblyPath} does not have expected signature."); + + return null; + } + + try + { + var builder = (IWebHostBuilder)methodInfo.Invoke(obj: null, parameters: args); + + return builder.Build().Services; + } + catch (Exception ex) + { + Reporter.WriteError($"CreateWebHostBuilder method threw: {FormatException(ex)}"); + + return null; + } + } + + return null; + } + + private static string FormatException(Exception exception) + { + return $"{exception.GetType().FullName}: {exception.Message}"; + } + } +} diff --git a/src/GetDocumentInsider/Commands/HelpCommandBase.cs b/src/GetDocumentInsider/Commands/HelpCommandBase.cs new file mode 100644 index 0000000000..55e84272ac --- /dev/null +++ b/src/GetDocumentInsider/Commands/HelpCommandBase.cs @@ -0,0 +1,17 @@ +// 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 Microsoft.DotNet.Cli.CommandLine; + +namespace Microsoft.Extensions.ApiDescription.Tool.Commands +{ + internal class HelpCommandBase : CommandBase + { + public override void Configure(CommandLineApplication command) + { + base.Configure(command); + + command.HelpOption("-h|--help"); + } + } +} diff --git a/src/GetDocumentInsider/Commands/ProjectCommandBase.cs b/src/GetDocumentInsider/Commands/ProjectCommandBase.cs new file mode 100644 index 0000000000..8e60d9603f --- /dev/null +++ b/src/GetDocumentInsider/Commands/ProjectCommandBase.cs @@ -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 Microsoft.DotNet.Cli.CommandLine; + +namespace Microsoft.Extensions.ApiDescription.Tool.Commands +{ + internal abstract class ProjectCommandBase : HelpCommandBase + { + public CommandOption AssemblyPath { get; private set; } + + public CommandOption ToolsDirectory { get; private set; } + + public override void Configure(CommandLineApplication command) + { + base.Configure(command); + + AssemblyPath = command.Option("-a|--assembly ", Resources.AssemblyDescription); + ToolsDirectory = command.Option("--tools-directory ", Resources.ToolsDirectoryDescription); + } + + protected override void Validate() + { + base.Validate(); + + if (!AssemblyPath.HasValue()) + { + throw new CommandException(Resources.FormatMissingOption(AssemblyPath.LongName)); + } + + if (!ToolsDirectory.HasValue()) + { + throw new CommandException(Resources.FormatMissingOption(ToolsDirectory.LongName)); + } + } + } +} diff --git a/src/GetDocumentInsider/GetDocumentInsider.csproj b/src/GetDocumentInsider/GetDocumentInsider.csproj new file mode 100644 index 0000000000..03d6440c1f --- /dev/null +++ b/src/GetDocumentInsider/GetDocumentInsider.csproj @@ -0,0 +1,22 @@ + + + GetDocument.Insider + GetDocument Command-line Tool inside man + false + Exe + Microsoft.Extensions.ApiDescription.Tool + netcoreapp2.0;net461 + + + + + + + + + + + + + + diff --git a/src/GetDocumentInsider/ProductInfo.cs b/src/GetDocumentInsider/ProductInfo.cs new file mode 100644 index 0000000000..c57bc65d10 --- /dev/null +++ b/src/GetDocumentInsider/ProductInfo.cs @@ -0,0 +1,16 @@ +// 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.Reflection; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal static class ProductInfo + { + public static string GetVersion() + => typeof(ProductInfo) + .Assembly + .GetCustomAttribute() + .InformationalVersion; + } +} diff --git a/src/GetDocumentInsider/Program.cs b/src/GetDocumentInsider/Program.cs new file mode 100644 index 0000000000..6d144dc5d5 --- /dev/null +++ b/src/GetDocumentInsider/Program.cs @@ -0,0 +1,48 @@ +// 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.Text; +using Microsoft.DotNet.Cli.CommandLine; +using Microsoft.Extensions.ApiDescription.Tool.Commands; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal static class Program + { + private static int Main(string[] args) + { + if (Console.IsOutputRedirected) + { + Console.OutputEncoding = Encoding.UTF8; + } + + var app = new CommandLineApplication(throwOnUnexpectedArg: false) + { + Name = "GetDocument.Insider" + }; + + new GetDocumentCommand().Configure(app); + + try + { + return app.Execute(args); + } + catch (Exception ex) + { + if (ex is CommandException || ex is CommandParsingException) + { + Reporter.WriteVerbose(ex.ToString()); + } + else + { + Reporter.WriteInformation(ex.ToString()); + } + + Reporter.WriteError(ex.Message); + + return 1; + } + } + } +} diff --git a/src/GetDocumentInsider/Properties/Resources.Designer.cs b/src/GetDocumentInsider/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..eaec18f2fe --- /dev/null +++ b/src/GetDocumentInsider/Properties/Resources.Designer.cs @@ -0,0 +1,240 @@ +// +namespace Microsoft.Extensions.ApiDescription.Tool +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.Extensions.ApiDescription.Tool.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// The assembly to use. + /// + internal static string AssemblyDescription + { + get => GetString("AssemblyDescription"); + } + + /// + /// The assembly to use. + /// + internal static string FormatAssemblyDescription() + => GetString("AssemblyDescription"); + + /// + /// Missing required option '--{0}'. + /// + internal static string MissingOption + { + get => GetString("MissingOption"); + } + + /// + /// Missing required option '--{0}'. + /// + internal static string FormatMissingOption(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("MissingOption"), p0); + + /// + /// Do not colorize output. + /// + internal static string NoColorDescription + { + get => GetString("NoColorDescription"); + } + + /// + /// Do not colorize output. + /// + internal static string FormatNoColorDescription() + => GetString("NoColorDescription"); + + /// + /// The file to write the result to. + /// + internal static string OutputDescription + { + get => GetString("OutputDescription"); + } + + /// + /// The file to write the result to. + /// + internal static string FormatOutputDescription() + => GetString("OutputDescription"); + + /// + /// Prefix console output with logging level. + /// + internal static string PrefixDescription + { + get => GetString("PrefixDescription"); + } + + /// + /// Prefix console output with logging level. + /// + internal static string FormatPrefixDescription() + => GetString("PrefixDescription"); + + /// + /// Show verbose output. + /// + internal static string VerboseDescription + { + get => GetString("VerboseDescription"); + } + + /// + /// Show verbose output. + /// + internal static string FormatVerboseDescription() + => GetString("VerboseDescription"); + + /// + /// Location from which inside man was copied (in the .NET Framework case) or loaded. + /// + internal static string ToolsDirectoryDescription + { + get => GetString("ToolsDirectoryDescription"); + } + + /// + /// Location from which inside man was copied (in the .NET Framework case) or loaded. + /// + internal static string FormatToolsDirectoryDescription() + => GetString("ToolsDirectoryDescription"); + + /// + /// The name of the method to invoke on the '--service' instance. Default value '{0}'. + /// + internal static string MethodDescription + { + get => GetString("MethodDescription"); + } + + /// + /// The name of the method to invoke on the '--service' instance. Default value '{0}'. + /// + internal static string FormatMethodDescription(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("MethodDescription"), p0); + + /// + /// The qualified name of the service type to retrieve from dependency injection. Default value '{0}'. + /// + internal static string ServiceDescription + { + get => GetString("ServiceDescription"); + } + + /// + /// The qualified name of the service type to retrieve from dependency injection. Default value '{0}'. + /// + internal static string FormatServiceDescription(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("ServiceDescription"), p0); + + /// + /// The name of the document to pass to the '--method' method. Default value '{0}'. + /// + internal static string DocumentDescription + { + get => GetString("DocumentDescription"); + } + + /// + /// The name of the document to pass to the '--method' method. Default value '{0}'. + /// + internal static string FormatDocumentDescription(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("DocumentDescription"), p0); + + /// + /// Using document name '{0}'. + /// + internal static string UsingDocument + { + get => GetString("UsingDocument"); + } + + /// + /// Using document name '{0}'. + /// + internal static string FormatUsingDocument(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("UsingDocument"), p0); + + /// + /// Using method '{0}'. + /// + internal static string UsingMethod + { + get => GetString("UsingMethod"); + } + + /// + /// Using method '{0}'. + /// + internal static string FormatUsingMethod(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("UsingMethod"), p0); + + /// + /// Using service '{0}'. + /// + internal static string UsingService + { + get => GetString("UsingService"); + } + + /// + /// Using service '{0}'. + /// + internal static string FormatUsingService(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("UsingService"), p0); + + /// + /// Method '{0}' of service '{1}' failed to generate document '{2}'. + /// + internal static string MethodInvocationFailed + { + get => GetString("MethodInvocationFailed"); + } + + /// + /// Method '{0}' of service '{1}' failed to generate document '{2}'. + /// + internal static string FormatMethodInvocationFailed(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("MethodInvocationFailed"), p0, p1, p2); + + /// + /// Assembly '{0}' does not contain an entry point. + /// + internal static string MissingEntryPoint + { + get => GetString("MissingEntryPoint"); + } + + /// + /// Assembly '{0}' does not contain an entry point. + /// + internal static string FormatMissingEntryPoint(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("MissingEntryPoint"), p0); + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/GetDocumentInsider/Reporter.cs b/src/GetDocumentInsider/Reporter.cs new file mode 100644 index 0000000000..9a589fcc67 --- /dev/null +++ b/src/GetDocumentInsider/Reporter.cs @@ -0,0 +1,58 @@ +// 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.Linq; +using static Microsoft.Extensions.ApiDescription.Tool.AnsiConstants; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal static class Reporter + { + public static bool IsVerbose { get; set; } + public static bool NoColor { get; set; } + public static bool PrefixOutput { get; set; } + + public static string Colorize(string value, Func colorizeFunc) + => NoColor ? value : colorizeFunc(value); + + public static void WriteError(string message) + => WriteLine(Prefix("error: ", Colorize(message, x => Bold + Red + x + Reset))); + + public static void WriteWarning(string message) + => WriteLine(Prefix("warn: ", Colorize(message, x => Bold + Yellow + x + Reset))); + + public static void WriteInformation(string message) + => WriteLine(Prefix("info: ", message)); + + public static void WriteData(string message) + => WriteLine(Prefix("data: ", Colorize(message, x => Bold + Gray + x + Reset))); + + public static void WriteVerbose(string message) + { + if (IsVerbose) + { + WriteLine(Prefix("verbose: ", Colorize(message, x => Bold + Black + x + Reset))); + } + } + + private static string Prefix(string prefix, string value) + => PrefixOutput + ? string.Join( + Environment.NewLine, + value.Split(new[] { Environment.NewLine }, StringSplitOptions.None).Select(l => prefix + l)) + : value; + + private static void WriteLine(string value) + { + if (NoColor) + { + Console.WriteLine(value); + } + else + { + AnsiConsole.WriteLine(value); + } + } + } +} diff --git a/src/GetDocumentInsider/Resources.resx b/src/GetDocumentInsider/Resources.resx new file mode 100644 index 0000000000..fffabb44f3 --- /dev/null +++ b/src/GetDocumentInsider/Resources.resx @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The assembly to use. + + + Missing required option '--{0}'. + + + Do not colorize output. + + + The file to write the result to. + + + Prefix console output with logging level. + + + Show verbose output. + + + Location from which inside man was copied (in the .NET Framework case) or loaded. + + + The name of the method to invoke on the '--service' instance. Default value '{0}'. + + + The qualified name of the service type to retrieve from dependency injection. Default value '{0}'. + + + The name of the document to pass to the '--method' method. Default value '{0}'. + + + Using document name '{0}'. + + + Using method '{0}'. + + + Using service '{0}'. + + + Method '{0}' of service '{1}' failed to generate document '{2}'. + + + Assembly '{0}' does not contain an entry point. + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs index f78815a7d1..8701d4f4a3 100644 --- a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs @@ -325,6 +325,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// public abstract bool ValidateChildren { get; } + /// + /// Gets a value that indicates if the model, or one of it's properties, or elements has associatated validators. + /// + /// + /// When , validation can be assume that the model is valid () without + /// inspecting the object graph. + /// + public virtual bool? HasValidators { get; } + /// /// Gets a collection of metadata items for validators. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs index a0c082fdf6..95a4afb21b 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs @@ -146,6 +146,8 @@ namespace Microsoft.Extensions.DependencyInjection ServiceDescriptor.Transient, MvcCoreMvcOptionsSetup>()); services.TryAddEnumerable( ServiceDescriptor.Transient, MvcOptionsConfigureCompatibilityOptions>()); + services.TryAddEnumerable( + ServiceDescriptor.Transient, MvcCoreMvcOptionsSetup>()); services.TryAddEnumerable( ServiceDescriptor.Transient, ApiBehaviorOptionsSetup>()); services.TryAddEnumerable( diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcCoreMvcOptionsSetup.cs similarity index 86% rename from src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs rename to src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcCoreMvcOptionsSetup.cs index 4cb4f9f3bd..7b89b69ee6 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcCoreMvcOptionsSetup.cs @@ -8,24 +8,25 @@ using System.Threading; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc { /// /// Sets up default options for . /// - public class MvcCoreMvcOptionsSetup : IConfigureOptions + internal class MvcCoreMvcOptionsSetup : IConfigureOptions, IPostConfigureOptions { private readonly IHttpRequestStreamReaderFactory _readerFactory; private readonly ILoggerFactory _loggerFactory; - // Used in tests public MvcCoreMvcOptionsSetup(IHttpRequestStreamReaderFactory readerFactory) : this(readerFactory, NullLoggerFactory.Instance) { @@ -83,6 +84,15 @@ namespace Microsoft.AspNetCore.Mvc.Internal options.ModelValidatorProviders.Add(new DefaultModelValidatorProvider()); } + public void PostConfigure(string name, MvcOptions options) + { + // HasValidatorsValidationMetadataProvider uses the results of other ValidationMetadataProvider to determine if a model requires + // validation. It is imperative that this executes later than all other metadata provider. We'll register it as part of PostConfigure. + // This should ensure it appears later than all of the details provider registered by MVC and user configured details provider registered + // as part of ConfigureOptions. + options.ModelMetadataDetailsProviders.Add(new HasValidatorsValidationMetadataProvider(options.ModelValidatorProviders)); + } + internal static void ConfigureAdditionalModelMetadataDetailsProviders(IList modelMetadataDetailsProviders) { // Don't bind the Type class by default as it's expensive. A user can override this behavior diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs index bfb1bde8b5..277da94ddd 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata @@ -29,6 +30,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata private bool? _isRequired; private ModelPropertyCollection _properties; private bool? _validateChildren; + private bool? _hasValidators; private ReadOnlyCollection _validatorMetadata; /// @@ -427,6 +429,84 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata } } + /// + public override bool? HasValidators + { + get + { + if (!_hasValidators.HasValue) + { + var visited = new HashSet(); + + _hasValidators = CalculateHasValidators(visited, this); + } + + return _hasValidators.Value; + } + } + + internal static bool CalculateHasValidators(HashSet visited, ModelMetadata metadata) + { + RuntimeHelpers.EnsureSufficientExecutionStack(); + + if (metadata?.GetType() != typeof(DefaultModelMetadata)) + { + // The calculation is valid only for DefaultModelMetadata instances. Null, other ModelMetadata instances + // or subtypes of DefaultModelMetadata will be treated as always requiring validation. + return true; + } + + var defaultModelMetadata = (DefaultModelMetadata)metadata; + + if (defaultModelMetadata._hasValidators.HasValue) + { + // Return a previously calculated value if available. + return defaultModelMetadata._hasValidators.Value; + } + + if (defaultModelMetadata.ValidationMetadata.HasValidators != false) + { + // Either the ModelMetadata instance has some validators (HasValidators = true) or it is non-deterministic (HasValidators = null). + // In either case, assume it has validators. + return true; + } + + // Before inspecting properties or elements of a collection, ensure we do not have a cycle. + // Consider a model like so + // + // Employee { BusinessDivision Division; int Id; string Name; } + // BusinessDivision { int Id; List Employees } + // + // If we get to the Employee element from Employee.Division.Employees, we can return false for that instance + // and allow other properties of BusinessDivision and Employee to determine if it has validators. + if (!visited.Add(defaultModelMetadata)) + { + return false; + } + + // We have inspected the current element. Inspect properties or elements that may contribute to this value. + if (defaultModelMetadata.IsEnumerableType) + { + if (CalculateHasValidators(visited, defaultModelMetadata.ElementMetadata)) + { + return true; + } + } + else if (defaultModelMetadata.IsComplexType) + { + foreach (var property in defaultModelMetadata.Properties) + { + if (CalculateHasValidators(visited, property)) + { + return true; + } + } + } + + // We've come this far. The ModelMetadata does not have any validation + return false; + } + /// public override IReadOnlyList ValidatorMetadata { diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/HasValidatorsValidationMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/HasValidatorsValidationMetadataProvider.cs new file mode 100644 index 0000000000..6378ba518f --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/HasValidatorsValidationMetadataProvider.cs @@ -0,0 +1,53 @@ +// 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.Linq; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation +{ + internal class HasValidatorsValidationMetadataProvider : IValidationMetadataProvider + { + private readonly bool _hasOnlyMetadataBasedValidators; + private readonly IMetadataBasedModelValidatorProvider[] _validatorProviders; + + public HasValidatorsValidationMetadataProvider(IList modelValidatorProviders) + { + if (modelValidatorProviders.Count > 0 && modelValidatorProviders.All(p => p is IMetadataBasedModelValidatorProvider)) + { + _hasOnlyMetadataBasedValidators = true; + _validatorProviders = modelValidatorProviders.Cast().ToArray(); + } + } + + public void CreateValidationMetadata(ValidationMetadataProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (!_hasOnlyMetadataBasedValidators) + { + return; + } + + for (var i = 0; i < _validatorProviders.Length; i++) + { + var provider = _validatorProviders[i]; + if (provider.HasValidators(context.Key.ModelType, context.ValidationMetadata.ValidatorMetadata)) + { + context.ValidationMetadata.HasValidators = true; + return; + } + } + + if (context.ValidationMetadata.HasValidators == null) + { + context.ValidationMetadata.HasValidators = false; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ValidationMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ValidationMetadata.cs index bd4ff327aa..d205fdf172 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ValidationMetadata.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ValidationMetadata.cs @@ -41,5 +41,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// in this list, to be consumed later by an . /// public IList ValidatorMetadata { get; } = new List(); + + /// + /// Gets a value that indicates if the model has validators . + /// + public bool? HasValidators { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelValidatorProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/DefaultModelValidatorProvider.cs similarity index 64% rename from src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelValidatorProvider.cs rename to src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/DefaultModelValidatorProvider.cs index 11d5739c02..2174be1417 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelValidatorProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/DefaultModelValidatorProvider.cs @@ -1,9 +1,10 @@ // 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 Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using System; +using System.Collections.Generic; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation { /// /// A default . @@ -12,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal /// The provides validators from /// instances in . /// - public class DefaultModelValidatorProvider : IModelValidatorProvider + internal sealed class DefaultModelValidatorProvider : IMetadataBasedModelValidatorProvider { /// public void CreateValidators(ModelValidatorProviderContext context) @@ -28,13 +29,25 @@ namespace Microsoft.AspNetCore.Mvc.Internal continue; } - var validator = validatorItem.ValidatorMetadata as IModelValidator; - if (validator != null) + if (validatorItem.ValidatorMetadata is IModelValidator validator) { validatorItem.Validator = validator; validatorItem.IsReusable = true; } } } + + public bool HasValidators(Type modelType, IList validatorMetadata) + { + for (var i = 0; i < validatorMetadata.Count; i++) + { + if (validatorMetadata[i] is IModelValidator) + { + return true; + } + } + + return false; + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/IMetadataBasedModelValidatorProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/IMetadataBasedModelValidatorProvider.cs new file mode 100644 index 0000000000..4fb9bd0c6f --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/IMetadataBasedModelValidatorProvider.cs @@ -0,0 +1,30 @@ +// 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.Mvc.ModelBinding.Metadata; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation +{ + /// + /// An that provides instances + /// exclusively using values in or the model type. + /// + /// can be used to statically determine if a given + /// instance can incur any validation. The value for + /// can be calculated if all instances in are . + /// + /// + public interface IMetadataBasedModelValidatorProvider : IModelValidatorProvider + { + /// + /// Gets a value that determines if the can + /// produce any validators given the and . + /// + /// The of the model. + /// The list of metadata items for validators. . + /// + bool HasValidators(Type modelType, IList validatorMetadata); + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/ValidationVisitor.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/ValidationVisitor.cs index 164928c54a..e62f825887 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/ValidationVisitor.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/ValidationVisitor.cs @@ -265,6 +265,24 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation CurrentPath.Pop(model); return true; } + // If the metadata indicates that no validators exist AND the aggregate state for the key says that the model graph + // is not invalid (i.e. is one of Unvalidated, Valid, or Skipped) we can safely mark the graph as valid. + else if (metadata.HasValidators == false && + ModelState.GetFieldValidationState(key) != ModelValidationState.Invalid) + { + // No validators will be created for this graph of objects. Mark it as valid if it wasn't previously validated. + var entries = ModelState.FindKeysWithPrefix(key); + foreach (var item in entries) + { + if (item.Value.ValidationState == ModelValidationState.Unvalidated) + { + item.Value.ValidationState = ModelValidationState.Valid; + } + } + + CurrentPath.Pop(model); + return true; + } using (StateManager.Recurse(this, key ?? string.Empty, metadata, model, strategy)) { diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs b/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs index 44b816aa05..9d07004103 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs @@ -18,7 +18,7 @@ namespace Microsoft.AspNetCore.Mvc /// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be /// "about:blank". /// - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "type")] public string Type { get; set; } /// @@ -26,25 +26,25 @@ namespace Microsoft.AspNetCore.Mvc /// of the problem, except for purposes of localization(e.g., using proactive content negotiation; /// see[RFC7231], Section 3.4). /// - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "title")] public string Title { get; set; } /// /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. /// - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "status")] public int? Status { get; set; } /// /// A human-readable explanation specific to this occurrence of the problem. /// - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "detail")] public string Detail { get; set; } /// /// A URI reference that identifies the specific occurrence of the problem.It may or may not yield further information if dereferenced. /// - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "instance")] public string Instance { get; set; } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ValidationProblemDetails.cs b/src/Microsoft.AspNetCore.Mvc.Core/ValidationProblemDetails.cs index 2332367b95..da104c9a9b 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ValidationProblemDetails.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ValidationProblemDetails.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Newtonsoft.Json; namespace Microsoft.AspNetCore.Mvc { @@ -64,6 +65,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// Gets or sets the validation errors associated with this instance of . /// + [JsonProperty(PropertyName = "errors")] public IDictionary Errors { get; } = new Dictionary(StringComparer.Ordinal); } } diff --git a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsModelValidatorProvider.cs b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/DataAnnotationsModelValidatorProvider.cs similarity index 84% rename from src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsModelValidatorProvider.cs rename to src/Microsoft.AspNetCore.Mvc.DataAnnotations/DataAnnotationsModelValidatorProvider.cs index 5ece07f042..b6266958c3 100644 --- a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsModelValidatorProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/DataAnnotationsModelValidatorProvider.cs @@ -2,19 +2,21 @@ // 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; +using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; -namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal +namespace Microsoft.AspNetCore.Mvc.DataAnnotations { /// /// An implementation of which provides validators /// for attributes which derive from . It also provides /// a validator for types which implement . /// - public class DataAnnotationsModelValidatorProvider : IModelValidatorProvider + internal sealed class DataAnnotationsModelValidatorProvider : IMetadataBasedModelValidatorProvider { private readonly IOptions _options; private readonly IStringLocalizerFactory _stringLocalizerFactory; @@ -66,8 +68,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal continue; } - var attribute = validatorItem.ValidatorMetadata as ValidationAttribute; - if (attribute == null) + if (!(validatorItem.ValidatorMetadata is ValidationAttribute attribute)) { continue; } @@ -98,5 +99,23 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal }); } } + + public bool HasValidators(Type modelType, IList validatorMetadata) + { + if (typeof(IValidatableObject).IsAssignableFrom(modelType)) + { + return true; + } + + for (var i = 0; i < validatorMetadata.Count; i++) + { + if (validatorMetadata[i] is ValidationAttribute) + { + return true; + } + } + + return false; + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/AssemblyInfo.cs index c375950300..a0584f4754 100644 --- a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/AssemblyInfo.cs @@ -4,3 +4,9 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.DataAnnotations.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Core.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Core.TestCommon, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ViewFeatures.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Performance, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcBuilderExtensions.cs index 4210fbe0bc..17bce01d5e 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcBuilderExtensions.cs @@ -3,7 +3,7 @@ using System; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal; +using Microsoft.AspNetCore.Mvc.Formatters.Xml; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -14,6 +14,29 @@ namespace Microsoft.Extensions.DependencyInjection /// public static class MvcXmlMvcBuilderExtensions { + /// + /// Adds configuration of for the application. + /// + /// The . + /// The which need to be configured. + public static IMvcBuilder AddXmlOptions( + this IMvcBuilder builder, + Action setupAction) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (setupAction == null) + { + throw new ArgumentNullException(nameof(setupAction)); + } + + builder.Services.Configure(setupAction); + return builder; + } + /// /// Adds the XML DataContractSerializer formatters to MVC. /// @@ -30,6 +53,31 @@ namespace Microsoft.Extensions.DependencyInjection return builder; } + /// + /// Adds the XML DataContractSerializer formatters to MVC. + /// + /// The . + /// The which need to be configured. + /// The . + public static IMvcBuilder AddXmlDataContractSerializerFormatters( + this IMvcBuilder builder, + Action setupAction) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (setupAction == null) + { + throw new ArgumentNullException(nameof(setupAction)); + } + + AddXmlDataContractSerializerFormatterServices(builder.Services); + builder.Services.Configure(setupAction); + return builder; + } + /// /// Adds the XML Serializer formatters to MVC. /// @@ -46,18 +94,44 @@ namespace Microsoft.Extensions.DependencyInjection return builder; } + /// + /// Adds the XML Serializer formatters to MVC. + /// + /// The . + /// The which need to be configured. + /// The . + public static IMvcBuilder AddXmlSerializerFormatters( + this IMvcBuilder builder, + Action setupAction) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + AddXmlSerializerFormatterServices(builder.Services); + builder.Services.Configure(setupAction); + return builder; + } + // Internal for testing. internal static void AddXmlDataContractSerializerFormatterServices(IServiceCollection services) { services.TryAddEnumerable( - ServiceDescriptor.Transient, MvcXmlDataContractSerializerMvcOptionsSetup>()); + ServiceDescriptor.Transient, XmlDataContractSerializerMvcOptionsSetup>()); + + services.TryAddEnumerable( + ServiceDescriptor.Transient, MvcXmlOptionsConfigureCompatibilityOptions>()); } // Internal for testing. internal static void AddXmlSerializerFormatterServices(IServiceCollection services) { services.TryAddEnumerable( - ServiceDescriptor.Transient, MvcXmlSerializerMvcOptionsSetup>()); + ServiceDescriptor.Transient, XmlSerializerMvcOptionsSetup>()); + + services.TryAddEnumerable( + ServiceDescriptor.Transient, MvcXmlOptionsConfigureCompatibilityOptions>()); } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcCoreBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcCoreBuilderExtensions.cs index 5e14f41647..9608d3fc18 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcCoreBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcCoreBuilderExtensions.cs @@ -3,7 +3,7 @@ using System; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal; +using Microsoft.AspNetCore.Mvc.Formatters.Xml; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -14,6 +14,30 @@ namespace Microsoft.Extensions.DependencyInjection /// public static class MvcXmlMvcCoreBuilderExtensions { + /// + /// Adds configuration of for the application. + /// + /// The . + /// The which need to be configured. + /// The . + public static IMvcCoreBuilder AddXmlOptions( + this IMvcCoreBuilder builder, + Action setupAction) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (setupAction == null) + { + throw new ArgumentNullException(nameof(setupAction)); + } + + builder.Services.Configure(setupAction); + return builder; + } + /// /// Adds the XML DataContractSerializer formatters to MVC. /// @@ -30,6 +54,31 @@ namespace Microsoft.Extensions.DependencyInjection return builder; } + /// + /// Adds the XML DataContractSerializer formatters to MVC. + /// + /// The . + /// The which need to be configured. + /// The . + public static IMvcCoreBuilder AddXmlDataContractSerializerFormatters( + this IMvcCoreBuilder builder, + Action setupAction) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (setupAction == null) + { + throw new ArgumentNullException(nameof(setupAction)); + } + + AddXmlDataContractSerializerFormatterServices(builder.Services); + builder.Services.Configure(setupAction); + return builder; + } + /// /// Adds the XML Serializer formatters to MVC. /// @@ -46,18 +95,44 @@ namespace Microsoft.Extensions.DependencyInjection return builder; } + /// + /// Adds the XML Serializer formatters to MVC. + /// + /// The . + /// The which need to be configured. + /// /// The . + public static IMvcCoreBuilder AddXmlSerializerFormatters( + this IMvcCoreBuilder builder, + Action setupAction) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + AddXmlSerializerFormatterServices(builder.Services); + builder.Services.Configure(setupAction); + return builder; + } + // Internal for testing. internal static void AddXmlDataContractSerializerFormatterServices(IServiceCollection services) { services.TryAddEnumerable( - ServiceDescriptor.Transient, MvcXmlDataContractSerializerMvcOptionsSetup>()); + ServiceDescriptor.Transient, XmlDataContractSerializerMvcOptionsSetup>()); + + services.TryAddEnumerable( + ServiceDescriptor.Transient, MvcXmlOptionsConfigureCompatibilityOptions>()); } // Internal for testing. internal static void AddXmlSerializerFormatterServices(IServiceCollection services) { services.TryAddEnumerable( - ServiceDescriptor.Transient, MvcXmlSerializerMvcOptionsSetup>()); + ServiceDescriptor.Transient, XmlSerializerMvcOptionsSetup>()); + + services.TryAddEnumerable( + ServiceDescriptor.Transient, MvcXmlOptionsConfigureCompatibilityOptions>()); } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptions.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptions.cs new file mode 100644 index 0000000000..d8ada74590 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptions.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.Collections; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml +{ + /// + /// Provides configuration for XML formatters. + /// + public class MvcXmlOptions : IEnumerable + { + private readonly CompatibilitySwitch _allowRfc7807CompliantProblemDetailsFormat; + private readonly IReadOnlyList _switches; + + /// + /// Creates a new instance of . + /// + public MvcXmlOptions() + { + _allowRfc7807CompliantProblemDetailsFormat = new CompatibilitySwitch(nameof(AllowRfc7807CompliantProblemDetailsFormat)); + + _switches = new ICompatibilitySwitch[] + { + _allowRfc7807CompliantProblemDetailsFormat, + }; + } + + /// + /// Gets or sets a value inidicating whether and + /// are serialized in a format compliant with the RFC 7807 specification (https://tools.ietf.org/html/rfc7807). + /// + /// + /// The default value is if the version is + /// or later; otherwise. + /// + /// + /// + /// This property is associated with a compatibility switch and can provide a different behavior depending on + /// the configured compatibility version for the application. See for + /// guidance and examples of setting the application's compatibility version. + /// + /// + /// Configuring the desired value of the compatibility switch by calling this property's setter will take + /// precedence over the value implied by the application's . + /// + /// + /// If the application's compatibility version is set to or + /// lower then this setting will have the value unless explicitly configured. + /// + /// + /// If the application's compatibility version is set to or + /// higher then this setting will have the value unless explicitly configured. + /// + /// + public bool AllowRfc7807CompliantProblemDetailsFormat + { + get => _allowRfc7807CompliantProblemDetailsFormat.Value; + set => _allowRfc7807CompliantProblemDetailsFormat.Value = value; + } + + public IEnumerator GetEnumerator() => _switches.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptionsConfigureCompatibilityOptions.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptionsConfigureCompatibilityOptions.cs new file mode 100644 index 0000000000..c5d1d3e340 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptionsConfigureCompatibilityOptions.cs @@ -0,0 +1,36 @@ +// 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 Microsoft.AspNetCore.Mvc.Formatters.Xml; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc +{ + internal sealed class MvcXmlOptionsConfigureCompatibilityOptions : ConfigureCompatibilityOptions + { + public MvcXmlOptionsConfigureCompatibilityOptions( + ILoggerFactory loggerFactory, + IOptions compatibilityOptions) + : base(loggerFactory, compatibilityOptions) + { + } + + protected override IReadOnlyDictionary DefaultValues + { + get + { + var values = new Dictionary(); + + if (Version >= CompatibilityVersion.Version_2_2) + { + values[nameof(MvcXmlOptions.AllowRfc7807CompliantProblemDetailsFormat)] = true; + } + + return values; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetails21Wrapper.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetails21Wrapper.cs new file mode 100644 index 0000000000..9a4dcf0bd7 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetails21Wrapper.cs @@ -0,0 +1,179 @@ +// 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.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; + +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml +{ + /// + /// Wrapper class for to enable it to be serialized by the xml formatters. + /// + [XmlRoot(nameof(ProblemDetails))] + [Obsolete("This type is deprecated and will be removed in a future version")] + public class ProblemDetails21Wrapper : IXmlSerializable, IUnwrappable + { + protected static readonly string EmptyKey = SerializableErrorWrapper.EmptyKey; + + public ProblemDetails21Wrapper() + : this(new ProblemDetails()) + { + } + + public ProblemDetails21Wrapper(ProblemDetails problemDetails) + { + ProblemDetails = problemDetails; + } + + internal ProblemDetails ProblemDetails { get; } + + /// + public XmlSchema GetSchema() => null; + + /// + public virtual void ReadXml(XmlReader reader) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + if (reader.IsEmptyElement) + { + reader.Read(); + return; + } + + reader.ReadStartElement(); + while (reader.NodeType != XmlNodeType.EndElement) + { + var key = XmlConvert.DecodeName(reader.LocalName); + ReadValue(reader, key); + + reader.MoveToContent(); + } + + reader.ReadEndElement(); + } + + /// + /// Reads the value for the specified from the . + /// + /// The . + /// The name of the node. + protected virtual void ReadValue(XmlReader reader, string name) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + var value = reader.ReadInnerXml(); + + switch (name) + { + case "Detail": + ProblemDetails.Detail = value; + break; + + case "Instance": + ProblemDetails.Instance = value; + break; + + case "Status": + ProblemDetails.Status = string.IsNullOrEmpty(value) ? + (int?)null : + int.Parse(value, CultureInfo.InvariantCulture); + break; + + case "Title": + ProblemDetails.Title = value; + break; + + case "Type": + ProblemDetails.Type = value; + break; + + default: + if (string.Equals(name, EmptyKey, StringComparison.Ordinal)) + { + name = string.Empty; + } + + ProblemDetails.Extensions.Add(name, value); + break; + } + } + + /// + public virtual void WriteXml(XmlWriter writer) + { + if (!string.IsNullOrEmpty(ProblemDetails.Detail)) + { + writer.WriteElementString( + XmlConvert.EncodeLocalName("Detail"), + ProblemDetails.Detail); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Instance)) + { + writer.WriteElementString( + XmlConvert.EncodeLocalName("Instance"), + ProblemDetails.Instance); + } + + if (ProblemDetails.Status.HasValue) + { + writer.WriteStartElement(XmlConvert.EncodeLocalName("Status")); + writer.WriteValue(ProblemDetails.Status.Value); + writer.WriteEndElement(); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Title)) + { + writer.WriteElementString( + XmlConvert.EncodeLocalName("Title"), + ProblemDetails.Title); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Type)) + { + writer.WriteElementString( + XmlConvert.EncodeLocalName("Type"), + ProblemDetails.Type); + } + + foreach (var keyValuePair in ProblemDetails.Extensions) + { + var key = keyValuePair.Key; + var value = keyValuePair.Value; + + if (string.IsNullOrEmpty(key)) + { + key = EmptyKey; + } + + writer.WriteStartElement(XmlConvert.EncodeLocalName(key)); + if (value != null) + { + writer.WriteValue(value); + } + + writer.WriteEndElement(); + } + } + + object IUnwrappable.Unwrap(Type declaredType) + { + if (declaredType == null) + { + throw new ArgumentNullException(nameof(declaredType)); + } + + return ProblemDetails; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs index 30775bd1b1..f7d2056806 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs @@ -12,9 +12,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml /// /// Wrapper class for to enable it to be serialized by the xml formatters. /// - [XmlRoot(nameof(ProblemDetails))] + [XmlRoot("problem", Namespace = Namespace)] public class ProblemDetailsWrapper : IXmlSerializable, IUnwrappable { + internal const string Namespace = "urn:ietf:rfc:7807"; + /// /// Key used to represent dictionary elements with empty keys /// @@ -83,25 +85,25 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml switch (name) { - case nameof(ProblemDetails.Detail): + case "detail": ProblemDetails.Detail = value; break; - case nameof(ProblemDetails.Instance): + case "instance": ProblemDetails.Instance = value; break; - case nameof(ProblemDetails.Status): + case "status": ProblemDetails.Status = string.IsNullOrEmpty(value) ? (int?)null : int.Parse(value, CultureInfo.InvariantCulture); break; - case nameof(ProblemDetails.Title): + case "title": ProblemDetails.Title = value; break; - case nameof(ProblemDetails.Type): + case "type": ProblemDetails.Type = value; break; @@ -122,20 +124,20 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml if (!string.IsNullOrEmpty(ProblemDetails.Detail)) { writer.WriteElementString( - XmlConvert.EncodeLocalName(nameof(ProblemDetails.Detail)), + XmlConvert.EncodeLocalName("detail"), ProblemDetails.Detail); } if (!string.IsNullOrEmpty(ProblemDetails.Instance)) { writer.WriteElementString( - XmlConvert.EncodeLocalName(nameof(ProblemDetails.Instance)), + XmlConvert.EncodeLocalName("instance"), ProblemDetails.Instance); } if (ProblemDetails.Status.HasValue) { - writer.WriteStartElement(XmlConvert.EncodeLocalName(nameof(ProblemDetails.Status))); + writer.WriteStartElement(XmlConvert.EncodeLocalName("status")); writer.WriteValue(ProblemDetails.Status.Value); writer.WriteEndElement(); } @@ -143,14 +145,14 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml if (!string.IsNullOrEmpty(ProblemDetails.Title)) { writer.WriteElementString( - XmlConvert.EncodeLocalName(nameof(ProblemDetails.Title)), + XmlConvert.EncodeLocalName("title"), ProblemDetails.Title); } if (!string.IsNullOrEmpty(ProblemDetails.Type)) { writer.WriteElementString( - XmlConvert.EncodeLocalName(nameof(ProblemDetails.Type)), + XmlConvert.EncodeLocalName("type"), ProblemDetails.Type); } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapperProviderFactory.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapperProviderFactory.cs new file mode 100644 index 0000000000..9b93b86c6b --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapperProviderFactory.cs @@ -0,0 +1,65 @@ +// 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 Microsoft.AspNetCore.Mvc.Formatters.Xml +{ + internal class ProblemDetailsWrapperProviderFactory : IWrapperProviderFactory + { + private readonly MvcXmlOptions _options; + + public ProblemDetailsWrapperProviderFactory(MvcXmlOptions options) + { + _options = options; + } + + public IWrapperProvider GetProvider(WrapperProviderContext context) + { + if (context.DeclaredType == typeof(ProblemDetails)) + { + if (_options.AllowRfc7807CompliantProblemDetailsFormat) + { + return new WrapperProvider(typeof(ProblemDetailsWrapper), p => new ProblemDetailsWrapper((ProblemDetails)p)); + } + else + { +#pragma warning disable CS0618 // Type or member is obsolete + return new WrapperProvider(typeof(ProblemDetails21Wrapper), p => new ProblemDetails21Wrapper((ProblemDetails)p)); +#pragma warning restore CS0618 // Type or member is obsolete + } + } + + if (context.DeclaredType == typeof(ValidationProblemDetails)) + { + if (_options.AllowRfc7807CompliantProblemDetailsFormat) + { + return new WrapperProvider(typeof(ValidationProblemDetailsWrapper), p => new ValidationProblemDetailsWrapper((ValidationProblemDetails)p)); + } + else + { +#pragma warning disable CS0618 // Type or member is obsolete + return new WrapperProvider(typeof(ValidationProblemDetails21Wrapper), p => new ValidationProblemDetails21Wrapper((ValidationProblemDetails)p)); +#pragma warning restore CS0618 // Type or member is obsolete + } + } + + return null; + } + + private class WrapperProvider : IWrapperProvider + { + public WrapperProvider(Type wrappingType, Func wrapDelegate) + { + WrappingType = wrappingType; + WrapDelegate = wrapDelegate; + } + + public Type WrappingType { get; } + + public Func WrapDelegate { get; } + + public object Wrap(object original) => WrapDelegate(original); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetails21Wrapper.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetails21Wrapper.cs new file mode 100644 index 0000000000..e138c0f3d1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetails21Wrapper.cs @@ -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; +using System.Xml; +using System.Xml.Serialization; + +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml +{ + /// + /// Wrapper class for to enable it to be serialized by the xml formatters. + /// + [XmlRoot(nameof(ValidationProblemDetails))] + [Obsolete("This type is deprecated and will be removed in a future version")] + public class ValidationProblemDetails21Wrapper : ProblemDetails21Wrapper, IUnwrappable + { + private static readonly string ErrorKey = "MVC-Errors"; + + /// + /// Initializes a new instance of . + /// + public ValidationProblemDetails21Wrapper() + : this(new ValidationProblemDetails()) + { + } + + /// + /// Initializes a new instance of for the specified + /// . + /// + /// The . + public ValidationProblemDetails21Wrapper(ValidationProblemDetails problemDetails) + : base(problemDetails) + { + ProblemDetails = problemDetails; + } + + internal new ValidationProblemDetails ProblemDetails { get; } + + /// + protected override void ReadValue(XmlReader reader, string name) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + if (string.Equals(name, ErrorKey, StringComparison.Ordinal)) + { + reader.Read(); + ReadErrorProperty(reader); + } + else + { + base.ReadValue(reader, name); + } + } + + private void ReadErrorProperty(XmlReader reader) + { + if (reader.IsEmptyElement) + { + return; + } + + while (reader.NodeType != XmlNodeType.EndElement) + { + var key = XmlConvert.DecodeName(reader.LocalName); + var value = reader.ReadInnerXml(); + if (string.Equals(EmptyKey, key, StringComparison.Ordinal)) + { + key = string.Empty; + } + + ProblemDetails.Errors.Add(key, new[] { value }); + reader.MoveToContent(); + } + } + + /// + public override void WriteXml(XmlWriter writer) + { + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + base.WriteXml(writer); + + if (ProblemDetails.Errors.Count == 0) + { + return; + } + + writer.WriteStartElement(XmlConvert.EncodeLocalName(ErrorKey)); + + foreach (var keyValuePair in ProblemDetails.Errors) + { + var key = keyValuePair.Key; + var value = keyValuePair.Value; + if (string.IsNullOrEmpty(key)) + { + key = EmptyKey; + } + + writer.WriteStartElement(XmlConvert.EncodeLocalName(key)); + if (value != null) + { + writer.WriteValue(value); + } + + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + + object IUnwrappable.Unwrap(Type declaredType) + { + if (declaredType == null) + { + throw new ArgumentNullException(nameof(declaredType)); + } + + return ProblemDetails; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs index b8787ee0e0..a454fb5d0a 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml /// /// Wrapper class for to enable it to be serialized by the xml formatters. /// - [XmlRoot(nameof(ValidationProblemDetails))] + [XmlRoot("problem", Namespace = "urn:ietf:rfc:7807")] public class ValidationProblemDetailsWrapper : ProblemDetailsWrapper, IUnwrappable { private static readonly string ErrorKey = "MVC-Errors"; diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactoriesExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactoriesExtensions.cs index 6ff62a8ec0..1ccea1645d 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactoriesExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactoriesExtensions.cs @@ -44,24 +44,5 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml return null; } - - internal static IList GetDefaultProviderFactories() - { - var wrapperProviderFactories = new List(); - - wrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); - - wrapperProviderFactories.Add(new WrapperProviderFactory( - typeof(ProblemDetails), - typeof(ProblemDetailsWrapper), - value => new ProblemDetailsWrapper((ProblemDetails)value))); - - wrapperProviderFactories.Add(new WrapperProviderFactory( - typeof(ValidationProblemDetails), - typeof(ValidationProblemDetailsWrapper), - value => new ValidationProblemDetailsWrapper((ValidationProblemDetails)value))); - - return wrapperProviderFactories; - } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactory.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactory.cs deleted file mode 100644 index 3f7c4a48af..0000000000 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactory.cs +++ /dev/null @@ -1,50 +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 Microsoft.AspNetCore.Mvc.Formatters.Xml -{ - internal class WrapperProviderFactory : IWrapperProviderFactory - { - public WrapperProviderFactory(Type declaredType, Type wrappingType, Func wrapper) - { - DeclaredType = declaredType; - WrappingType = wrappingType; - Wrapper = wrapper; - } - - public Type DeclaredType { get; } - - public Type WrappingType { get; } - - public Func Wrapper { get; } - - public IWrapperProvider GetProvider(WrapperProviderContext context) - { - if (context.DeclaredType == DeclaredType) - { - return new WrapperProvider(this); - } - - return null; - } - - private class WrapperProvider : IWrapperProvider - { - private readonly WrapperProviderFactory _wrapperFactory; - - public WrapperProvider(WrapperProviderFactory wrapperFactory) - { - _wrapperFactory = wrapperFactory; - } - - public Type WrappingType => _wrapperFactory.WrappingType; - - public object Wrap(object original) - { - return _wrapperFactory.Wrapper(original); - } - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs index b5d6d74a22..f0954c36a4 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs @@ -46,7 +46,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters _serializerSettings = new DataContractSerializerSettings(); - WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); + WrapperProviderFactories = new List + { + new SerializableErrorWrapperProviderFactory(), + }; } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerMvcOptionsSetup.cs similarity index 57% rename from src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs rename to src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerMvcOptionsSetup.cs index c1cd77f685..eba6b9d5fd 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerMvcOptionsSetup.cs @@ -2,34 +2,36 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Xml; +using System.Xml.Linq; +using Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; -namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml { /// /// A implementation which will add the /// data contract serializer formatters to . /// - public class MvcXmlDataContractSerializerMvcOptionsSetup : IConfigureOptions + internal sealed class XmlDataContractSerializerMvcOptionsSetup : IConfigureOptions { + private readonly MvcXmlOptions _xmlOptions; private readonly ILoggerFactory _loggerFactory; /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// + /// . /// The . - public MvcXmlDataContractSerializerMvcOptionsSetup(ILoggerFactory loggerFactory) + public XmlDataContractSerializerMvcOptionsSetup( + IOptions xmlOptions, + ILoggerFactory loggerFactory) { - if (loggerFactory == null) - { - throw new ArgumentNullException(nameof(loggerFactory)); - } - - _loggerFactory = loggerFactory; + _xmlOptions = xmlOptions?.Value ?? throw new ArgumentNullException(nameof(xmlOptions)); + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); } /// @@ -40,8 +42,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal { options.ModelMetadataDetailsProviders.Add(new DataMemberRequiredBindingMetadataProvider()); - options.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter(_loggerFactory)); - options.InputFormatters.Add(new XmlDataContractSerializerInputFormatter(options)); + var inputFormatter = new XmlDataContractSerializerInputFormatter(options); + inputFormatter.WrapperProviderFactories.Add(new ProblemDetailsWrapperProviderFactory(_xmlOptions)); + options.InputFormatters.Add(inputFormatter); + + var outputFormatter = new XmlDataContractSerializerOutputFormatter(_loggerFactory); + outputFormatter.WrapperProviderFactories.Add(new ProblemDetailsWrapperProviderFactory(_xmlOptions)); + options.OutputFormatters.Add(outputFormatter); // Do not override any user mapping var key = "xml"; diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs index 6a312d903d..9b89042636 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs @@ -76,7 +76,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters _serializerSettings = new DataContractSerializerSettings(); - WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); + WrapperProviderFactories = new List() + { + new SerializableErrorWrapperProviderFactory(), + }; WrapperProviderFactories.Add(new EnumerableWrapperProviderFactory(WrapperProviderFactories)); _logger = loggerFactory?.CreateLogger(GetType()); diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs index 2c1ea40bbd..4d530a015b 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs @@ -43,7 +43,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedMediaTypes.Add(MediaTypeHeaderValues.TextXml); SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyXmlSyntax); - WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); + WrapperProviderFactories = new List + { + new SerializableErrorWrapperProviderFactory(), + }; } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerMvcOptionsSetup.cs similarity index 51% rename from src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs rename to src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerMvcOptionsSetup.cs index 6c7546e332..1a57d167ce 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerMvcOptionsSetup.cs @@ -2,32 +2,32 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; -namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml { /// /// A implementation which will add the /// XML serializer formatters to . /// - public class MvcXmlSerializerMvcOptionsSetup : IConfigureOptions + internal sealed class XmlSerializerMvcOptionsSetup : IConfigureOptions { + private readonly MvcXmlOptions _xmlOptions; private readonly ILoggerFactory _loggerFactory; /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// + /// . /// The . - public MvcXmlSerializerMvcOptionsSetup(ILoggerFactory loggerFactory) + public XmlSerializerMvcOptionsSetup( + IOptions xmlOptions, + ILoggerFactory loggerFactory) { - if (loggerFactory == null) - { - throw new ArgumentNullException(nameof(loggerFactory)); - } - - _loggerFactory = loggerFactory; + _xmlOptions = xmlOptions?.Value ?? throw new ArgumentNullException(nameof(xmlOptions)); + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); } /// @@ -46,8 +46,14 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal MediaTypeHeaderValues.ApplicationXml); } - options.OutputFormatters.Add(new XmlSerializerOutputFormatter(_loggerFactory)); - options.InputFormatters.Add(new XmlSerializerInputFormatter(options)); + var inputFormatter = new XmlSerializerInputFormatter(options); + inputFormatter.WrapperProviderFactories.Add(new ProblemDetailsWrapperProviderFactory(_xmlOptions)); + options.InputFormatters.Add(inputFormatter); + + var outputFormatter = new XmlSerializerOutputFormatter(_loggerFactory); + outputFormatter.WrapperProviderFactories.Add(new ProblemDetailsWrapperProviderFactory(_xmlOptions)); + options.OutputFormatters.Add(outputFormatter); + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs index e24b2c9d20..c289972ea4 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs @@ -73,7 +73,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters WriterSettings = writerSettings; - WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); + WrapperProviderFactories = new List + { + new SerializableErrorWrapperProviderFactory(), + }; WrapperProviderFactories.Add(new EnumerableWrapperProviderFactory(WrapperProviderFactories)); _logger = loggerFactory?.CreateLogger(GetType()); diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs b/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs index 7485a15694..276bca6cbc 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs @@ -1,4 +1,7 @@ -using System; +// 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 Microsoft.AspNetCore.Mvc.Razor { diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/CookieContainerHandler.cs b/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/CookieContainerHandler.cs index bde4250b50..dedcf35f86 100644 --- a/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/CookieContainerHandler.cs +++ b/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/CookieContainerHandler.cs @@ -1,4 +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.Net; diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/RedirectHandler.cs b/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/RedirectHandler.cs index 8af03645ac..9addd609ec 100644 --- a/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/RedirectHandler.cs +++ b/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/RedirectHandler.cs @@ -1,4 +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; diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactory.cs b/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactory.cs index 85fcdadba2..8356180bd9 100644 --- a/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactory.cs +++ b/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactory.cs @@ -1,4 +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; diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryClientOptions.cs b/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryClientOptions.cs index c934b2fd56..cfc0244603 100644 --- a/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryClientOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryClientOptions.cs @@ -1,4 +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; diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryContentRootAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryContentRootAttribute.cs index a2ea31cb45..51832bd7a8 100644 --- a/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryContentRootAttribute.cs +++ b/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryContentRootAttribute.cs @@ -1,4 +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; diff --git a/src/Microsoft.Extensions.ApiDescription.Design/DownloadFile.cs b/src/Microsoft.Extensions.ApiDescription.Design/DownloadFile.cs new file mode 100644 index 0000000000..177f405ea1 --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/DownloadFile.cs @@ -0,0 +1,227 @@ +// 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.IO; +using System.Net.Http; +using System.Net.Sockets; +using System.Reflection; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Task = System.Threading.Tasks.Task; +using Utilities = Microsoft.Build.Utilities; + +namespace Microsoft.Extensions.ApiDescription.Tasks +{ + /// + /// Downloads a file. + /// + public class DownloadFile : Utilities.Task, ICancelableTask + { + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + + /// + /// The URI to download. + /// + [Required] + public string Uri { get; set; } + + /// + /// Destination for the downloaded file. If the file already exists, it is not re-downloaded unless + /// is true. + /// + [Required] + public string DestinationPath { get; set; } + + /// + /// Should be overwritten. When true, the file is downloaded and its hash + /// compared to the existing file. If those hashes do not match (or does not + /// exist), is overwritten. + /// + public bool Overwrite { get; set; } + + /// + /// The maximum amount of time in seconds to allow for downloading the file. Defaults to 2 minutes. + /// + public int TimeoutSeconds { get; set; } = 60 * 2; + + /// + public void Cancel() => _cts.Cancel(); + + /// + public override bool Execute() => ExecuteAsync().Result; + + public async Task ExecuteAsync() + { + if (string.IsNullOrEmpty(Uri)) + { + Log.LogError("Uri parameter must not be null or empty."); + return false; + } + + if (string.IsNullOrEmpty(Uri)) + { + Log.LogError("DestinationPath parameter must not be null or empty."); + return false; + } + + var builder = new UriBuilder(Uri); + if (!string.Equals(System.Uri.UriSchemeHttp, builder.Scheme, StringComparison.OrdinalIgnoreCase) && + !string.Equals(System.Uri.UriSchemeHttps, builder.Scheme, StringComparison.OrdinalIgnoreCase)) + { + Log.LogError($"{nameof(Uri)} parameter does not have scheme {System.Uri.UriSchemeHttp} or " + + $"{System.Uri.UriSchemeHttps}."); + return false; + } + + await DownloadFileAsync(Uri, DestinationPath, Overwrite, _cts.Token, TimeoutSeconds, Log); + + return !Log.HasLoggedErrors; + } + + private static async Task DownloadFileAsync( + string uri, + string destinationPath, + bool overwrite, + CancellationToken cancellationToken, + int timeoutSeconds, + TaskLoggingHelper log) + { + var destinationExists = File.Exists(destinationPath); + if (destinationExists && !overwrite) + { + log.LogMessage($"Not downloading '{uri}' to overwrite existing file '{destinationPath}'."); + return; + } + + log.LogMessage(MessageImportance.High, $"Downloading '{uri}' to '{destinationPath}'."); + + using (var httpClient = new HttpClient()) + { + await DownloadAsync(uri, destinationPath, httpClient, cancellationToken, log, timeoutSeconds); + } + } + + public static async Task DownloadAsync( + string uri, + string destinationPath, + HttpClient httpClient, + CancellationToken cancellationToken, + TaskLoggingHelper log, + int timeoutSeconds) + { + // Timeout if the response has not begun within 1 minute + httpClient.Timeout = TimeSpan.FromMinutes(1); + + var destinationExists = File.Exists(destinationPath); + var reachedCopy = false; + try + { + using (var response = await httpClient.GetAsync(uri, cancellationToken)) + { + response.EnsureSuccessStatusCode(); + cancellationToken.ThrowIfCancellationRequested(); + + using (var responseStreamTask = response.Content.ReadAsStreamAsync()) + { + var finished = await Task.WhenAny( + responseStreamTask, + Task.Delay(TimeSpan.FromSeconds(timeoutSeconds))); + + if (!ReferenceEquals(responseStreamTask, finished)) + { + throw new TimeoutException($"Download failed to complete in {timeoutSeconds} seconds."); + } + + using (var responseStream = await responseStreamTask) + { + if (destinationExists) + { + // Check hashes before using the downloaded information. + var downloadHash = GetHash(responseStream); + responseStream.Position = 0L; + + byte[] destinationHash; + using (var destinationStream = File.OpenRead(destinationPath)) + { + destinationHash = GetHash(destinationStream); + } + + var sameHashes = downloadHash.Length == destinationHash.Length; + for (var i = 0; sameHashes && i < downloadHash.Length; i++) + { + sameHashes = downloadHash[i] == destinationHash[i]; + } + + if (sameHashes) + { + log.LogMessage($"Not overwriting existing and matching file '{destinationPath}'."); + return; + } + } + else + { + // May need to create directory to hold the file. + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + } + + // Create or overwrite the destination file. + reachedCopy = true; + using (var outStream = File.Create(destinationPath)) + { + await responseStream.CopyToAsync(outStream); + } + } + } + } + } + catch (HttpRequestException ex) when (destinationExists) + { + if (ex.InnerException is SocketException socketException) + { + log.LogWarning($"Unable to download {uri}, socket error code '{socketException.SocketErrorCode}'."); + } + else + { + log.LogWarning($"Unable to download {uri}: {ex.Message}"); + } + } + catch (Exception ex) + { + log.LogError($"Downloading '{uri}' failed."); + log.LogErrorFromException(ex, showStackTrace: true); + if (reachedCopy) + { + File.Delete(destinationPath); + } + } + } + + private static byte[] GetHash(Stream stream) + { + SHA256 algorithm; + try + { + algorithm = SHA256.Create(); + } + catch (TargetInvocationException) + { + // SHA256.Create is documented to throw this exception on FIPS-compliant machines. See + // https://msdn.microsoft.com/en-us/library/z08hz7ad Fall back to a FIPS-compliant SHA256 algorithm. + algorithm = new SHA256CryptoServiceProvider(); + } + + using (algorithm) + { + return algorithm.ComputeHash(stream); + } + } + } +} diff --git a/src/Microsoft.Extensions.ApiDescription.Design/GetCurrentItems.cs b/src/Microsoft.Extensions.ApiDescription.Design/GetCurrentItems.cs new file mode 100644 index 0000000000..97ea236f2c --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/GetCurrentItems.cs @@ -0,0 +1,34 @@ +// 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 Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.Extensions.ApiDescription.Tasks +{ + /// + /// Restore s from given property value. + /// + public class GetCurrentItems : Task + { + /// + /// The property value to deserialize. + /// + [Required] + public string Input { get; set; } + + /// + /// The restored s. Will never contain more than one item. + /// + [Output] + public ITaskItem[] Outputs { get; set; } + + /// + public override bool Execute() + { + Outputs = new[] { MetadataSerializer.DeserializeMetadata(Input) }; + + return true; + } + } +} diff --git a/src/Microsoft.Extensions.ApiDescription.Design/GetFileReferenceMetadata.cs b/src/Microsoft.Extensions.ApiDescription.Design/GetFileReferenceMetadata.cs new file mode 100644 index 0000000000..70ac4d847f --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/GetFileReferenceMetadata.cs @@ -0,0 +1,132 @@ +// 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.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.Extensions.ApiDescription.Tasks +{ + /// + /// Adds or corrects ClassName, Namespace and OutputPath metadata in ServiceFileReference items. Also stores final + /// metadata as SerializedMetadata. + /// + public class GetFileReferenceMetadata : Task + { + private const string TypeScriptLanguageName = "TypeScript"; + + /// + /// Extension to use in default OutputPath metadata value. Ignored when generating TypeScript. + /// + [Required] + public string Extension { get; set; } + + /// + /// Default Namespace metadata value. + /// + [Required] + public string Namespace { get; set; } + + /// + /// Default directory for OutputPath values. + /// + public string OutputDirectory { get; set; } + + /// + /// The ServiceFileReference items to update. + /// + [Required] + public ITaskItem[] Inputs { get; set; } + + /// + /// The updated ServiceFileReference items. Will include ClassName, Namespace and OutputPath metadata. + /// + [Output] + public ITaskItem[] Outputs{ get; set; } + + /// + public override bool Execute() + { + var outputs = new List(Inputs.Length); + var destinations = new HashSet(); + + foreach (var item in Inputs) + { + var newItem = new TaskItem(item); + outputs.Add(newItem); + + var codeGenerator = item.GetMetadata("CodeGenerator"); + if (string.IsNullOrEmpty("CodeGenerator")) + { + // This case occurs when user forgets to specify the required metadata. We have no default here. + string type; + if (!string.IsNullOrEmpty(item.GetMetadata("SourceProject"))) + { + type = "ServiceProjectReference"; + } + else if (!string.IsNullOrEmpty(item.GetMetadata("SourceUri"))) + { + type = "ServiceUriReference"; + } + else + { + type = "ServiceFileReference"; + } + + Log.LogError(Resources.FormatInvalidEmptyMetadataValue("CodeGenerator", type, item.ItemSpec)); + } + + var className = item.GetMetadata("ClassName"); + if (string.IsNullOrEmpty(className)) + { + var filename = item.GetMetadata("Filename"); + className = $"{filename}Client"; + if (char.IsLower(className[0])) + { + className = char.ToUpper(className[0]) + className.Substring(startIndex: 1); + } + + MetadataSerializer.SetMetadata(newItem, "ClassName", className); + } + + var @namespace = item.GetMetadata("Namespace"); + if (string.IsNullOrEmpty(@namespace)) + { + MetadataSerializer.SetMetadata(newItem, "Namespace", Namespace); + } + + var outputPath = item.GetMetadata("OutputPath"); + if (string.IsNullOrEmpty(outputPath)) + { + var isTypeScript = codeGenerator.EndsWith(TypeScriptLanguageName, StringComparison.OrdinalIgnoreCase); + outputPath = $"{className}{(isTypeScript ? ".ts" : Extension)}"; + } + + // Place output file in correct directory (relative to project directory). + if (!Path.IsPathRooted(outputPath) && !string.IsNullOrEmpty(OutputDirectory)) + { + outputPath = Path.Combine(OutputDirectory, outputPath); + } + + if (!destinations.Add(outputPath)) + { + // This case may occur when user is experimenting e.g. with multiple code generators or options. + // May also occur when user accidentally duplicates OutputPath metadata. + Log.LogError(Resources.FormatDuplicateFileOutputPaths(outputPath)); + } + + MetadataSerializer.SetMetadata(newItem, "OutputPath", outputPath); + + // Add metadata which may be used as a property and passed to an inner build. + newItem.RemoveMetadata("SerializedMetadata"); + newItem.SetMetadata("SerializedMetadata", MetadataSerializer.SerializeMetadata(newItem)); + } + + Outputs = outputs.ToArray(); + + return !Log.HasLoggedErrors; + } + } +} diff --git a/src/Microsoft.Extensions.ApiDescription.Design/GetProjectReferenceMetadata.cs b/src/Microsoft.Extensions.ApiDescription.Design/GetProjectReferenceMetadata.cs new file mode 100644 index 0000000000..a4ad42abe7 --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/GetProjectReferenceMetadata.cs @@ -0,0 +1,103 @@ +// 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.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.Extensions.ApiDescription.Tasks +{ + /// + /// Adds or corrects DocumentPath and project-related metadata in ServiceProjectReference items. Also stores final + /// metadata as SerializedMetadata. + /// + public class GetProjectReferenceMetadata : Task + { + /// + /// Default directory for DocumentPath values. + /// + public string DocumentDirectory { get; set; } + + /// + /// The ServiceFileReference items to update. + /// + [Required] + public ITaskItem[] Inputs { get; set; } + + /// + /// The updated ServiceFileReference items. Will include Namespace and OutputPath metadata. OutputPath metadata + /// will contain full paths. + /// + [Output] + public ITaskItem[] Outputs{ get; set; } + + /// + public override bool Execute() + { + var outputs = new List(Inputs.Length); + var destinations = new HashSet(); + + foreach (var item in Inputs) + { + var newItem = new TaskItem(item); + outputs.Add(newItem); + + var documentGenerator = item.GetMetadata("DocumentGenerator"); + if (string.IsNullOrEmpty(documentGenerator)) + { + // This case occurs when user overrides the default metadata. + Log.LogError(Resources.FormatInvalidEmptyMetadataValue( + "DocumentGenerator", + "ServiceProjectReference", + item.ItemSpec)); + } + + var documentPath = item.GetMetadata("DocumentPath"); + if (string.IsNullOrEmpty(documentPath)) + { + var filename = item.GetMetadata("Filename"); + var documentName = item.GetMetadata("DocumentName"); + if (string.IsNullOrEmpty(documentName)) + { + documentName = "v1"; + } + + documentPath = $"{filename}.{documentName}.json"; + } + + documentPath = GetFullPath(documentPath); + MetadataSerializer.SetMetadata(newItem, "DocumentPath", documentPath); + + if (!destinations.Add(documentPath)) + { + // This case may occur when user is experimenting e.g. with multiple generators or options. + // May also occur when user accidentally duplicates DocumentPath metadata. + Log.LogError(Resources.FormatDuplicateProjectDocumentPaths(documentPath)); + } + + // Add metadata which may be used as a property and passed to an inner build. + newItem.SetMetadata("SerializedMetadata", MetadataSerializer.SerializeMetadata(newItem)); + } + + Outputs = outputs.ToArray(); + + return !Log.HasLoggedErrors; + } + + private string GetFullPath(string path) + { + if (!Path.IsPathRooted(path)) + { + if (!string.IsNullOrEmpty(DocumentDirectory)) + { + path = Path.Combine(DocumentDirectory, path); + } + + path = Path.GetFullPath(path); + } + + return path; + } + } +} diff --git a/src/Microsoft.Extensions.ApiDescription.Design/GetUriReferenceMetadata.cs b/src/Microsoft.Extensions.ApiDescription.Design/GetUriReferenceMetadata.cs new file mode 100644 index 0000000000..922359cb36 --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/GetUriReferenceMetadata.cs @@ -0,0 +1,128 @@ +// 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.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.Extensions.ApiDescription.Tasks +{ + /// + /// Adds or corrects DocumentPath metadata in ServiceUriReference items. + /// + public class GetUriReferenceMetadata : Task + { + /// + /// Default directory for DocumentPath metadata values. + /// + public string DocumentDirectory { get; set; } + + /// + /// The ServiceUriReference items to update. + /// + [Required] + public ITaskItem[] Inputs { get; set; } + + /// + /// The updated ServiceUriReference items. Will include DocumentPath metadata with full paths. + /// + [Output] + public ITaskItem[] Outputs{ get; set; } + + /// + public override bool Execute() + { + var outputs = new List(Inputs.Length); + var destinations = new HashSet(); + foreach (var item in Inputs) + { + var newItem = new TaskItem(item); + outputs.Add(newItem); + + var documentPath = item.GetMetadata("DocumentPath"); + if (string.IsNullOrEmpty(documentPath)) + { + var uri = item.ItemSpec; + var builder = new UriBuilder(uri); + if (!builder.Uri.IsAbsoluteUri) + { + Log.LogError($"{nameof(Inputs)} item '{uri}' is not an absolute URI."); + return false; + } + + if (!string.Equals(Uri.UriSchemeHttp, builder.Scheme, StringComparison.OrdinalIgnoreCase) && + !string.Equals(Uri.UriSchemeHttps, builder.Scheme, StringComparison.OrdinalIgnoreCase)) + { + Log.LogError($"{nameof(Inputs)} item '{uri}' does not have scheme {Uri.UriSchemeHttp} or " + + $"{Uri.UriSchemeHttps}."); + return false; + } + + var host = builder.Host + .Replace("/", string.Empty) + .Replace("[", string.Empty) + .Replace("]", string.Empty) + .Replace(':', '_'); + var path = builder.Path + .Replace("!", string.Empty) + .Replace("'", string.Empty) + .Replace("$", string.Empty) + .Replace("%", string.Empty) + .Replace("&", string.Empty) + .Replace("(", string.Empty) + .Replace(")", string.Empty) + .Replace("*", string.Empty) + .Replace("@", string.Empty) + .Replace("~", string.Empty) + .Replace('/', '_') + .Replace(':', '_') + .Replace(';', '_') + .Replace('+', '_') + .Replace('=', '_'); + + documentPath = host + path; + if (char.IsLower(documentPath[0])) + { + documentPath = char.ToUpper(documentPath[0]) + documentPath.Substring(startIndex: 1); + } + + if (!documentPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + documentPath = $"{documentPath}.json"; + } + } + + documentPath = GetFullPath(documentPath); + MetadataSerializer.SetMetadata(newItem, "DocumentPath", documentPath); + + if (!destinations.Add(documentPath)) + { + // This case may occur when user is experimenting e.g. with multiple code generators or options. + // May also occur when user accidentally duplicates DocumentPath metadata. + Log.LogError(Resources.FormatDuplicateUriDocumentPaths(documentPath)); + } + } + + Outputs = outputs.ToArray(); + + return !Log.HasLoggedErrors; + } + + private string GetFullPath(string path) + { + if (!Path.IsPathRooted(path)) + { + if (!string.IsNullOrEmpty(DocumentDirectory)) + { + path = Path.Combine(DocumentDirectory, path); + } + + path = Path.GetFullPath(path); + } + + return path; + } + } +} diff --git a/src/Microsoft.Extensions.ApiDescription.Design/MetadataSerializer.cs b/src/Microsoft.Extensions.ApiDescription.Design/MetadataSerializer.cs new file mode 100644 index 0000000000..331bac617a --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/MetadataSerializer.cs @@ -0,0 +1,147 @@ +// 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.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.Extensions.ApiDescription.Tasks +{ + /// + /// Utility methods to serialize and deserialize metadata. + /// + /// + /// Based on and uses the same escaping as + /// https://github.com/Microsoft/msbuild/blob/e70a3159d64f9ed6ec3b60253ef863fa883a99b1/src/Shared/EscapingUtilities.cs + /// + public static class MetadataSerializer + { + private static readonly char[] CharsToEscape = { '%', '*', '?', '@', '$', '(', ')', ';', '\'' }; + private static readonly HashSet CharsToEscapeHash = new HashSet(CharsToEscape); + + /// + /// Add the given and to the . Or, + /// modify existing value to be . + /// + /// The to update. + /// The name of the new metadata. + /// The value of the new metadata. Assumed to be unescaped. + /// Uses same hex-encoded format as MSBuild's EscapeUtilities. + public static void SetMetadata(ITaskItem item, string key, string value) + { + if (item is ITaskItem2 item2) + { + item2.SetMetadataValueLiteral(key, value); + return; + } + + if (value.IndexOfAny(CharsToEscape) == -1) + { + item.SetMetadata(key, value); + return; + } + + var builder = new StringBuilder(); + EscapeValue(value, builder); + item.SetMetadata(key, builder.ToString()); + } + + /// + /// Serialize metadata for use as a property value passed into an inner build. + /// + /// The item to serialize. + /// A containing the serialized metadata. + /// Uses same hex-encoded format as MSBuild's EscapeUtilities. + public static string SerializeMetadata(ITaskItem item) + { + var builder = new StringBuilder(); + if (item is ITaskItem2 item2) + { + builder.Append($"Identity={item2.EvaluatedIncludeEscaped}"); + var metadata = item2.CloneCustomMetadataEscaped(); + foreach (var key in metadata.Keys) + { + var value = metadata[key]; + builder.Append($"|{key.ToString()}={value.ToString()}"); + } + } + else + { + builder.Append($"Identity="); + EscapeValue(item.ItemSpec, builder); + + var metadata = item.CloneCustomMetadata(); + foreach (var key in metadata.Keys) + { + builder.Append($"|{key.ToString()}="); + + var value = metadata[key]; + EscapeValue(value.ToString(), builder); + } + } + + return builder.ToString(); + } + + /// + /// Recreate an with metadata encoded in given . + /// + /// The serialized metadata. + /// The deserialized . + public static ITaskItem DeserializeMetadata(string value) + { + var metadata = value.Split('|'); + var item = new TaskItem(); + + // TaskItem implements ITaskITem2 explicitly and ITaskItem implicitly. + var item2 = (ITaskItem2)item; + foreach (var segment in metadata) + { + var keyAndValue = segment.Split(new[] { '=' }, count: 2); + if (string.Equals("Identity", keyAndValue[0])) + { + item2.EvaluatedIncludeEscaped = keyAndValue[1]; + continue; + } + + item2.SetMetadata(keyAndValue[0], keyAndValue[1]); + } + + return item; + } + + private static void EscapeValue(string value, StringBuilder builder) + { + if (string.IsNullOrEmpty(value)) + { + builder.Append(value); + return; + } + + if (value.IndexOfAny(CharsToEscape) == -1) + { + builder.Append(value); + return; + } + + foreach (var @char in value) + { + if (CharsToEscapeHash.Contains(@char)) + { + builder.Append('%'); + builder.Append(HexDigitChar(@char / 0x10)); + builder.Append(HexDigitChar(@char & 0x0F)); + continue; + } + + builder.Append(@char); + } + } + + private static char HexDigitChar(int x) + { + return (char)(x + (x < 10 ? '0' : ('a' - 10))); + } + } +} diff --git a/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.csproj b/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.csproj new file mode 100644 index 0000000000..7809b19970 --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.csproj @@ -0,0 +1,91 @@ + + + + $(GenerateNuspecDependsOn);PopulateNuspec + + + true + + Microsoft.Extensions.ApiDescription.Tasks + MSBuild tasks and targets for code generation + false + false + false + false + $(MSBuildProjectName).nuspec + $(MSBuildProjectName) + Build Tasks;MSBuild;Swagger;Open API;code generation; Web API client + $(AssemblyName) + netstandard2.0;net461 + + + + + + + + + + $(AssemblySigningCertName) + tasks/$(TargetFramework)/$(TargetFileName) + $(AssemblySigningStrongName) + + + + + + + $(AssemblySigningCertName) + tools/dotnet-getdocument.dll + $(AssemblySigningStrongName) + + + $(AssemblySigningCertName) + tools/net461/GetDocument.Insider.exe + $(AssemblySigningStrongName) + + + $(AssemblySigningCertName) + tools/net461-x86/GetDocument.Insider.exe + $(AssemblySigningStrongName) + + + $(AssemblySigningCertName) + tools/netcoreapp2.0/GetDocument.Insider.exe + $(AssemblySigningStrongName) + + + + tools/Newtonsoft.Json.dll" + $(AssemblySigning3rdPartyCertName) + + + + + + + + + id=$(PackageId); + authors=$(Authors); + configuration=$(Configuration); + copyright=$(Copyright); + description=$(PackageDescription); + iconUrl=$(PackageIconUrl); + licenseUrl=$(PackageLicenseUrl); + owners=$(Company); + projectUrl=$(PackageProjectUrl); + repositoryCommit=$(RepositoryCommit); + repositoryUrl=$(RepositoryUrl); + tags=$(PackageTags.Replace(';', ' ')); + version=$(PackageVersion); + + + + diff --git a/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.nuspec b/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.nuspec new file mode 100644 index 0000000000..e4b8130e5b --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.nuspec @@ -0,0 +1,30 @@ + + + + $id$ + $authors$ + $copyright$ + $description$ + true + $iconUrl$ + $licenseUrl$ + 2.8 + $owners$ + $projectUrl$ + + true + $tags$ + $version$ + + + + + + + + + + + + + diff --git a/src/Microsoft.Extensions.ApiDescription.Design/Properties/Resources.Designer.cs b/src/Microsoft.Extensions.ApiDescription.Design/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..b72e347e21 --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/Properties/Resources.Designer.cs @@ -0,0 +1,86 @@ +// +namespace Microsoft.Extensions.ApiDescription.Tasks +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.Extensions.ApiDescription.Tasks.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// Multiple items have OutputPath='{0}'. All ServiceFileReference, ServiceProjectReference and ServiceUriReference items must have unique OutputPath metadata. + /// + internal static string DuplicateFileOutputPaths + { + get => GetString("DuplicateFileOutputPaths"); + } + + /// + /// Multiple items have OutputPath='{0}'. All ServiceFileReference, ServiceProjectReference and ServiceUriReference items must have unique OutputPath metadata. + /// + internal static string FormatDuplicateFileOutputPaths(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("DuplicateFileOutputPaths"), p0); + + /// + /// Mutliple ServiceProjectReference items have DocumentPath='{0}'. ServiceProjectReference items must have unique DocumentPath metadata. + /// + internal static string DuplicateProjectDocumentPaths + { + get => GetString("DuplicateProjectDocumentPaths"); + } + + /// + /// Mutliple ServiceProjectReference items have DocumentPath='{0}'. ServiceProjectReference items must have unique DocumentPath metadata. + /// + internal static string FormatDuplicateProjectDocumentPaths(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("DuplicateProjectDocumentPaths"), p0); + + /// + /// Mutliple ServiceUriReference items have DocumentPath='{0}'. ServiceUriReference items must have unique DocumentPath metadata. + /// + internal static string DuplicateUriDocumentPaths + { + get => GetString("DuplicateUriDocumentPaths"); + } + + /// + /// Mutliple ServiceUriReference items have DocumentPath='{0}'. ServiceUriReference items must have unique DocumentPath metadata. + /// + internal static string FormatDuplicateUriDocumentPaths(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("DuplicateUriDocumentPaths"), p0); + + /// + /// Invalid {0} metadata value for {1} item '{2}'. {0} metadata must not be set to the empty string. + /// + internal static string InvalidEmptyMetadataValue + { + get => GetString("InvalidEmptyMetadataValue"); + } + + /// + /// Invalid {0} metadata value for {1} item '{2}'. {0} metadata must not be set to the empty string. + /// + internal static string FormatInvalidEmptyMetadataValue(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("InvalidEmptyMetadataValue"), p0, p1, p2); + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Microsoft.Extensions.ApiDescription.Design/Resources.resx b/src/Microsoft.Extensions.ApiDescription.Design/Resources.resx new file mode 100644 index 0000000000..68fa954f09 --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/Resources.resx @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Multiple items have OutputPath='{0}'. All ServiceFileReference, ServiceProjectReference and ServiceUriReference items must have unique OutputPath metadata. + ServiceProjectReference and ServiceUriReference items become ServiceFileReference items and all ServiceFileReference items must have unique OutputPath metadata. + + + Mutliple ServiceProjectReference items have DocumentPath='{0}'. ServiceProjectReference items must have unique DocumentPath metadata. + + + Mutliple ServiceUriReference items have DocumentPath='{0}'. ServiceUriReference items must have unique DocumentPath metadata. + Ignore corner case of ServiceProjectReference and ServiceUriReference items having the same DocumentPath. + + + Invalid {0} metadata value for {1} item '{2}'. {0} metadata must not be set to the empty string. + + \ No newline at end of file diff --git a/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.props b/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.props new file mode 100644 index 0000000000..3a3177cdc9 --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.props @@ -0,0 +1,140 @@ + + + + <_ApiDescriptionTasksAssemblyTarget + Condition="'$(MSBuildRuntimeType)' == 'Core'">netstandard2.0 + <_ApiDescriptionTasksAssemblyTarget + Condition="'$(MSBuildRuntimeType)' != 'Core'">net461 + <_ApiDescriptionTasksAssemblyPath>$(MSBuildThisFileDirectory)/../tasks/$(_ApiDescriptionTasksAssemblyTarget)/Microsoft.Extensions.ApiDescription.Tasks.dll + <_ApiDescriptionTasksAssemblyTarget /> + + + + + + + + + + true + $([MSBuild]::EnsureTrailingSlash('$(ServiceProjectReferenceDirectory)')) + + true + $([MSBuild]::EnsureTrailingSlash('$(ServiceUriReferenceDirectory)')) + + true + $([MSBuild]::EnsureTrailingSlash('$(ServiceFileReferenceDirectory)')) + + + + + + + + + Default + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.targets b/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.targets new file mode 100644 index 0000000000..de1655454c --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.targets @@ -0,0 +1,335 @@ + + + + + + _GetTargetFrameworkForServiceProjectReferences; + _GetTargetPathForServiceProjectReferences; + _GetMetadataForServiceProjectReferences; + _BuildServiceProjectReferences; + _GenerateServiceProjectReferenceDocuments; + _CreateFileItemsForServiceProjectReferences + + + _GetMetadataForServiceUriReferences; + _GenerateServiceUriReferenceDocuments + + + GenerateServiceProjectReferenceDocuments; + GenerateServiceUriReferenceDocuments; + _GetMetadataForServiceFileReferences; + _GenerateServiceFileReferenceCodes; + _CreateCompileItemsForServiceFileReferences + + + + + + + + + <_FullPath>%(ServiceProjectReference.FullPath) + + + <_Temporary Remove="@(_Temporary)" /> + + + + + + + + + <_TargetFrameworks>%(_Temporary.TargetFrameworks) + <_TargetFramework>$(_TargetFrameworks.Split(';')[0]) + + + + $(_TargetFramework) + + <_Temporary Remove="@(_Temporary)" /> + + + + <_FullPath /> + <_TargetFramework /> + <_TargetFrameworks /> + + + + + + + <_FullPath>%(ServiceProjectReference.FullPath) + <_TargetFramework>%(ServiceProjectReference.TargetFramework) + + + <_Temporary Remove="@(_Temporary)" /> + + + + + + + + <_TargetPath>%(_Temporary.FullPath) + + + + $(_TargetPath) + + <_Temporary Remove="@(_Temporary)" /> + + + + <_FullPath /> + <_TargetPath /> + <_TargetFramework /> + + + + + + + <_Temporary Remove="@(_Temporary)" /> + + + + + + + + + + + + <_Temporary Remove="@(_Temporary)" /> + + + + + + + + + + + + + + + + + + + + + + + + + + %(ServiceProjectReference.FullPath) + + + + + + + + + + + + + dotnet $(MSBuildThisFileDirectory)/../tools/dotnet-getdocument.dll --project %(FullPath) + $(Configuration) + $(GenerateDefaultDocumentDefaultOptions) + + + %(Command) --framework %(TargetFramework) --output %(DocumentPath) + + + %(Command) --method %(Method) + + + %(Command) --service %(Service) + + + %(Command) --projectExtensionsPath %(ProjectExtensionsPath) + + + %(Command) --configuration %(Configuration) %(GenerateDefaultDocumentOptions) + + + + + + + + + + + + + <_Temporary Remove="@(_Temporary)" /> + + + + + + + + + + <_Temporary Remove="@(_Temporary)" /> + + + + + + + + + + + %(ServiceUriReference.Identity) + + + + + + + + + + + <_Temporary Remove="@(_Temporary)" /> + + + + + + + + + + <_Temporary Remove="@(_Temporary)" /> + + + + + + + + + + + + + + + + + + + <_Files Remove="@(_Files)" /> + <_Files Include="@(ServiceFileReference -> '%(OutputPath)')" + Condition="$([System.IO.File]::Exists('%(ServiceFileReference.OutputPath)'))"> + $([System.IO.Path]::GetExtension('%(ServiceFileReference.OutputPath)')) + + <_Directories Remove="@(_Directories)" /> + <_Directories Include="@(ServiceFileReference -> '%(OutputPath)')" + Condition="Exists('%(ServiceFileReference.OutputPath)') AND ! $([System.IO.File]::Exists('%(ServiceFileReference.OutputPath)'))" /> + + + + + %(_Files.FullPath) + + + + + %(ServiceFileReference.FullPath) + + + + + + %(_Directories.FullPath) + + + + + %(_Directories.FullPath) + + + <_Files Remove="@(_Files)" /> + <_Directories Remove="@(_Directories)" /> + + + + + diff --git a/src/Microsoft.Extensions.ApiDescription.Design/buildMultiTargeting/Microsoft.Extensions.ApiDescription.Design.targets b/src/Microsoft.Extensions.ApiDescription.Design/buildMultiTargeting/Microsoft.Extensions.ApiDescription.Design.targets new file mode 100644 index 0000000000..af5d08a6bb --- /dev/null +++ b/src/Microsoft.Extensions.ApiDescription.Design/buildMultiTargeting/Microsoft.Extensions.ApiDescription.Design.targets @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/dotnet-getdocument/Commands/InvokeCommand.cs b/src/dotnet-getdocument/Commands/InvokeCommand.cs new file mode 100644 index 0000000000..4d24919c08 --- /dev/null +++ b/src/dotnet-getdocument/Commands/InvokeCommand.cs @@ -0,0 +1,234 @@ +// 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.IO; +using System.Linq; +using System.Runtime.Versioning; +using Microsoft.DotNet.Cli.CommandLine; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Extensions.ApiDescription.Tool.Commands +{ + internal class InvokeCommand : HelpCommandBase + { + private const string InsideManName = "GetDocument.Insider"; + + private IList _args; + private CommandOption _configuration; + private CommandOption _output; + private CommandOption _project; + private CommandOption _projectExtensionsPath; + private CommandOption _runtime; + private CommandOption _targetFramework; + + public override void Configure(CommandLineApplication command) + { + var options = new ProjectOptions(); + options.Configure(command); + + _configuration = options.Configuration; + _project = options.Project; + _projectExtensionsPath = options.ProjectExtensionsPath; + _runtime = options.Runtime; + _targetFramework = options.TargetFramework; + + _output = command.Option("--output ", Resources.OutputDescription); + command.VersionOption("--version", ProductInfo.GetVersion); + _args = command.RemainingArguments; + + base.Configure(command); + } + + protected override int Execute() + { + var projectFile = FindProjects( + _project.Value(), + Resources.NoProject, + Resources.MultipleProjects); + Reporter.WriteVerbose(Resources.FormatUsingProject(projectFile)); + + var project = Project.FromFile( + projectFile, + _projectExtensionsPath.Value(), + _targetFramework.Value(), + _configuration.Value(), + _runtime.Value()); + if (!File.Exists(project.TargetPath)) + { + throw new CommandException(Resources.MustBuild); + } + + var thisPath = Path.GetFullPath(Path.GetDirectoryName(typeof(InvokeCommand).Assembly.Location)); + + string executable = null; + var cleanupExecutable = false; + try + { + string toolsDirectory; + var args = new List(); + var targetFramework = new FrameworkName(project.TargetFrameworkMoniker); + switch (targetFramework.Identifier) + { + case ".NETFramework": + cleanupExecutable = true; + executable = Path.Combine(project.OutputPath, InsideManName + ".exe"); + toolsDirectory = Path.Combine( + thisPath, + project.PlatformTarget == "x86" ? "net461-x86" : "net461"); + + var executableSource = Path.Combine(toolsDirectory, InsideManName + ".exe"); + File.Copy(executableSource, executable, overwrite: true); + + if (!string.IsNullOrEmpty(project.ConfigPath)) + { + File.Copy(project.ConfigPath, executable + ".config", overwrite: true); + } + break; + + case ".NETCoreApp": + executable = "dotnet"; + toolsDirectory = Path.Combine(thisPath, "netcoreapp2.0"); + + if (targetFramework.Version < new Version(2, 0)) + { + throw new CommandException( + Resources.FormatNETCoreApp1Project(project.ProjectName, targetFramework.Version)); + } + + args.Add("exec"); + args.Add("--depsFile"); + args.Add(project.ProjectDepsFilePath); + + if (!string.IsNullOrEmpty(project.ProjectAssetsFile)) + { + using (var reader = new JsonTextReader(File.OpenText(project.ProjectAssetsFile))) + { + var projectAssets = JToken.ReadFrom(reader); + var packageFolders = projectAssets["packageFolders"] + .Children() + .Select(p => p.Name); + + foreach (var packageFolder in packageFolders) + { + args.Add("--additionalProbingPath"); + args.Add(packageFolder.TrimEnd(Path.DirectorySeparatorChar)); + } + } + } + + if (File.Exists(project.ProjectRuntimeConfigFilePath)) + { + args.Add("--runtimeConfig"); + args.Add(project.ProjectRuntimeConfigFilePath); + } + else if (!string.IsNullOrEmpty(project.RuntimeFrameworkVersion)) + { + args.Add("--fx-version"); + args.Add(project.RuntimeFrameworkVersion); + } + + args.Add(Path.Combine(toolsDirectory, InsideManName + ".dll")); + break; + + case ".NETStandard": + throw new CommandException(Resources.FormatNETStandardProject(project.ProjectName)); + + default: + throw new CommandException( + Resources.FormatUnsupportedFramework(project.ProjectName, targetFramework.Identifier)); + } + + args.AddRange(_args); + args.Add("--assembly"); + args.Add(project.TargetPath); + args.Add("--tools-directory"); + args.Add(toolsDirectory); + + if (!(args.Contains("--method") || string.IsNullOrEmpty(project.DefaultMethod))) + { + args.Add("--method"); + args.Add(project.DefaultMethod); + } + + if (!(args.Contains("--service") || string.IsNullOrEmpty(project.DefaultService))) + { + args.Add("--service"); + args.Add(project.DefaultService); + } + + if (_output.HasValue()) + { + args.Add("--output"); + args.Add(Path.GetFullPath(_output.Value())); + } + + if (Reporter.IsVerbose) + { + args.Add("--verbose"); + } + + if (Reporter.NoColor) + { + args.Add("--no-color"); + } + + if (Reporter.PrefixOutput) + { + args.Add("--prefix-output"); + } + + return Exe.Run(executable, args, project.ProjectDirectory); + } + finally + { + if (cleanupExecutable && !string.IsNullOrEmpty(executable)) + { + File.Delete(executable); + File.Delete(executable + ".config"); + } + } + } + + private static string FindProjects( + string path, + string errorWhenNoProject, + string errorWhenMultipleProjects) + { + var specified = true; + if (path == null) + { + specified = false; + path = Directory.GetCurrentDirectory(); + } + else if (!Directory.Exists(path)) // It's not a directory + { + return path; + } + + var projectFiles = Directory + .EnumerateFiles(path, "*.*proj", SearchOption.TopDirectoryOnly) + .Where(f => !string.Equals(Path.GetExtension(f), ".xproj", StringComparison.OrdinalIgnoreCase)) + .Take(2) + .ToList(); + if (projectFiles.Count == 0) + { + throw new CommandException( + specified + ? Resources.FormatNoProjectInDirectory(path) + : errorWhenNoProject); + } + if (projectFiles.Count != 1) + { + throw new CommandException( + specified + ? Resources.FormatMultipleProjectsInDirectory(path) + : errorWhenMultipleProjects); + } + + return projectFiles[0]; + } + } +} diff --git a/src/dotnet-getdocument/Exe.cs b/src/dotnet-getdocument/Exe.cs new file mode 100644 index 0000000000..08fc4217ba --- /dev/null +++ b/src/dotnet-getdocument/Exe.cs @@ -0,0 +1,118 @@ +// 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.Diagnostics; +using System.Text; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal static class Exe + { + public static int Run( + string executable, + IReadOnlyList args, + string workingDirectory = null, + bool interceptOutput = false) + { + var arguments = ToArguments(args); + + Reporter.WriteVerbose(executable + " " + arguments); + + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = interceptOutput + }; + if (workingDirectory != null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + var process = Process.Start(startInfo); + + if (interceptOutput) + { + string line; + while ((line = process.StandardOutput.ReadLine()) != null) + { + Reporter.WriteVerbose(line); + } + } + + process.WaitForExit(); + + return process.ExitCode; + } + + private static string ToArguments(IReadOnlyList args) + { + var builder = new StringBuilder(); + for (var i = 0; i < args.Count; i++) + { + if (i != 0) + { + builder.Append(" "); + } + + if (args[i].IndexOf(' ') == -1) + { + builder.Append(args[i]); + + continue; + } + + builder.Append("\""); + + var pendingBackslashs = 0; + for (var j = 0; j < args[i].Length; j++) + { + switch (args[i][j]) + { + case '\"': + if (pendingBackslashs != 0) + { + builder.Append('\\', pendingBackslashs * 2); + pendingBackslashs = 0; + } + builder.Append("\\\""); + break; + + case '\\': + pendingBackslashs++; + break; + + default: + if (pendingBackslashs != 0) + { + if (pendingBackslashs == 1) + { + builder.Append("\\"); + } + else + { + builder.Append('\\', pendingBackslashs * 2); + } + + pendingBackslashs = 0; + } + + builder.Append(args[i][j]); + break; + } + } + + if (pendingBackslashs != 0) + { + builder.Append('\\', pendingBackslashs * 2); + } + + builder.Append("\""); + } + + return builder.ToString(); + } + } +} diff --git a/src/dotnet-getdocument/Program.cs b/src/dotnet-getdocument/Program.cs new file mode 100644 index 0000000000..94958f2840 --- /dev/null +++ b/src/dotnet-getdocument/Program.cs @@ -0,0 +1,42 @@ +// 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 Microsoft.DotNet.Cli.CommandLine; +using Microsoft.Extensions.ApiDescription.Tool.Commands; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal static class Program + { + private static int Main(string[] args) + { + var app = new CommandLineApplication(throwOnUnexpectedArg: false) + { + FullName = Resources.CommandFullName, + }; + + new InvokeCommand().Configure(app); + + try + { + return app.Execute(args); + } + catch (Exception ex) + { + if (ex is CommandException || ex is CommandParsingException) + { + Reporter.WriteVerbose(ex.ToString()); + } + else + { + Reporter.WriteInformation(ex.ToString()); + } + + Reporter.WriteError(ex.Message); + + return 1; + } + } + } +} diff --git a/src/dotnet-getdocument/Project.cs b/src/dotnet-getdocument/Project.cs new file mode 100644 index 0000000000..dc4a06ea36 --- /dev/null +++ b/src/dotnet-getdocument/Project.cs @@ -0,0 +1,235 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; +using IODirectory = System.IO.Directory; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal class Project + { + private const string ResourceFilename = "ServiceProjectReferenceMetadata.targets"; + private const string MSBuildResourceName = "Microsoft.Extensions.ApiDescription.Tool." + ResourceFilename; + + private Project() + { + } + + public string AssemblyName { get; private set; } + + public string ConfigPath { get; private set; } + + public string Configuration { get; private set; } + + public string DefaultDocumentName { get; private set; } + + public string DefaultMethod { get; private set; } + + public string DefaultService { get; private set; } + + public string OutputPath { get; private set; } + + public string Platform { get; private set; } + + public string PlatformTarget { get; private set; } + + public string ProjectAssetsFile { get; private set; } + + public string ProjectDepsFilePath { get; private set; } + + public string ProjectDirectory { get; private set; } + + public string ProjectExtensionsPath { get; private set; } + + public string ProjectName { get; private set; } + + public string ProjectRuntimeConfigFilePath { get; private set; } + + public string RuntimeFrameworkVersion { get; private set; } + + public string RuntimeIdentifier { get; private set; } + + public string TargetFramework { get; private set; } + + public string TargetFrameworkMoniker { get; private set; } + + public string TargetPath { get; private set; } + + public static Project FromFile( + string projectFile, + string buildExtensionsDirectory, + string framework = null, + string configuration = null, + string runtime = null) + { + if (string.IsNullOrEmpty(projectFile)) + { + throw new ArgumentNullException(nameof(projectFile)); + } + + if (string.IsNullOrEmpty(buildExtensionsDirectory)) + { + buildExtensionsDirectory = Path.Combine(Path.GetDirectoryName(projectFile), "obj"); + } + + IODirectory.CreateDirectory(buildExtensionsDirectory); + + var assembly = typeof(Project).Assembly; + var targetsPath = Path.Combine( + buildExtensionsDirectory, + $"{Path.GetFileName(projectFile)}.{ResourceFilename}"); + using (var input = assembly.GetManifestResourceStream(MSBuildResourceName)) + { + using (var output = File.OpenWrite(targetsPath)) + { + // NB: Copy always in case it changes + Reporter.WriteVerbose(Resources.FormatWritingFile(targetsPath)); + input.CopyTo(output); + } + } + + IDictionary metadata; + var metadataPath = Path.GetTempFileName(); + try + { + var args = new List + { + "msbuild", + "/target:WriteServiceProjectReferenceMetadata", + "/verbosity:quiet", + "/nologo", + $"/property:ServiceProjectReferenceMetadataPath={metadataPath}", + projectFile, + }; + + if (!string.IsNullOrEmpty(framework)) + { + args.Add($"/property:TargetFramework={framework}"); + } + + if (!string.IsNullOrEmpty(configuration)) + { + args.Add($"/property:Configuration={configuration}"); + } + + if (!string.IsNullOrEmpty(runtime)) + { + args.Add($"/property:RuntimeIdentifier={runtime}"); + } + + var exitCode = Exe.Run("dotnet", args); + if (exitCode != 0) + { + throw new CommandException(Resources.GetMetadataFailed); + } + + metadata = File + .ReadLines(metadataPath) + .Select(l => l.Split(new[] { ':' }, 2)) + .ToDictionary(s => s[0], s => s[1].TrimStart()); + } + finally + { + File.Delete(metadataPath); + File.Delete(targetsPath); + } + + var project = new Project + { + DefaultDocumentName = metadata[nameof(DefaultDocumentName)], + DefaultMethod = metadata[nameof(DefaultMethod)], + DefaultService = metadata[nameof(DefaultService)], + + AssemblyName = metadata[nameof(AssemblyName)], + Configuration = metadata[nameof(Configuration)], + OutputPath = metadata[nameof(OutputPath)], + Platform = metadata[nameof(Platform)], + PlatformTarget = metadata[nameof(PlatformTarget)] ?? metadata[nameof(Platform)], + ProjectAssetsFile = metadata[nameof(ProjectAssetsFile)], + ProjectDepsFilePath = metadata[nameof(ProjectDepsFilePath)], + ProjectDirectory = metadata[nameof(ProjectDirectory)], + ProjectExtensionsPath = metadata[nameof(ProjectExtensionsPath)], + ProjectName = metadata[nameof(ProjectName)], + ProjectRuntimeConfigFilePath = metadata[nameof(ProjectRuntimeConfigFilePath)], + RuntimeFrameworkVersion = metadata[nameof(RuntimeFrameworkVersion)], + RuntimeIdentifier = metadata[nameof(RuntimeIdentifier)], + TargetFramework = metadata[nameof(TargetFramework)], + TargetFrameworkMoniker = metadata[nameof(TargetFrameworkMoniker)], + TargetPath = metadata[nameof(TargetPath)], + }; + + if (string.IsNullOrEmpty(project.OutputPath)) + { + throw new CommandException( + Resources.FormatGetMetadataValueFailed(nameof(OutputPath), nameof(OutputPath))); + } + + if (string.IsNullOrEmpty(project.ProjectDirectory)) + { + throw new CommandException( + Resources.FormatGetMetadataValueFailed(nameof(ProjectDirectory), "MSBuildProjectDirectory")); + } + + if (string.IsNullOrEmpty(project.TargetPath)) + { + throw new CommandException( + Resources.FormatGetMetadataValueFailed(nameof(TargetPath), nameof(TargetPath))); + } + + if (!Path.IsPathRooted(project.ProjectDirectory)) + { + project.OutputPath = Path.GetFullPath( + Path.Combine(IODirectory.GetCurrentDirectory(), project.ProjectDirectory)); + } + + if (!Path.IsPathRooted(project.OutputPath)) + { + project.OutputPath = Path.GetFullPath(Path.Combine(project.ProjectDirectory, project.OutputPath)); + } + + if (!Path.IsPathRooted(project.ProjectExtensionsPath)) + { + project.ProjectExtensionsPath = Path.GetFullPath( + Path.Combine(project.ProjectDirectory, project.ProjectExtensionsPath)); + } + + if (!Path.IsPathRooted(project.TargetPath)) + { + project.TargetPath = Path.GetFullPath(Path.Combine(project.OutputPath, project.TargetPath)); + } + + // Some document generation tools support non-ASP.NET Core projects. Any of the remaining properties may + // thus be null empty. + var configPath = $"{project.TargetPath}.config"; + if (File.Exists(configPath)) + { + project.ConfigPath = configPath; + } + + if (!(string.IsNullOrEmpty(project.ProjectAssetsFile) || Path.IsPathRooted(project.ProjectAssetsFile))) + { + project.ProjectAssetsFile = Path.GetFullPath( + Path.Combine(project.ProjectDirectory, project.ProjectAssetsFile)); + } + + if (!(string.IsNullOrEmpty(project.ProjectDepsFilePath) || Path.IsPathRooted(project.ProjectDepsFilePath))) + { + project.ProjectDepsFilePath = Path.GetFullPath( + Path.Combine(project.ProjectDirectory, project.ProjectDepsFilePath)); + } + + if (!(string.IsNullOrEmpty(project.ProjectRuntimeConfigFilePath) || + Path.IsPathRooted(project.ProjectRuntimeConfigFilePath))) + { + project.ProjectRuntimeConfigFilePath = Path.GetFullPath( + Path.Combine(project.OutputPath, project.ProjectRuntimeConfigFilePath)); + } + + return project; + } + } +} diff --git a/src/dotnet-getdocument/ProjectOptions.cs b/src/dotnet-getdocument/ProjectOptions.cs new file mode 100644 index 0000000000..c235c2191a --- /dev/null +++ b/src/dotnet-getdocument/ProjectOptions.cs @@ -0,0 +1,31 @@ +// 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 Microsoft.DotNet.Cli.CommandLine; + +namespace Microsoft.Extensions.ApiDescription.Tool +{ + internal class ProjectOptions + { + public CommandOption Configuration { get; private set; } + + public CommandOption Project { get; private set; } + + public CommandOption ProjectExtensionsPath { get; private set; } + + public CommandOption Runtime { get; private set; } + + public CommandOption TargetFramework { get; private set; } + + public void Configure(CommandLineApplication command) + { + Configuration = command.Option("--configuration ", Resources.ConfigurationDescription); + Project = command.Option("-p|--project ", Resources.ProjectDescription); + ProjectExtensionsPath = command.Option( + "--projectExtensionsPath ", + Resources.ProjectExtensionsPathDescription); + Runtime = command.Option("--runtime ", Resources.RuntimeDescription); + TargetFramework = command.Option("--framework ", Resources.TargetFrameworkDescription); + } + } +} diff --git a/src/dotnet-getdocument/Properties/Resources.Designer.cs b/src/dotnet-getdocument/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..f2b9084ae1 --- /dev/null +++ b/src/dotnet-getdocument/Properties/Resources.Designer.cs @@ -0,0 +1,338 @@ +// +namespace Microsoft.Extensions.ApiDescription.Tool +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.Extensions.ApiDescription.Tool.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// The configuration to use. + /// + internal static string ConfigurationDescription + { + get => GetString("ConfigurationDescription"); + } + + /// + /// The configuration to use. + /// + internal static string FormatConfigurationDescription() + => GetString("ConfigurationDescription"); + + /// + /// dotnet-getdocument + /// + internal static string CommandFullName + { + get => GetString("CommandFullName"); + } + + /// + /// dotnet-getdocument + /// + internal static string FormatCommandFullName() + => GetString("CommandFullName"); + + /// + /// The target framework. + /// + internal static string TargetFrameworkDescription + { + get => GetString("TargetFrameworkDescription"); + } + + /// + /// The target framework. + /// + internal static string FormatTargetFrameworkDescription() + => GetString("TargetFrameworkDescription"); + + /// + /// Unable to retrieve project metadata. If you are using custom BaseIntermediateOutputPath or MSBuildProjectExtensionsPath values, use the --projectExtensionsPath option. + /// + internal static string GetMetadataFailed + { + get => GetString("GetMetadataFailed"); + } + + /// + /// Unable to retrieve project metadata. If you are using custom BaseIntermediateOutputPath or MSBuildProjectExtensionsPath values, use the --projectExtensionsPath option. + /// + internal static string FormatGetMetadataFailed() + => GetString("GetMetadataFailed"); + + /// + /// More than one project was found in the current working directory. Use the --project option. + /// + internal static string MultipleProjects + { + get => GetString("MultipleProjects"); + } + + /// + /// More than one project was found in the current working directory. Use the --project option. + /// + internal static string FormatMultipleProjects() + => GetString("MultipleProjects"); + + /// + /// More than one project was found in directory '{0}'. Specify one using its file name. + /// + internal static string MultipleProjectsInDirectory + { + get => GetString("MultipleProjectsInDirectory"); + } + + /// + /// More than one project was found in directory '{0}'. Specify one using its file name. + /// + internal static string FormatMultipleProjectsInDirectory(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("MultipleProjectsInDirectory"), p0); + + /// + /// Project '{0}' targets framework '.NETCoreApp' version '{1}'. This version of the dotnet-getdocument tool only supports version 2.0 or higher. + /// + internal static string NETCoreApp1Project + { + get => GetString("NETCoreApp1Project"); + } + + /// + /// Project '{0}' targets framework '.NETCoreApp' version '{1}'. This version of the dotnet-getdocument tool only supports version 2.0 or higher. + /// + internal static string FormatNETCoreApp1Project(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("NETCoreApp1Project"), p0, p1); + + /// + /// Project '{0}' targets framework '.NETStandard'. There is no runtime associated with this framework, and projects targeting it cannot be executed directly. To use the dotnet-getdocument tool with this project, add an executable project targeting .NET Core or .NET Framework that references this project and specify it using the --project option; or, update this project to target .NET Core and / or .NET Framework. + /// + internal static string NETStandardProject + { + get => GetString("NETStandardProject"); + } + + /// + /// Project '{0}' targets framework '.NETStandard'. There is no runtime associated with this framework, and projects targeting it cannot be executed directly. To use the dotnet-getdocument tool with this project, add an executable project targeting .NET Core or .NET Framework that references this project and specify it using the --project option; or, update this project to target .NET Core and / or .NET Framework. + /// + internal static string FormatNETStandardProject(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("NETStandardProject"), p0); + + /// + /// Do not colorize output. + /// + internal static string NoColorDescription + { + get => GetString("NoColorDescription"); + } + + /// + /// Do not colorize output. + /// + internal static string FormatNoColorDescription() + => GetString("NoColorDescription"); + + /// + /// No project was found. Change the current working directory or use the --project option. + /// + internal static string NoProject + { + get => GetString("NoProject"); + } + + /// + /// No project was found. Change the current working directory or use the --project option. + /// + internal static string FormatNoProject() + => GetString("NoProject"); + + /// + /// No project was found in directory '{0}'. + /// + internal static string NoProjectInDirectory + { + get => GetString("NoProjectInDirectory"); + } + + /// + /// No project was found in directory '{0}'. + /// + internal static string FormatNoProjectInDirectory(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("NoProjectInDirectory"), p0); + + /// + /// Prefix output with level. + /// + internal static string PrefixDescription + { + get => GetString("PrefixDescription"); + } + + /// + /// Prefix output with level. + /// + internal static string FormatPrefixDescription() + => GetString("PrefixDescription"); + + /// + /// The project to use. + /// + internal static string ProjectDescription + { + get => GetString("ProjectDescription"); + } + + /// + /// The project to use. + /// + internal static string FormatProjectDescription() + => GetString("ProjectDescription"); + + /// + /// The MSBuild project extensions path. Defaults to "obj". + /// + internal static string ProjectExtensionsPathDescription + { + get => GetString("ProjectExtensionsPathDescription"); + } + + /// + /// The MSBuild project extensions path. Defaults to "obj". + /// + internal static string FormatProjectExtensionsPathDescription() + => GetString("ProjectExtensionsPathDescription"); + + /// + /// The runtime identifier to use. + /// + internal static string RuntimeDescription + { + get => GetString("RuntimeDescription"); + } + + /// + /// The runtime identifier to use. + /// + internal static string FormatRuntimeDescription() + => GetString("RuntimeDescription"); + + /// + /// Project '{0}' targets framework '{1}'. The dotnet-getdocument tool does not support this framework. + /// + internal static string UnsupportedFramework + { + get => GetString("UnsupportedFramework"); + } + + /// + /// Project '{0}' targets framework '{1}'. The dotnet-getdocument tool does not support this framework. + /// + internal static string FormatUnsupportedFramework(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("UnsupportedFramework"), p0, p1); + + /// + /// Using project '{0}'. + /// + internal static string UsingProject + { + get => GetString("UsingProject"); + } + + /// + /// Using project '{0}'. + /// + internal static string FormatUsingProject(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("UsingProject"), p0); + + /// + /// Show verbose output. + /// + internal static string VerboseDescription + { + get => GetString("VerboseDescription"); + } + + /// + /// Show verbose output. + /// + internal static string FormatVerboseDescription() + => GetString("VerboseDescription"); + + /// + /// Writing '{0}'... + /// + internal static string WritingFile + { + get => GetString("WritingFile"); + } + + /// + /// Writing '{0}'... + /// + internal static string FormatWritingFile(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("WritingFile"), p0); + + /// + /// Project output not found. Project must be up-to-date when using this tool. + /// + internal static string MustBuild + { + get => GetString("MustBuild"); + } + + /// + /// Project output not found. Project must be up-to-date when using this tool. + /// + internal static string FormatMustBuild() + => GetString("MustBuild"); + + /// + /// The file to write the result to. + /// + internal static string OutputDescription + { + get => GetString("OutputDescription"); + } + + /// + /// The file to write the result to. + /// + internal static string FormatOutputDescription() + => GetString("OutputDescription"); + + /// + /// Unable to retrieve '{0}' project metadata. Ensure '{1}' is set. + /// + internal static string GetMetadataValueFailed + { + get => GetString("GetMetadataValueFailed"); + } + + /// + /// Unable to retrieve '{0}' project metadata. Ensure '{1}' is set. + /// + internal static string FormatGetMetadataValueFailed(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("GetMetadataValueFailed"), p0, p1); + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/dotnet-getdocument/Resources.resx b/src/dotnet-getdocument/Resources.resx new file mode 100644 index 0000000000..d87157fff2 --- /dev/null +++ b/src/dotnet-getdocument/Resources.resx @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The configuration to use. + + + dotnet-getdocument + + + The target framework. + + + Unable to retrieve project metadata. If you are using custom BaseIntermediateOutputPath or MSBuildProjectExtensionsPath values, use the --projectExtensionsPath option. + + + More than one project was found in the current working directory. Use the --project option. + + + More than one project was found in directory '{0}'. Specify one using its file name. + + + Project '{0}' targets framework '.NETCoreApp' version '{1}'. This version of the dotnet-getdocument tool only supports version 2.0 or higher. + + + Project '{0}' targets framework '.NETStandard'. There is no runtime associated with this framework, and projects targeting it cannot be executed directly. To use the dotnet-getdocument tool with this project, add an executable project targeting .NET Core or .NET Framework that references this project and specify it using the --project option; or, update this project to target .NET Core and / or .NET Framework. + + + Do not colorize output. + + + No project was found. Change the current working directory or use the --project option. + + + No project was found in directory '{0}'. + + + Prefix output with level. + + + The project to use. + + + The MSBuild project extensions path. Defaults to "obj". + + + The runtime identifier to use. + + + Project '{0}' targets framework '{1}'. The dotnet-getdocument tool does not support this framework. + + + Using project '{0}'. + + + Show verbose output. + + + Writing '{0}'... + + + Project output not found. Project must be up-to-date when using this tool. + + + The file to write the result to. + + + Unable to retrieve '{0}' project metadata. Ensure '$({1})' is set. + + diff --git a/src/dotnet-getdocument/ServiceProjectReferenceMetadata.targets b/src/dotnet-getdocument/ServiceProjectReferenceMetadata.targets new file mode 100644 index 0000000000..177d950c9e --- /dev/null +++ b/src/dotnet-getdocument/ServiceProjectReferenceMetadata.targets @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/dotnet-getdocument/dotnet-getdocument.csproj b/src/dotnet-getdocument/dotnet-getdocument.csproj new file mode 100644 index 0000000000..ebda3eb047 --- /dev/null +++ b/src/dotnet-getdocument/dotnet-getdocument.csproj @@ -0,0 +1,25 @@ + + + dotnet-getdocument + GetDocument Command-line Tool outside man + false + false + Exe + Microsoft.Extensions.ApiDescription.Tool + netcoreapp2.1 + + + + + + + + + + + + + + + + diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs index ba70d85c6e..e4e56bebd2 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs @@ -248,6 +248,7 @@ namespace Microsoft.AspNetCore.Mvc new Type[] { typeof(MvcOptionsConfigureCompatibilityOptions), + typeof(MvcCoreMvcOptionsSetup), } }, { diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultObjectValidatorTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultObjectValidatorTests.cs index 5450c507c1..8fad61aadd 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultObjectValidatorTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultObjectValidatorTests.cs @@ -1170,11 +1170,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal var modelState = actionContext.ModelState; var validationState = new ValidationStateDictionary(); - var validator = CreateValidator(typeof(List)); + var validator = CreateValidator(typeof(List)); - var model = new List() + var model = new List() { - "15", + new ValidatedModel { Value = "15" }, }; modelState.SetModelValue("userIds[0]", "15", "15"); @@ -1192,6 +1192,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Empty(entry.Errors); } + private class ValidatedModel + { + [Required] + public string Value { get; set; } + } + [Fact] public void Validate_SuppressesValidation_ForExcludedType_Stream() { @@ -1317,7 +1323,6 @@ namespace Microsoft.AspNetCore.Mvc.Internal { model, new ValidationStateEntry() } }; var method = GetType().GetMethod(nameof(Validate_Throws_ForTopLevelMetadataData), BindingFlags.NonPublic | BindingFlags.Instance); - var metadata = MetadataProvider.GetMetadataForParameter(method.GetParameters()[0]); // Act & Assert var ex = Assert.Throws(() => validator.Validate(actionContext, validationState, prefix: string.Empty, model)); @@ -1325,6 +1330,102 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.NotNull(ex.HelpLink); } + [Fact] + public void Validate_TypeWithoutValidators() + { + var actionContext = new ActionContext(); + var validator = CreateValidator(); + var model = new ModelWithoutValidation(); + var validationState = new ValidationStateDictionary + { + { model, new ValidationStateEntry() } + }; + + actionContext.ModelState.SetModelValue("Property1", new ValueProviderResult("value1")); + actionContext.ModelState.SetModelValue("Property2", new ValueProviderResult("value2")); + + // Act + validator.Validate(actionContext, validationState, string.Empty, model); + + // Assert + var modelState = actionContext.ModelState; + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + Assert.True(modelState.IsValid); + + var entry = modelState["Property1"]; + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = modelState["Property2"]; + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + [Fact] + public void Validate_TypeWithoutValidators_DoesNotUpdateValidationState() + { + var actionContext = new ActionContext(); + var validator = CreateValidator(); + var model = new ModelWithoutValidation(); + var validationState = new ValidationStateDictionary + { + { model, new ValidationStateEntry() } + }; + + var modelState = actionContext.ModelState; + modelState.SetModelValue("Property1", new ValueProviderResult("value1")); + modelState.SetModelValue("Property2", new ValueProviderResult("value2")); + modelState["Property2"].ValidationState = ModelValidationState.Skipped; + + // Act + validator.Validate(actionContext, validationState, string.Empty, model); + + // Assert + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + Assert.True(modelState.IsValid); + + var entry = modelState["Property1"]; + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = modelState["Property2"]; + Assert.Equal(ModelValidationState.Skipped, entry.ValidationState); + } + + [Fact] + public void Validate_TypeWithoutValidators_DoesNotResetInvalidState() + { + var actionContext = new ActionContext(); + var validator = CreateValidator(); + var model = new ModelWithoutValidation(); + var validationState = new ValidationStateDictionary + { + { model, new ValidationStateEntry() } + }; + + var modelState = actionContext.ModelState; + modelState.SetModelValue("Property1", new ValueProviderResult("value1")); + modelState.SetModelValue("Property2", new ValueProviderResult("value2")); + modelState["Property2"].ValidationState = ModelValidationState.Invalid; + + // Act + validator.Validate(actionContext, validationState, string.Empty, model); + + // Assert + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + Assert.False(modelState.IsValid); + + var entry = modelState["Property1"]; + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = modelState["Property2"]; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + } + + private class ModelWithoutValidation + { + public string Property1 { get; set; } + + public string Property2 { get; set; } + } + private static DefaultObjectValidator CreateValidator(Type excludedType) { var excludeFilters = new List(); @@ -1352,6 +1453,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal private class ThrowingProperty { + [Required] public string WatchOut { get @@ -1520,6 +1622,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal Depth = depth; } + [Range(-10, 400)] public int Depth { get; } public int MaxAllowedDepth { get; } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs index 6f3eb0830f..3352890707 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs @@ -6,6 +6,7 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Reflection; using System.Xml; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; @@ -909,6 +910,351 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata metadataProvider.VerifyAll(); } + [Fact] + public void CalculateHasValidators_ParameterMetadata_TypeHasNoValidators() + { + // Arrange + var parameter = GetType() + .GetMethod(nameof(CalculateHasValidators_ParameterMetadata_TypeHasNoValidatorsMethod), BindingFlags.Static | BindingFlags.NonPublic) + .GetParameters()[0]; + var modelIdentity = ModelMetadataIdentity.ForParameter(parameter); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.False(result); + } + + private static void CalculateHasValidators_ParameterMetadata_TypeHasNoValidatorsMethod(string model) { } + + [Fact] + public void CalculateHasValidators_PropertyMetadata_TypeHasNoValidators() + { + // Arrange + var property = GetType() + .GetProperty(nameof(CalculateHasValidators_PropertyMetadata_TypeHasNoValidatorsProperty), BindingFlags.Static | BindingFlags.NonPublic); + var modelIdentity = ModelMetadataIdentity.ForProperty(property.PropertyType, property.Name, GetType()); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.False(result); + } + + private static int CalculateHasValidators_PropertyMetadata_TypeHasNoValidatorsProperty { get; set; } + + [Fact] + public void CalculateHasValidators_TypeWithoutProperties_TypeHasNoValidators() + { + // Arrange + var modelIdentity = ModelMetadataIdentity.ForType(typeof(string)); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.False(result); + } + + [Fact] + public void CalculateHasValidators_SimpleType_TypeHasValidators() + { + // Arrange + var modelIdentity = ModelMetadataIdentity.ForType(typeof(string)); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), true); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_ReturnsTrue_SimpleType_TypeHasNonDeterministicValidators() + { + // Arrange + var modelIdentity = ModelMetadataIdentity.ForType(typeof(string)); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), null); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_TypeWithProperties_PropertyIsNotDefaultModelMetadata() + { + // Arrange + var modelType = typeof(TypeWithProperties); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + + var propertyIdentity = ModelMetadataIdentity.ForProperty(typeof(int), nameof(TypeWithProperties.PublicGetPublicSetProperty), typeof(string)); + var propertyMetadata = new Mock(propertyIdentity); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { propertyMetadata.Object, }) + .Verifiable(); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_TypeWithProperties_HasValidatorForAnyPropertyIsTrue() + { + // Arrange + var modelType = typeof(TypeWithProperties); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + + var property1Identity = ModelMetadataIdentity.ForProperty(typeof(int), nameof(TypeWithProperties.PublicGetPublicSetProperty), typeof(string)); + var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, false); + + var property2Identity = ModelMetadataIdentity.ForProperty(typeof(int), nameof(TypeWithProperties.PublicGetProtectedSetProperty), typeof(string)); + var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, true); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { property1Metadata, property2Metadata }) + .Verifiable(); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_TypeWithProperties_HasValidatorsForPropertyIsNotDeterminstic() + { + // Arrange + var modelType = typeof(TypeWithProperties); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + + var propertyIdentity = ModelMetadataIdentity.ForProperty(typeof(int), nameof(TypeWithProperties.PublicGetPublicSetProperty), typeof(string)); + var propertyMetadata = CreateModelMetadata(propertyIdentity, metadataProvider.Object, null); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { propertyMetadata, }) + .Verifiable(); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_TypeWithProperties_HasValidatorForAllPropertiesIsFalse() + { + // Arrange + var modelType = typeof(TypeWithProperties); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + + var property1Identity = ModelMetadataIdentity.ForProperty(typeof(int), nameof(TypeWithProperties.PublicGetPublicSetProperty), modelType); + var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, false); + + var property2Identity = ModelMetadataIdentity.ForProperty(typeof(int), nameof(TypeWithProperties.PublicGetProtectedSetProperty), modelType); + var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, false); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { property1Metadata, property2Metadata }) + .Verifiable(); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.False(result); + } + + [Fact] + public void CalculateHasValidators_SelfReferencingType_HasValidatorOnNestedProperty() + { + // Arrange + var modelType = typeof(Employee); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + + var employeeId = ModelMetadataIdentity.ForProperty(typeof(int), nameof(Employee.Id), modelType); + var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var employeeUnit = ModelMetadataIdentity.ForProperty(typeof(BusinessUnit), nameof(Employee.Unit), modelType); + var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false); + var employeeManager = ModelMetadataIdentity.ForProperty(typeof(Employee), nameof(Employee.Unit), modelType); + var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false); + var employeeEmployees = ModelMetadataIdentity.ForProperty(typeof(List), nameof(Employee.Employees), modelType); + var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false); + + var unitHead = ModelMetadataIdentity.ForProperty(typeof(Employee), nameof(BusinessUnit.Head), modelType); + var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, false); + var unitId = ModelMetadataIdentity.ForProperty(typeof(int), nameof(BusinessUnit.Id), modelType); + var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, true); // BusinessUnit.Id has validators. + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { employeeIdMetadata, employeeUnitMetadata, employeeManagerMetadata, employeeEmployeesMetadata, }) + .Verifiable(); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(typeof(BusinessUnit))) + .Returns(new[] { unitHeadMetadata, unitIdMetadata, }) + .Verifiable(); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_SelfReferencingType_HasValidatorOnSelfReferencedProperty() + { + // Arrange + var modelType = typeof(Employee); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + + var employeeId = ModelMetadataIdentity.ForProperty(typeof(int), nameof(Employee.Id), modelType); + var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var employeeUnit = ModelMetadataIdentity.ForProperty(typeof(BusinessUnit), nameof(Employee.Unit), modelType); + var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false); + var employeeManager = ModelMetadataIdentity.ForProperty(typeof(Employee), nameof(Employee.Unit), modelType); + var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false); + var employeeEmployees = ModelMetadataIdentity.ForProperty(typeof(List), nameof(Employee.Employees), modelType); + var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false); + + var unitHead = ModelMetadataIdentity.ForProperty(typeof(Employee), nameof(BusinessUnit.Head), modelType); + var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, true); // BusinessUnit.Head has validators + var unitId = ModelMetadataIdentity.ForProperty(typeof(int), nameof(BusinessUnit.Id), modelType); + var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, false); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { employeeIdMetadata, employeeUnitMetadata, employeeManagerMetadata, employeeEmployeesMetadata, }); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(typeof(BusinessUnit))) + .Returns(new[] { unitHeadMetadata, unitIdMetadata, }); + + metadataProvider + .Setup(mp => mp.GetMetadataForType(modelType)) + .Returns(modelMetadata); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_CollectionElementHasValidators() + { + // Arrange + var modelType = typeof(Employee); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + + var employeeId = ModelMetadataIdentity.ForProperty(typeof(int), nameof(Employee.Id), modelType); + var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var employeeEmployees = ModelMetadataIdentity.ForProperty(typeof(List), nameof(Employee.Employees), modelType); + var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { employeeIdMetadata, employeeEmployeesMetadata, }); + + metadataProvider + .Setup(mp => mp.GetMetadataForType(modelType)) + .Returns(CreateModelMetadata(modelIdentity, metadataProvider.Object, true)); // Employees.Employee has validators + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_SelfReferencingType_NoValidatorsInGraph() + { + // Arrange + var modelType = typeof(Employee); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + + var employeeId = ModelMetadataIdentity.ForProperty(typeof(int), nameof(Employee.Id), modelType); + var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var employeeUnit = ModelMetadataIdentity.ForProperty(typeof(BusinessUnit), nameof(Employee.Unit), modelType); + var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false); + var employeeManager = ModelMetadataIdentity.ForProperty(typeof(Employee), nameof(Employee.Unit), modelType); + var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false); + var employeeEmployeesId = ModelMetadataIdentity.ForProperty(typeof(List), nameof(Employee.Employees), modelType); + var employeeEmployeesIdMetadata = CreateModelMetadata(employeeEmployeesId, metadataProvider.Object, false); + + var unitHead = ModelMetadataIdentity.ForProperty(typeof(Employee), nameof(BusinessUnit.Head), modelType); + var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, false); + var unitId = ModelMetadataIdentity.ForProperty(typeof(int), nameof(BusinessUnit.Id), modelType); + var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, false); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { employeeIdMetadata, employeeUnitMetadata, employeeManagerMetadata, employeeEmployeesIdMetadata, }); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(typeof(BusinessUnit))) + .Returns(new[] { unitHeadMetadata, unitIdMetadata, }); + + metadataProvider + .Setup(mp => mp.GetMetadataForType(modelType)) + .Returns(modelMetadata); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.False(result); + } + + private static DefaultModelMetadata CreateModelMetadata( + ModelMetadataIdentity modelIdentity, + IModelMetadataProvider metadataProvider, + bool? hasValidators) + { + return new DefaultModelMetadata( + metadataProvider, + new SetHasValidatorsCompositeMetadataDetailsProvider { HasValidators = hasValidators }, + new DefaultMetadataDetails(modelIdentity, new ModelAttributes(new object[0], new object[0], new object[0]))); + } + private void ActionMethod(string input) { } @@ -921,5 +1267,41 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata public int PublicGetPublicSetProperty { get; set; } } + + public class Employee + { + public int Id { get; set; } + + public BusinessUnit Unit { get; set; } + + public Employee Manager { get; set; } + + public List Employees { get; set; } + } + + public class BusinessUnit + { + public Employee Head { get; set; } + + public int Id { get; set; } + } + + private class SetHasValidatorsCompositeMetadataDetailsProvider : ICompositeMetadataDetailsProvider + { + public bool? HasValidators { get; set; } + + public void CreateBindingMetadata(BindingMetadataProviderContext context) + { + } + + public void CreateDisplayMetadata(DisplayMetadataProviderContext context) + { + } + + public void CreateValidationMetadata(ValidationMetadataProviderContext context) + { + context.ValidationMetadata.HasValidators = HasValidators; + } + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/HasValidatorsValidationMetadataProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/HasValidatorsValidationMetadataProviderTest.cs new file mode 100644 index 0000000000..94ebad7b3b --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/HasValidatorsValidationMetadataProviderTest.cs @@ -0,0 +1,131 @@ +// 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 Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata +{ + public class HasValidatorsValidationMetadataProviderTest + { + [Fact] + public void CreateValidationMetadata_DoesNotSetHasValidators_IfNonMetadataBasedProviderExists() + { + // Arrange + var validationProviders = new IModelValidatorProvider[] + { + new DefaultModelValidatorProvider(), + Mock.Of(), + }; + var metadataProvider = new HasValidatorsValidationMetadataProvider(validationProviders); + + var key = ModelMetadataIdentity.ForType(typeof(object)); + var modelAttributes = new ModelAttributes(new object[0], new object[0], new object[0]); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.Null(context.ValidationMetadata.HasValidators); + } + + [Fact] + public void CreateValidationMetadata_DoesNotSetHasValidators_IfProviderIsConfigured() + { + // Arrange + var validationProviders = new IModelValidatorProvider[0]; + var metadataProvider = new HasValidatorsValidationMetadataProvider(validationProviders); + + var key = ModelMetadataIdentity.ForType(typeof(object)); + var modelAttributes = new ModelAttributes(new object[0], new object[0], new object[0]); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.Null(context.ValidationMetadata.HasValidators); + } + + [Fact] + public void CreateValidationMetadata_SetsHasValidatorsToTrue_IfProviderReturnsTrue() + { + // Arrange + var metadataBasedModelValidatorProvider = new Mock(); + metadataBasedModelValidatorProvider.Setup(p => p.HasValidators(typeof(object), It.IsAny>())) + .Returns(true) + .Verifiable(); + + var validationProviders = new IModelValidatorProvider[] + { + new DefaultModelValidatorProvider(), + metadataBasedModelValidatorProvider.Object, + + }; + var metadataProvider = new HasValidatorsValidationMetadataProvider(validationProviders); + + var key = ModelMetadataIdentity.ForType(typeof(object)); + var modelAttributes = new ModelAttributes(new object[0], new object[0], new object[0]); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.True(context.ValidationMetadata.HasValidators); + metadataBasedModelValidatorProvider.Verify(); + } + + [Fact] + public void CreateValidationMetadata_SetsHasValidatorsToFalse_IfNoProviderReturnsTrue() + { + // Arrange + var provider = Mock.Of(p => p.HasValidators(typeof(object), It.IsAny>()) == false); + var validationProviders = new IModelValidatorProvider[] + { + new DefaultModelValidatorProvider(), + provider, + }; + var metadataProvider = new HasValidatorsValidationMetadataProvider(validationProviders); + + var key = ModelMetadataIdentity.ForType(typeof(object)); + var modelAttributes = new ModelAttributes(new object[0], new object[0], new object[0]); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.False(context.ValidationMetadata.HasValidators); + } + + [Fact] + public void CreateValidationMetadata_DoesNotOverrideExistingHasValidatorsValue() + { + // Arrange + var provider = Mock.Of(p => p.HasValidators(typeof(object), It.IsAny>()) == false); + var validationProviders = new IModelValidatorProvider[] + { + new DefaultModelValidatorProvider(), + provider, + }; + var metadataProvider = new HasValidatorsValidationMetadataProvider(validationProviders); + + var key = ModelMetadataIdentity.ForType(typeof(object)); + var modelAttributes = new ModelAttributes(new object[0], new object[0], new object[0]); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Initialize this value. + context.ValidationMetadata.HasValidators = true; + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.True(context.ValidationMetadata.HasValidators); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultModelValidatorProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Validation/DefaultModelValidatorProviderTest.cs similarity index 88% rename from test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultModelValidatorProviderTest.cs rename to test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Validation/DefaultModelValidatorProviderTest.cs index 680d46ff30..bc8e9d4aa4 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultModelValidatorProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Validation/DefaultModelValidatorProviderTest.cs @@ -6,11 +6,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation { // Integration tests for the default configuration of ModelMetadata and Validation providers public class DefaultModelValidatorProviderTest @@ -145,6 +143,34 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Single(validatorItems, v => ((DataAnnotationsModelValidator)v.Validator).Attribute is StringLengthAttribute); } + [Fact] + public void HasValidators_ReturnsTrue_IfMetadataIsIModelValidator() + { + // Arrange + var validatorProvider = new DefaultModelValidatorProvider(); + var attributes = new object[] { new RequiredAttribute(), new CustomModelValidatorAttribute(), new BindRequiredAttribute(), }; + + // Act + var result = validatorProvider.HasValidators(typeof(object), attributes); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasValidators_ReturnsFalse_IfNoMetadataIsIModelValidator() + { + // Arrange + var validatorProvider = new DefaultModelValidatorProvider(); + var attributes = new object[] { new RequiredAttribute(), new BindRequiredAttribute(), }; + + // Act + var result = validatorProvider.HasValidators(typeof(object), attributes); + + // Assert + Assert.False(result); + } + private static IList GetValidatorItems(ModelMetadata metadata) { return metadata.ValidatorMetadata.Select(v => new ValidatorItem(v)).ToList(); diff --git a/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelMetadataProvider.cs b/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelMetadataProvider.cs index 2548dc26c3..684875d565 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelMetadataProvider.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelMetadataProvider.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using Xunit; @@ -37,6 +38,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding MvcCoreMvcOptionsSetup.ConfigureAdditionalModelMetadataDetailsProviders(detailsProviders); + var validationProviders = TestModelValidatorProvider.CreateDefaultProvider(); + detailsProviders.Add(new HasValidatorsValidationMetadataProvider(validationProviders.ValidatorProviders)); + var compositeDetailsProvider = new DefaultCompositeMetadataDetailsProvider(detailsProviders); return new DefaultModelMetadataProvider(compositeDetailsProvider, Options.Create(new MvcOptions())); } @@ -57,6 +61,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding detailsProviders.AddRange(providers); + var validationProviders = TestModelValidatorProvider.CreateDefaultProvider(); + detailsProviders.Add(new HasValidatorsValidationMetadataProvider(validationProviders.ValidatorProviders)); + var compositeDetailsProvider = new DefaultCompositeMetadataDetailsProvider(detailsProviders); return new DefaultModelMetadataProvider(compositeDetailsProvider, Options.Create(new MvcOptions())); } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelValidatorProvider.cs b/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelValidatorProvider.cs index 230f7970b4..4dab51dc37 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelValidatorProvider.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelValidatorProvider.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.DataAnnotations; -using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; -using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; diff --git a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/DataAnnotationsModelValidatorProviderTest.cs similarity index 79% rename from test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorProviderTest.cs rename to test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/DataAnnotationsModelValidatorProviderTest.cs index e0a156e8b1..4889e7c4da 100644 --- a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/DataAnnotationsModelValidatorProviderTest.cs @@ -1,16 +1,18 @@ // 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; using System.Linq; +using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.Options; using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal +namespace Microsoft.AspNetCore.Mvc.DataAnnotations { public class DataAnnotationsModelValidatorProviderTest { @@ -110,6 +112,56 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal Assert.Single(providerContext.Results); } + [Fact] + public void HasValidators_ReturnsTrue_IfModelIsIValidatableObject() + { + // Arrange + var provider = GetProvider(); + var mockValidatable = Mock.Of(); + + // Act + var result = provider.HasValidators(mockValidatable.GetType(), Array.Empty()); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasValidators_ReturnsTrue_IfMetadataContainsValidationAttribute() + { + // Arrange + var provider = GetProvider(); + var attributes = new object[] { new BindNeverAttribute(), new DummyValidationAttribute() }; + + // Act + var result = provider.HasValidators(typeof(object), attributes); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasValidators_ReturnsFalse_IfNoDataAnnotationsValidationIsAvailable() + { + // Arrange + var provider = GetProvider(); + var attributes = new object[] { new BindNeverAttribute(), }; + + // Act + var result = provider.HasValidators(typeof(object), attributes); + + // Assert + Assert.False(result); + } + + private static DataAnnotationsModelValidatorProvider GetProvider() + { + return new DataAnnotationsModelValidatorProvider( + new ValidationAttributeAdapterProvider(), + Options.Create(new MvcDataAnnotationsLocalizationOptions()), + stringLocalizerFactory: null); + } + private IList GetValidatorItems(ModelMetadata metadata) { var items = new List(metadata.ValidatorMetadata.Count); diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlDataContractSerializerMvcOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlDataContractSerializerMvcOptionsSetupTest.cs deleted file mode 100644 index 5219f0b97a..0000000000 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlDataContractSerializerMvcOptionsSetupTest.cs +++ /dev/null @@ -1,42 +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 Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal -{ - public class MvcXmlDataContractSerializerMvcOptionsSetupTest - { - [Fact] - public void AddsFormatterMapping() - { - // Arrange - var optionsSetup = new MvcXmlDataContractSerializerMvcOptionsSetup(NullLoggerFactory.Instance); - var options = new MvcOptions(); - - // Act - optionsSetup.Configure(options); - - // Assert - var mappedContentType = options.FormatterMappings.GetMediaTypeMappingForFormat("xml"); - Assert.Equal("application/xml", mappedContentType); - } - - [Fact] - public void DoesNotOverrideExistingMapping() - { - // Arrange - var optionsSetup = new MvcXmlDataContractSerializerMvcOptionsSetup(NullLoggerFactory.Instance); - var options = new MvcOptions(); - options.FormatterMappings.SetMediaTypeMappingForFormat("xml", "text/xml"); - - // Act - optionsSetup.Configure(options); - - // Assert - var mappedContentType = options.FormatterMappings.GetMediaTypeMappingForFormat("xml"); - Assert.Equal("text/xml", mappedContentType); - } - } -} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlSerializerMvcOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlSerializerMvcOptionsSetupTest.cs deleted file mode 100644 index 0ff2072a4c..0000000000 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlSerializerMvcOptionsSetupTest.cs +++ /dev/null @@ -1,42 +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 Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal -{ - public class MvcXmlSerializerMvcOptionsSetupTest - { - [Fact] - public void AddsFormatterMapping() - { - // Arrange - var optionsSetup = new MvcXmlSerializerMvcOptionsSetup(NullLoggerFactory.Instance); - var options = new MvcOptions(); - - // Act - optionsSetup.Configure(options); - - // Assert - var mappedContentType = options.FormatterMappings.GetMediaTypeMappingForFormat("xml"); - Assert.Equal("application/xml", mappedContentType); - } - - [Fact] - public void DoesNotOverrideExistingMapping() - { - // Arrange - var optionsSetup = new MvcXmlSerializerMvcOptionsSetup(NullLoggerFactory.Instance); - var options = new MvcOptions(); - options.FormatterMappings.SetMediaTypeMappingForFormat("xml", "text/xml"); - - // Act - optionsSetup.Configure(options); - - // Assert - var mappedContentType = options.FormatterMappings.GetMediaTypeMappingForFormat("xml"); - Assert.Equal("text/xml", mappedContentType); - } - } -} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetails21WrapperTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetails21WrapperTest.cs new file mode 100644 index 0000000000..933da23704 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetails21WrapperTest.cs @@ -0,0 +1,102 @@ +// 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.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; +using System.Xml; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml +{ +#pragma warning disable CS0618 // Type or member is obsolete + public class ProblemDetails21WrapperTest + { + [Fact] + public void ReadXml_ReadsProblemDetailsXml() + { + // Arrange + var xml = "" + + "" + + "Some title" + + "403" + + "Some instance" + + "Test Value 1" + + "<_x005B_key2_x005D_>Test Value 2" + + "Test Value 3" + + ""; + var serializer = new DataContractSerializer(typeof(ProblemDetails21Wrapper)); + + // Act + var value = serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(xml))); + + // Assert + var problemDetails = Assert.IsType(value).ProblemDetails; + Assert.Equal("Some title", problemDetails.Title); + Assert.Equal("Some instance", problemDetails.Instance); + Assert.Equal(403, problemDetails.Status); + + Assert.Collection( + problemDetails.Extensions.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Empty(kvp.Key); + Assert.Equal("Test Value 3", kvp.Value); + }, + kvp => + { + Assert.Equal("[key2]", kvp.Key); + Assert.Equal("Test Value 2", kvp.Value); + }, + kvp => + { + Assert.Equal("key1", kvp.Key); + Assert.Equal("Test Value 1", kvp.Value); + }); + } + + [Fact] + public void WriteXml_WritesValidXml() + { + // Arrange + var problemDetails = new ProblemDetails + { + Title = "Some title", + Detail = "Some detail", + Extensions = + { + ["key1"] = "Test Value 1", + ["[Key2]"] = "Test Value 2", + [""] = "Test Value 3", + }, + }; + + var wrapper = new ProblemDetails21Wrapper(problemDetails); + var outputStream = new MemoryStream(); + var expectedContent = "" + + "" + + "Some detail" + + "Some title" + + "Test Value 1" + + "<_x005B_Key2_x005D_>Test Value 2" + + "Test Value 3" + + ""; + + // Act + using (var xmlWriter = XmlWriter.Create(outputStream)) + { + var dataContractSerializer = new DataContractSerializer(wrapper.GetType()); + dataContractSerializer.WriteObject(xmlWriter, wrapper); + } + outputStream.Position = 0; + var res = new StreamReader(outputStream, Encoding.UTF8).ReadToEnd(); + + // Assert + Assert.Equal(expectedContent, res); + } + } +#pragma warning restore CS0618 // Type or member is obsolete + +} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperProviderFactoryTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperProviderFactoryTest.cs new file mode 100644 index 0000000000..21a62006b5 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperProviderFactoryTest.cs @@ -0,0 +1,119 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml +{ + public class ProblemDetailsWrapperProviderFactoryTest + { + [Fact] + public void GetProvider_ReturnsNull_IfTypeDoesNotMatch() + { + // Arrange + var xmlOptions = new MvcXmlOptions(); + var providerFactory = new ProblemDetailsWrapperProviderFactory(xmlOptions); + var context = new WrapperProviderContext(typeof(SerializableError), isSerialization: true); + + // Act + var provider = providerFactory.GetProvider(context); + + // Assert + Assert.Null(provider); + } + + [Fact] + public void GetProvider_ReturnsWrapper_ForProblemDetails() + { + // Arrange + var xmlOptions = new MvcXmlOptions { AllowRfc7807CompliantProblemDetailsFormat = true }; + var providerFactory = new ProblemDetailsWrapperProviderFactory(xmlOptions); + var instance = new ProblemDetails(); + var context = new WrapperProviderContext(instance.GetType(), isSerialization: true); + + // Act + var provider = providerFactory.GetProvider(context); + + // Assert + var result = provider.Wrap(instance); + var wrapper = Assert.IsType(result); + Assert.Same(instance, wrapper.ProblemDetails); + } + + [Fact] + public void GetProvider_Returns21CompatibleWrapper_ForProblemDetails() + { + // Arrange + var xmlOptions = new MvcXmlOptions(); + var providerFactory = new ProblemDetailsWrapperProviderFactory(xmlOptions); + var instance = new ProblemDetails(); + var context = new WrapperProviderContext(instance.GetType(), isSerialization: true); + + // Act + var provider = providerFactory.GetProvider(context); + + // Assert + var result = provider.Wrap(instance); +#pragma warning disable CS0618 // Type or member is obsolete + var wrapper = Assert.IsType(result); +#pragma warning restore CS0618 // Type or member is obsolete + Assert.Same(instance, wrapper.ProblemDetails); + } + + [Fact] + public void GetProvider_ReturnsWrapper_ForValidationProblemDetails() + { + // Arrange + var xmlOptions = new MvcXmlOptions { AllowRfc7807CompliantProblemDetailsFormat = true }; + var providerFactory = new ProblemDetailsWrapperProviderFactory(xmlOptions); + var instance = new ValidationProblemDetails(); + var context = new WrapperProviderContext(instance.GetType(), isSerialization: true); + + // Act + var provider = providerFactory.GetProvider(context); + + // Assert + var result = provider.Wrap(instance); + var wrapper = Assert.IsType(result); + Assert.Same(instance, wrapper.ProblemDetails); + } + + [Fact] + public void GetProvider_Returns21CompatibleWrapper_ForValidationProblemDetails() + { + // Arrange + var xmlOptions = new MvcXmlOptions(); + var providerFactory = new ProblemDetailsWrapperProviderFactory(xmlOptions); + var instance = new ValidationProblemDetails(); + var context = new WrapperProviderContext(instance.GetType(), isSerialization: true); + + // Act + var provider = providerFactory.GetProvider(context); + + // Assert + var result = provider.Wrap(instance); +#pragma warning disable CS0618 // Type or member is obsolete + var wrapper = Assert.IsType(result); +#pragma warning restore CS0618 // Type or member is obsolete + Assert.Same(instance, wrapper.ProblemDetails); + } + + [Fact] + public void GetProvider_ReturnsNull_ForCustomProblemDetails() + { + // Arrange + var xmlOptions = new MvcXmlOptions(); + var providerFactory = new ProblemDetailsWrapperProviderFactory(xmlOptions); + var instance = new CustomProblemDetails(); + var context = new WrapperProviderContext(instance.GetType(), isSerialization: true); + + // Act + var provider = providerFactory.GetProvider(context); + + // Assert + Assert.Null(provider); + } + + private class CustomProblemDetails : ProblemDetails { } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs index b6760f0f15..055679f266 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs @@ -17,14 +17,14 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml { // Arrange var xml = "" + - "" + - "Some title" + - "403" + - "Some instance" + + "" + + "Some title" + + "403" + + "Some instance" + "Test Value 1" + "<_x005B_key2_x005D_>Test Value 2" + "Test Value 3" + - ""; + ""; var serializer = new DataContractSerializer(typeof(ProblemDetailsWrapper)); // Act @@ -56,7 +56,6 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml }); } - [Fact] public void WriteXml_WritesValidXml() { @@ -76,13 +75,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml var wrapper = new ProblemDetailsWrapper(problemDetails); var outputStream = new MemoryStream(); var expectedContent = "" + - "" + - "Some detail" + - "Some title" + + "" + + "Some detail" + + "Some title" + "Test Value 1" + "<_x005B_Key2_x005D_>Test Value 2" + "Test Value 3" + - ""; + ""; // Act using (var xmlWriter = XmlWriter.Create(outputStream)) diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetails21WrapperTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetails21WrapperTest.cs new file mode 100644 index 0000000000..49b41afaf4 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetails21WrapperTest.cs @@ -0,0 +1,228 @@ +// 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.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; +using System.Xml; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml +{ +#pragma warning disable CS0618 // Type or member is obsolete + public class ValidationProblemDetails21WrapperTest + { + [Fact] + public void ReadXml_ReadsValidationProblemDetailsXml() + { + // Arrange + var xml = "" + + "" + + "Some title" + + "400" + + "Some instance" + + "Test Value 1" + + "<_x005B_key2_x005D_>Test Value 2" + + "" + + "Test error 1 Test error 2" + + "<_x005B_error2_x005D_>Test error 3" + + "Test error 4" + + "" + + ""; + var serializer = new DataContractSerializer(typeof(ValidationProblemDetails21Wrapper)); + + // Act + var value = serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(xml))); + + // Assert + var problemDetails = Assert.IsType(value).ProblemDetails; + Assert.Equal("Some title", problemDetails.Title); + Assert.Equal("Some instance", problemDetails.Instance); + Assert.Equal(400, problemDetails.Status); + + Assert.Collection( + problemDetails.Extensions.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("[key2]", kvp.Key); + Assert.Equal("Test Value 2", kvp.Value); + }, + kvp => + { + Assert.Equal("key1", kvp.Key); + Assert.Equal("Test Value 1", kvp.Value); + }); + + Assert.Collection( + problemDetails.Errors.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Empty(kvp.Key); + Assert.Equal(new[] { "Test error 4" }, kvp.Value); + }, + kvp => + { + Assert.Equal("[error2]", kvp.Key); + Assert.Equal(new[] { "Test error 3" }, kvp.Value); + }, + kvp => + { + Assert.Equal("error1", kvp.Key); + Assert.Equal(new[] { "Test error 1 Test error 2" }, kvp.Value); + }); + } + + [Fact] + public void ReadXml_ReadsValidationProblemDetailsXml_WithNoErrors() + { + // Arrange + var xml = "" + + "" + + "Some title" + + "400" + + "Some instance" + + "Test Value 1" + + "<_x005B_key2_x005D_>Test Value 2" + + ""; + var serializer = new DataContractSerializer(typeof(ValidationProblemDetails21Wrapper)); + + // Act + var value = serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(xml))); + + // Assert + var problemDetails = Assert.IsType(value).ProblemDetails; + Assert.Equal("Some title", problemDetails.Title); + Assert.Equal("Some instance", problemDetails.Instance); + Assert.Equal(400, problemDetails.Status); + + Assert.Collection( + problemDetails.Extensions, + kvp => + { + Assert.Equal("key1", kvp.Key); + Assert.Equal("Test Value 1", kvp.Value); + }, + kvp => + { + Assert.Equal("[key2]", kvp.Key); + Assert.Equal("Test Value 2", kvp.Value); + }); + + Assert.Empty(problemDetails.Errors); + } + + [Fact] + public void ReadXml_ReadsValidationProblemDetailsXml_WithEmptyErrorsElement() + { + // Arrange + var xml = "" + + "" + + "Some title" + + "400" + + "" + + ""; + var serializer = new DataContractSerializer(typeof(ValidationProblemDetails21Wrapper)); + + // Act + var value = serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(xml))); + + // Assert + var problemDetails = Assert.IsType(value).ProblemDetails; + Assert.Equal("Some title", problemDetails.Title); + Assert.Equal(400, problemDetails.Status); + Assert.Empty(problemDetails.Errors); + } + + [Fact] + public void WriteXml_WritesValidXml() + { + // Arrange + var problemDetails = new ValidationProblemDetails + { + Title = "Some title", + Detail = "Some detail", + Extensions = + { + ["key1"] = "Test Value 1", + ["[Key2]"] = "Test Value 2" + }, + Errors = + { + { "error1", new[] {"Test error 1", "Test error 2" } }, + { "[error2]", new[] {"Test error 3" } }, + { "", new[] { "Test error 4" } }, + } + }; + + var wrapper = new ValidationProblemDetails21Wrapper(problemDetails); + var outputStream = new MemoryStream(); + var expectedContent = "" + + "" + + "Some detail" + + "Some title" + + "Test Value 1" + + "<_x005B_Key2_x005D_>Test Value 2" + + "" + + "Test error 1 Test error 2" + + "<_x005B_error2_x005D_>Test error 3" + + "Test error 4" + + "" + + ""; + + // Act + using (var xmlWriter = XmlWriter.Create(outputStream)) + { + var dataContractSerializer = new DataContractSerializer(wrapper.GetType()); + dataContractSerializer.WriteObject(xmlWriter, wrapper); + } + outputStream.Position = 0; + var res = new StreamReader(outputStream, Encoding.UTF8).ReadToEnd(); + + // Assert + Assert.Equal(expectedContent, res); + } + + [Fact] + public void WriteXml_WithNoValidationErrors() + { + // Arrange + var problemDetails = new ValidationProblemDetails + { + Title = "Some title", + Detail = "Some detail", + Extensions = + { + ["key1"] = "Test Value 1", + ["[Key2]"] = "Test Value 2" + }, + }; + + var wrapper = new ValidationProblemDetails21Wrapper(problemDetails); + var outputStream = new MemoryStream(); + var expectedContent = "" + + "" + + "Some detail" + + "Some title" + + "Test Value 1" + + "<_x005B_Key2_x005D_>Test Value 2" + + ""; + + // Act + using (var xmlWriter = XmlWriter.Create(outputStream)) + { + var dataContractSerializer = new DataContractSerializer(wrapper.GetType()); + dataContractSerializer.WriteObject(xmlWriter, wrapper); + } + outputStream.Position = 0; + var res = new StreamReader(outputStream, Encoding.UTF8).ReadToEnd(); + + // Assert + Assert.Equal(expectedContent, res); + } + } +#pragma warning restore CS0618 // Type or member is obsolete +} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs index 50630a0082..53bae5afaf 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs @@ -17,10 +17,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml { // Arrange var xml = "" + - "" + - "Some title" + - "400" + - "Some instance" + + "" + + "Some title" + + "400" + + "Some instance" + "Test Value 1" + "<_x005B_key2_x005D_>Test Value 2" + "" + @@ -28,7 +28,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml "<_x005B_error2_x005D_>Test error 3" + "Test error 4" + "" + - ""; + ""; var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper)); // Act @@ -78,13 +78,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml { // Arrange var xml = "" + - "" + - "Some title" + - "400" + - "Some instance" + + "" + + "Some title" + + "400" + + "Some instance" + "Test Value 1" + "<_x005B_key2_x005D_>Test Value 2" + - ""; + ""; var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper)); // Act @@ -118,11 +118,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml { // Arrange var xml = "" + - "" + - "Some title" + - "400" + + "" + + "Some title" + + "400" + "" + - ""; + ""; var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper)); // Act @@ -160,9 +160,9 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml var wrapper = new ValidationProblemDetailsWrapper(problemDetails); var outputStream = new MemoryStream(); var expectedContent = "" + - "" + - "Some detail" + - "Some title" + + "" + + "Some detail" + + "Some title" + "Test Value 1" + "<_x005B_Key2_x005D_>Test Value 2" + "" + @@ -170,7 +170,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml "<_x005B_error2_x005D_>Test error 3" + "Test error 4" + "" + - ""; + ""; // Act using (var xmlWriter = XmlWriter.Create(outputStream)) @@ -203,12 +203,12 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml var wrapper = new ValidationProblemDetailsWrapper(problemDetails); var outputStream = new MemoryStream(); var expectedContent = "" + - "" + - "Some detail" + - "Some title" + + "" + + "Some detail" + + "Some title" + "Test Value 1" + "<_x005B_Key2_x005D_>Test Value 2" + - ""; + ""; // Act using (var xmlWriter = XmlWriter.Create(outputStream)) diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryExtensionsTest.cs deleted file mode 100644 index ca981f679a..0000000000 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryExtensionsTest.cs +++ /dev/null @@ -1,34 +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 Xunit; - -namespace Microsoft.AspNetCore.Mvc.Formatters.Xml -{ - public class WrapperProviderFactoryExtensionsTest - { - [Fact] - public void GetDefaultProviderFactories_GetsFactoriesUsedByInputAndOutputFormatters() - { - // Act - var factoryProviders = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); - - // Assert - Assert.Collection( - factoryProviders, - factory => Assert.IsType(factory), - factory => - { - var wrapperProviderFactory = Assert.IsType(factory); - Assert.Equal(typeof(ProblemDetails), wrapperProviderFactory.DeclaredType); - Assert.Equal(typeof(ProblemDetailsWrapper), wrapperProviderFactory.WrappingType); - }, - factory => - { - var wrapperProviderFactory = Assert.IsType(factory); - Assert.Equal(typeof(ValidationProblemDetails), wrapperProviderFactory.DeclaredType); - Assert.Equal(typeof(ValidationProblemDetailsWrapper), wrapperProviderFactory.WrappingType); - }); - } - } -} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryTest.cs deleted file mode 100644 index c067ed4ab9..0000000000 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryTest.cs +++ /dev/null @@ -1,63 +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 Xunit; - -namespace Microsoft.AspNetCore.Mvc.Formatters.Xml -{ - public class WrapperProviderFactoryTest - { - [Fact] - public void GetProvider_ReturnsNull_IfTypeDoesNotMatch() - { - // Arrange - var provider = new WrapperProviderFactory( - typeof(ProblemDetails), - typeof(ProblemDetailsWrapper), - _ => null); - var context = new WrapperProviderContext(typeof(SerializableError), isSerialization: true); - - // Act - var result = provider.GetProvider(context); - - // Assert - Assert.Null(result); - } - - [Fact] - public void GetProvider_ReturnsNull_IfTypeIsSubtype() - { - // Arrange - var provider = new WrapperProviderFactory( - typeof(ProblemDetails), - typeof(ProblemDetailsWrapper), - _ => null); - var context = new WrapperProviderContext(typeof(ValidationProblemDetails), isSerialization: true); - - // Act - var result = provider.GetProvider(context); - - // Assert - Assert.Null(result); - } - - [Fact] - public void GetProvider_ReturnsValue_IfTypeMatches() - { - // Arrange - var expected = new object(); - var providerFactory = new WrapperProviderFactory( - typeof(ProblemDetails), - typeof(ProblemDetailsWrapper), - _ => expected); - var context = new WrapperProviderContext(typeof(ProblemDetails), isSerialization: true); - - // Act - var provider = providerFactory.GetProvider(context); - var result = provider.Wrap(new ProblemDetails()); - - // Assert - Assert.Same(expected, result); - } - } -} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerMvcOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerMvcOptionsSetupTest.cs new file mode 100644 index 0000000000..08c2b0aaf2 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerMvcOptionsSetupTest.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 Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml +{ + public class XmlDataContractSerializerMvcOptionsSetupTest + { + [Fact] + public void AddsFormatterMapping() + { + // Arrange + var optionsSetup = new XmlDataContractSerializerMvcOptionsSetup(Options.Create(new MvcXmlOptions()), NullLoggerFactory.Instance); + var options = new MvcOptions(); + + // Act + optionsSetup.Configure(options); + + // Assert + var mappedContentType = options.FormatterMappings.GetMediaTypeMappingForFormat("xml"); + Assert.Equal("application/xml", mappedContentType); + } + + [Fact] + public void DoesNotOverrideExistingMapping() + { + // Arrange + var optionsSetup = new XmlDataContractSerializerMvcOptionsSetup(Options.Create(new MvcXmlOptions()), NullLoggerFactory.Instance); + var options = new MvcOptions(); + options.FormatterMappings.SetMediaTypeMappingForFormat("xml", "text/xml"); + + // Act + optionsSetup.Configure(options); + + // Assert + var mappedContentType = options.FormatterMappings.GetMediaTypeMappingForFormat("xml"); + Assert.Equal("text/xml", mappedContentType); + } + + [Fact] + public void AddsInputFormatter() + { + // Arrange + var optionsSetup = new XmlDataContractSerializerMvcOptionsSetup(Options.Create(new MvcXmlOptions()), NullLoggerFactory.Instance); + var options = new MvcOptions(); + + // Act + optionsSetup.Configure(options); + + // Assert + Assert.IsType(Assert.Single(options.InputFormatters)); + } + + [Fact] + public void AddsOutputFormatter() + { + // Arrange + var optionsSetup = new XmlDataContractSerializerMvcOptionsSetup(Options.Create(new MvcXmlOptions()), NullLoggerFactory.Instance); + var options = new MvcOptions(); + + // Act + optionsSetup.Configure(options); + + // Assert + Assert.IsType(Assert.Single(options.OutputFormatters)); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerMvcOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerMvcOptionsSetupTest.cs new file mode 100644 index 0000000000..d3b8790e64 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerMvcOptionsSetupTest.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 Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Formatters.Xml +{ + public class XmlSerializerMvcOptionsSetupTest + { + [Fact] + public void AddsFormatterMapping() + { + // Arrange + var optionsSetup = new XmlSerializerMvcOptionsSetup(Options.Create(new MvcXmlOptions()), NullLoggerFactory.Instance); + var options = new MvcOptions(); + + // Act + optionsSetup.Configure(options); + + // Assert + var mappedContentType = options.FormatterMappings.GetMediaTypeMappingForFormat("xml"); + Assert.Equal("application/xml", mappedContentType); + } + + [Fact] + public void DoesNotOverrideExistingMapping() + { + // Arrange + var optionsSetup = new XmlSerializerMvcOptionsSetup(Options.Create(new MvcXmlOptions()), NullLoggerFactory.Instance); + var options = new MvcOptions(); + options.FormatterMappings.SetMediaTypeMappingForFormat("xml", "text/xml"); + + // Act + optionsSetup.Configure(options); + + // Assert + var mappedContentType = options.FormatterMappings.GetMediaTypeMappingForFormat("xml"); + Assert.Equal("text/xml", mappedContentType); + } + + [Fact] + public void AddsInputFormatter() + { + // Arrange + var optionsSetup = new XmlSerializerMvcOptionsSetup(Options.Create(new MvcXmlOptions()), NullLoggerFactory.Instance); + var options = new MvcOptions(); + + // Act + optionsSetup.Configure(options); + + // Assert + Assert.IsType(Assert.Single(options.InputFormatters)); + } + + [Fact] + public void AddsOutputFormatter() + { + // Arrange + var optionsSetup = new XmlSerializerMvcOptionsSetup(Options.Create(new MvcXmlOptions()), NullLoggerFactory.Instance); + var options = new MvcOptions(); + + // Act + optionsSetup.Configure(options); + + // Assert + Assert.IsType(Assert.Single(options.OutputFormatters)); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTestsBase.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTestsBase.cs index 01e9e900eb..3d5be3b90a 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTestsBase.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTestsBase.cs @@ -102,7 +102,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests [InlineData("HEAD")] [InlineData("POST")] [InlineData("PUT")] - public async Task PolicyFailed_Disallows_PreFlightRequest(string method) + public async Task OriginMatched_ReturnsHeaders(string method) { // Arrange var request = new HttpRequestMessage( @@ -120,7 +120,18 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert // MVC applied the policy and since that did not pass, there were no access control headers. Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Empty(response.Headers); + Assert.Collection( + response.Headers.OrderBy(h => h.Key), + h => + { + Assert.Equal(CorsConstants.AccessControlAllowMethods, h.Key); + Assert.Equal(new[] { "GET,POST,HEAD" }, h.Value); + }, + h => + { + Assert.Equal(CorsConstants.AccessControlAllowOrigin, h.Key); + Assert.Equal(new[] { "*" }, h.Value); + }); // It should short circuit and hence no result. var content = await response.Content.ReadAsStringAsync(); @@ -146,7 +157,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(HttpStatusCode.OK, response.StatusCode); var responseHeaders = response.Headers; Assert.Equal( - new[] { "http://example.com" }, + new[] { "*" }, responseHeaders.GetValues(CorsConstants.AccessControlAllowOrigin).ToArray()); Assert.Equal( new[] { "true" }, @@ -179,16 +190,16 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(HttpStatusCode.OK, response.StatusCode); var responseHeaders = response.Headers; Assert.Equal( - new[] { "http://example.com" }, + new[] { "*" }, responseHeaders.GetValues(CorsConstants.AccessControlAllowOrigin).ToArray()); Assert.Equal( new[] { "true" }, responseHeaders.GetValues(CorsConstants.AccessControlAllowCredentials).ToArray()); Assert.Equal( - new[] { "header1,header2" }, + new[] { "*" }, responseHeaders.GetValues(CorsConstants.AccessControlAllowHeaders).ToArray()); Assert.Equal( - new[] { "PUT" }, + new[] { "PUT,POST" }, responseHeaders.GetValues(CorsConstants.AccessControlAllowMethods).ToArray()); var content = await response.Content.ReadAsStringAsync(); @@ -270,12 +281,43 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Empty(content); } - [Theory] - [InlineData("http://localhost/api/store/actionusingcontrollercorssettings")] - [InlineData("http://localhost/api/store/actionwithcorssettings")] - public async Task CorsFilter_RunsBeforeOtherAuthorizationFilters(string url) + [Fact] + public async Task CorsFilter_RunsBeforeOtherAuthorizationFilters_UsesPolicySpecifiedOnController() { // Arrange + var url = "http://localhost/api/store/actionusingcontrollercorssettings"; + var request = new HttpRequestMessage(new HttpMethod(CorsConstants.PreflightHttpMethod), url); + + // Adding a custom header makes it a non-simple request. + request.Headers.Add(CorsConstants.Origin, "http://example.com"); + request.Headers.Add(CorsConstants.AccessControlRequestMethod, "GET"); + request.Headers.Add(CorsConstants.AccessControlRequestHeaders, "Custom"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseHeaders = response.Headers; + Assert.Equal( + new[] { "*" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowOrigin).ToArray()); + Assert.Equal( + new[] { "true" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowCredentials).ToArray()); + Assert.Equal( + new[] { "*" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowHeaders).ToArray()); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Empty(content); + } + + [Fact] + public async Task CorsFilter_RunsBeforeOtherAuthorizationFilters_UsesPolicySpecifiedOnAction() + { + // Arrange + var url = "http://localhost/api/store/actionwithcorssettings"; var request = new HttpRequestMessage(new HttpMethod(CorsConstants.PreflightHttpMethod), url); // Adding a custom header makes it a non-simple request. @@ -296,7 +338,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests new[] { "true" }, responseHeaders.GetValues(CorsConstants.AccessControlAllowCredentials).ToArray()); Assert.Equal( - new[] { "Custom" }, + new[] { "*" }, responseHeaders.GetValues(CorsConstants.AccessControlAllowHeaders).ToArray()); var content = await response.Content.ReadAsStringAsync(); diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputObjectValidationTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputObjectValidationTests.cs index 138e70f6c8..a56ee08b8e 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputObjectValidationTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputObjectValidationTests.cs @@ -262,5 +262,95 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var ex = await Assert.ThrowsAsync(() => Client.SendAsync(requestMessage)); Assert.Equal(expected, ex.Message); } + + [Fact] + public async Task ErrorsDeserializingMalformedJson_AreReportedForModelsWithoutAnyValidationAttributes() + { + // This test verifies that for a model with ModelMetadata.HasValidators = false, we continue to get an invalid ModelState + validation + // errors from json serialization errors + // Arrange + var input = "{Id = \"This string is incomplete"; + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "TestApi/PostBookWithNoValidation") + { + Content = new StringContent(input, Encoding.UTF8, "application/json"), + }; + + // Act + var response = await Client.SendAsync(requestMessage); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + var validationProblemDetails = JsonConvert.DeserializeObject(responseContent); + + Assert.Collection( + validationProblemDetails.Errors, + error => + { + Assert.Empty(error.Key); + Assert.Equal(new[] { "Invalid character after parsing property name. Expected ':' but got: =. Path '', line 1, position 4." }, error.Value); + }); + } + + [Fact] + public async Task JsonValidationErrors_AreReportedForModelsWithoutAnyValidationAttributes() + { + // This test verifies that for a model with ModelMetadata.HasValidators = false, we continue to get an invalid ModelState + validation + // errors from json serialization errors + // Arrange + var input = "{Id: \"0c92bb85-cfaf-4344-8a9d-f92e88716861\"}"; + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "TestApi/PostBookWithNoValidation") + { + Content = new StringContent(input, Encoding.UTF8, "application/json"), + }; + + // Act + var response = await Client.SendAsync(requestMessage); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + var validationProblemDetails = JsonConvert.DeserializeObject(responseContent); + + Assert.Collection( + validationProblemDetails.Errors, + error => + { + Assert.Empty(error.Key); + Assert.Equal(new[] { "Required property 'isbn' not found in JSON. Path '', line 1, position 44." }, error.Value); + }); + } + + [Fact] + public async Task ErrorsDeserializingMalformedXml_AreReportedForModelsWithoutAnyValidationAttributes() + { + // This test verifies that for a model with ModelMetadata.HasValidators = false, we continue to get an invalid ModelState + validation + // errors from json serialization errors + // Arrange + var input = "" + + "" + + "Incomplete element" + + ""; + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "TestApi/PostBookWithNoValidation") + { + Content = new StringContent(input, Encoding.UTF8, "application/xml"), + }; + + // Act + var response = await Client.SendAsync(requestMessage); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + var validationProblemDetails = JsonConvert.DeserializeObject(responseContent); + + Assert.Collection( + validationProblemDetails.Errors, + error => + { + Assert.Empty(error.Key); + Assert.Equal(new[] { "An error occurred while deserializing input data." }, error.Value); + }); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs index 2e34fbbe27..5c781c6652 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs @@ -2,12 +2,16 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Formatters.Xml; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Testing.xunit; +using XmlFormattersWebSite; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -16,10 +20,12 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests { public XmlDataContractSerializerFormattersWrappingTest(MvcTestFixture fixture) { - Client = fixture.CreateDefaultClient(); + Factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(builder => builder.UseStartup()); + Client = Factory.CreateDefaultClient(); } public HttpClient Client { get; } + public WebApplicationFactory Factory { get; } [ConditionalTheory] // Mono issue - https://github.com/aspnet/External/issues/18 @@ -216,12 +222,12 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Arrange using (new ActivityReplacer()) { - var expected = "" + - "404" + - "Not Found" + - "https://tools.ietf.org/html/rfc7231#section-6.5.4" + + var expected = "" + + "404" + + "Not Found" + + "https://tools.ietf.org/html/rfc7231#section-6.5.4" + $"{Activity.Current.Id}" + - ""; + ""; // Act var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningClientErrorStatusCodeResult"); @@ -237,8 +243,13 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task ProblemDetails_WithExtensionMembers_IsSerialized() { // Arrange - var expected = @"instance404title -correlationAccount1 Account2"; + var expected = "" + + "instance" + + "404" + + "title" + + "correlation" + + "Account1 Account2" + + ""; // Act var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningProblemDetails"); @@ -249,20 +260,45 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests XmlAssert.Equal(expected, content); } + [Fact] + public async Task ProblemDetails_With21Behavior() + { + // Arrange + var expected = "" + + "instance" + + "404" + + "title" + + "correlation" + + "Account1 Account2" + + ""; + + var client = Factory + .WithWebHostBuilder(builder => builder.UseStartup()) + .CreateDefaultClient(); + + // Act + var response = await client.GetAsync("/api/XmlDataContractApi/ActionReturningProblemDetails"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NotFound); + var content = await response.Content.ReadAsStringAsync(); + XmlAssert.Equal(expected, content); + } + [Fact] public async Task ValidationProblemDetails_IsSerialized() { // Arrange using (new ActivityReplacer()) { - var expected = "" + - "400" + - "One or more validation errors occurred." + + var expected = "" + + "400" + + "One or more validation errors occurred." + $"{Activity.Current.Id}" + "" + "The State field is required." + "" + - ""; + ""; // Act var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningValidationProblem"); @@ -278,8 +314,16 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task ValidationProblemDetails_WithExtensionMembers_IsSerialized() { // Arrange - var expected = @"some detail400One or more validation errors occurred. -some typecorrelationErrorValue"; + var expected = "" + + "some detail" + + "400" + + "One or more validation errors occurred." + + "some type" + + "correlation" + + "" + + "ErrorValue" + + "" + + ""; // Act var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningValidationDetailsWithMetadata"); @@ -289,5 +333,33 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var content = await response.Content.ReadAsStringAsync(); XmlAssert.Equal(expected, content); } + + [Fact] + public async Task ValidationProblemDetails_With21Behavior() + { + // Arrange + var expected = "" + + "some detail" + + "400" + + "One or more validation errors occurred." + + "some type" + + "correlation" + + "" + + "ErrorValue" + + "" + + ""; + + var client = Factory + .WithWebHostBuilder(builder => builder.UseStartup()) + .CreateDefaultClient(); + + // Act + var response = await client.GetAsync("/api/XmlDataContractApi/ActionReturningValidationDetailsWithMetadata"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var content = await response.Content.ReadAsStringAsync(); + XmlAssert.Equal(expected, content); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlSerializerFormattersWrappingTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlSerializerFormattersWrappingTest.cs index b8b41c5f0a..542941cfb8 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlSerializerFormattersWrappingTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlSerializerFormattersWrappingTest.cs @@ -2,11 +2,15 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Formatters.Xml; +using Microsoft.AspNetCore.Mvc.Testing; +using XmlFormattersWebSite; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -15,9 +19,11 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests { public XmlSerializerFormattersWrappingTest(MvcTestFixture fixture) { - Client = fixture.CreateDefaultClient(); + Factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(builder => builder.UseStartup()); + Client = Factory.CreateDefaultClient(); } + public WebApplicationFactory Factory { get; } public HttpClient Client { get; } [Theory] @@ -191,12 +197,12 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Arrange using (new ActivityReplacer()) { - var expected = "" + - "404" + - "Not Found" + - "https://tools.ietf.org/html/rfc7231#section-6.5.4" + + var expected = "" + + "404" + + "Not Found" + + "https://tools.ietf.org/html/rfc7231#section-6.5.4" + $"{Activity.Current.Id}" + - ""; + ""; // Act var response = await Client.GetAsync("/api/XmlSerializerApi/ActionReturningClientErrorStatusCodeResult"); @@ -208,12 +214,42 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests } } + [Fact] + public async Task ProblemDetails_With21Behavior() + { + // Arrange + var expected = "" + + "instance" + + "404" + + "title" + + "correlation" + + "Account1 Account2" + + ""; + + var client = Factory + .WithWebHostBuilder(builder => builder.UseStartup()) + .CreateDefaultClient(); + + // Act + var response = await client.GetAsync("/api/XmlSerializerApi/ActionReturningProblemDetails"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NotFound); + var content = await response.Content.ReadAsStringAsync(); + XmlAssert.Equal(expected, content); + } + [Fact] public async Task ProblemDetails_WithExtensionMembers_IsSerialized() { // Arrange - var expected = @"instance404title -correlationAccount1 Account2"; + var expected = "" + + "instance" + + "404" + + "title" + + "correlation" + + "Account1 Account2" + + ""; // Act var response = await Client.GetAsync("/api/XmlSerializerApi/ActionReturningProblemDetails"); @@ -230,14 +266,14 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Arrange using (new ActivityReplacer()) { - var expected = "" + - "400" + - "One or more validation errors occurred." + + var expected = "" + + "400" + + "One or more validation errors occurred." + $"{Activity.Current.Id}" + "" + "The State field is required." + "" + - ""; + ""; // Act var response = await Client.GetAsync("/api/XmlSerializerApi/ActionReturningValidationProblem"); @@ -253,8 +289,16 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task ValidationProblemDetails_WithExtensionMembers_IsSerialized() { // Arrange - var expected = @"some detail400One or more validation errors occurred. -some typecorrelationErrorValue"; + var expected = "" + + "some detail" + + "400" + + "One or more validation errors occurred." + + "some type" + + "correlation" + + "" + + "ErrorValue" + + "" + + ""; // Act var response = await Client.GetAsync("/api/XmlSerializerApi/ActionReturningValidationDetailsWithMetadata"); @@ -264,5 +308,33 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var content = await response.Content.ReadAsStringAsync(); XmlAssert.Equal(expected, content); } + + [Fact] + public async Task ValidationProblemDetails_With21Behavior() + { + // Arrange + var expected = "" + + "some detail" + + "400" + + "One or more validation errors occurred." + + "some type" + + "correlation" + + "" + + "ErrorValue" + + "" + + ""; + + var client = Factory + .WithWebHostBuilder(builder => builder.UseStartup()) + .CreateDefaultClient(); + + // Act + var response = await client.GetAsync("/api/XmlSerializerApi/ActionReturningValidationDetailsWithMetadata"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var content = await response.Content.ReadAsStringAsync(); + XmlAssert.Equal(expected, content); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ActionParametersIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ActionParametersIntegrationTest.cs index fcd5923101..6f84a74c88 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ActionParametersIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ActionParametersIntegrationTest.cs @@ -111,13 +111,12 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests // Read-only collection should not be updated. Assert.Empty(boundModel.Address); - // ModelState (data is can't be validated). - Assert.False(modelState.IsValid); + Assert.True(modelState.IsValid); var entry = Assert.Single(modelState); Assert.Equal("Address[0].Street", entry.Key); var state = entry.Value; Assert.NotNull(state); - Assert.Equal(ModelValidationState.Unvalidated, state.ValidationState); + Assert.Equal(ModelValidationState.Valid, state.ValidationState); Assert.Equal("SomeStreet", state.RawValue); Assert.Equal("SomeStreet", state.AttemptedValue); } @@ -292,12 +291,12 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Empty(boundModel.Address); // ModelState (data cannot be validated). - Assert.False(modelState.IsValid); + Assert.True(modelState.IsValid); var entry = Assert.Single(modelState); Assert.Equal("prefix.Address[0].Street", entry.Key); var state = entry.Value; Assert.NotNull(state); - Assert.Equal(ModelValidationState.Unvalidated, state.ValidationState); + Assert.Equal(ModelValidationState.Valid, state.ValidationState); Assert.Equal("SomeStreet", state.AttemptedValue); Assert.Equal("SomeStreet", state.RawValue); } diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/HasValidatorsValidationMetadataProviderIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/HasValidatorsValidationMetadataProviderIntegrationTest.cs new file mode 100644 index 0000000000..102ddff705 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/HasValidatorsValidationMetadataProviderIntegrationTest.cs @@ -0,0 +1,53 @@ +// 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.Linq; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.IntegrationTests +{ + public class HasValidatorsValidationMetadataProviderIntegrationTest + { + [Fact] + public void HasValidatorsValidationMetadataProvider_IsRegisteredAfterOtherMetadataProviders() + { + // HasValidatorsValidationMetadataProvider uses values populated by other details providers to query validator providers + // This test ensures all other detail providers have had an opportunity to modify validation metadata first. + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddSingleton(); + serviceCollection.AddMvc(); + var services = serviceCollection.BuildServiceProvider(); + + // Act + var options = services.GetRequiredService>(); + + Assert.IsType(options.Value.ModelMetadataDetailsProviders.Last()); + } + + [Fact] + public void HasValidatorsValidationMetadataProvider_IsRegisteredAfterUserSpecifiedMetadataProvider() + { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddSingleton(); + serviceCollection.AddMvc(mvcOptions => + { + mvcOptions.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(typeof(IQueryable))); + }); + var services = serviceCollection.BuildServiceProvider(); + + // Act + var options = services.GetRequiredService>(); + + Assert.IsType(options.Value.ModelMetadataDetailsProviders.Last()); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TestMvcOptions.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TestMvcOptions.cs index ef11bd7f1c..615e8685cb 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TestMvcOptions.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TestMvcOptions.cs @@ -5,12 +5,8 @@ using System; using System.Buffers; using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; -using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Formatters.Json.Internal; using Microsoft.AspNetCore.Mvc.Internal; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.ObjectPool; diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs index 200d95914d..0bf1131a36 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs @@ -373,15 +373,15 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var result = await TryUpdateModelAsync(model, string.Empty, testContext); // Assert - Assert.False(result); + Assert.True(result); // ModelState - Assert.False(modelState.IsValid); + Assert.True(modelState.IsValid); var entry = Assert.Single(modelState); Assert.Equal("Address[0].Street", entry.Key); var state = entry.Value; Assert.NotNull(state); - Assert.Equal(ModelValidationState.Unvalidated, state.ValidationState); + Assert.Equal(ModelValidationState.Valid, state.ValidationState); Assert.Equal("SomeStreet", state.RawValue); Assert.Equal("SomeStreet", state.AttemptedValue); } @@ -402,15 +402,15 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var result = await TryUpdateModelAsync(model, "prefix", testContext); // Assert - Assert.False(result); + Assert.True(result); // ModelState - Assert.False(modelState.IsValid); + Assert.True(modelState.IsValid); var entry = Assert.Single(modelState); Assert.Equal("prefix.Address[0].Street", entry.Key); var state = entry.Value; Assert.NotNull(state); - Assert.Equal(ModelValidationState.Unvalidated, state.ValidationState); + Assert.Equal(ModelValidationState.Valid, state.ValidationState); Assert.Equal("SomeStreet", state.RawValue); Assert.Equal("SomeStreet", state.AttemptedValue); } diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryValidateModelIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryValidateModelIntegrationTest.cs index d9bb172f0e..cae150842f 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryValidateModelIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryValidateModelIntegrationTest.cs @@ -158,7 +158,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Equal(2, modelStateErrors.Count); AssertErrorEquals("Property", modelStateErrors["Message"]); AssertErrorEquals("Model", modelStateErrors[""]); - } [Fact] @@ -183,7 +182,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var modelStateErrors = GetModelStateErrors(modelState); Assert.Single(modelStateErrors); // single error from the required attribute AssertErrorEquals("Property", modelStateErrors.Single().Value); - } [ModelLevelError] diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ValidationIntegrationTests.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ValidationIntegrationTests.cs index 9482cf918e..25deb2477d 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ValidationIntegrationTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ValidationIntegrationTests.cs @@ -2,9 +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; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using System.IO; +using System.Linq; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -14,6 +18,7 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; @@ -1488,6 +1493,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public string Control { get; set; } [ValidateSometimes(nameof(Control))] + [Range(0, 10)] public int ControlLength => Control.Length; } @@ -1571,6 +1577,53 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); } + // This type has a IPropertyValidationFilter declared on a property, but no validators. + // We should expect validation to short-circuit + private class ValidateSomePropertiesSometimesWithoutValidation + { + public string Control { get; set; } + + [ValidateSometimes(nameof(Control))] + public int ControlLength => Control.Length; + } + + [Fact] + public async Task PropertyToSometimesSkip_IsNotValidated_IfNoValidationAttributesExistButPropertyValidationFilterExists() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(ValidateSomePropertiesSometimesWithoutValidation), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var modelState = testContext.ModelState; + + // Add an entry for the ControlLength property so that we can observe Skipped versus Valid states. + modelState.SetModelValue( + nameof(ValidateSomePropertiesSometimes.ControlLength), + rawValue: null, + attemptedValue: null); + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.Null(model.Control); + + // Note this Exception is not thrown earlier. + Assert.Throws(() => model.ControlLength); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal(nameof(ValidateSomePropertiesSometimesWithoutValidation.ControlLength), kvp.Key); + Assert.Equal(ModelValidationState.Valid, kvp.Value.ValidationState); + } + private class Order11 { public IEnumerable
ShippingAddresses { get; set; } @@ -1838,6 +1891,550 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public string Message { get; set; } } + [Fact] + public async Task Validation_NoAttributeInGraphOfObjects_WithDefaultValidatorProviders() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order12), + BindingInfo = new BindingInfo + { + BindingSource = BindingSource.Body + }, + }; + + var input = new Order12 + { + Id = 10, + OrderFile = new byte[40], + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.Body = new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(input))); + request.ContentType = "application/json"; + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(input.Id, model.Id); + Assert.Equal(input.OrderFile, model.OrderFile); + Assert.Null(model.RelatedOrders); + + Assert.Empty(modelState); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + } + + private class Order12 + { + public int Id { get; set; } + + public byte[] OrderFile { get; set; } + + public IList RelatedOrders { get; set; } + } + + [Fact] + public async Task Validation_ListOfType_NoValidatorOnParameter() + { + // Arrange + var parameterInfo = GetType().GetMethod(nameof(Validation_ListOfType_NoValidatorOnParameterTestMethod), BindingFlags.NonPublic | BindingFlags.Static) + .GetParameters() + .First(); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForParameter(parameterInfo); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?[0]=1&[1]=2"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(new[] { 1, 2 }, model); + + Assert.False(modelMetadata.HasValidators); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "[0]").Value; + Assert.Equal("1", entry.AttemptedValue); + Assert.Equal("1", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "[1]").Value; + Assert.Equal("2", entry.AttemptedValue); + Assert.Equal("2", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private static void Validation_ListOfType_NoValidatorOnParameterTestMethod(List parameter) { } + + [Fact] + public async Task Validation_ListOfType_ValidatorOnParameter() + { + // Arrange + var parameterInfo = GetType().GetMethod(nameof(Validation_ListOfType_ValidatorOnParameterTestMethod), BindingFlags.NonPublic | BindingFlags.Static) + .GetParameters() + .First(); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForParameter(parameterInfo); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?[0]=1&[1]=2"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(new[] { 1, 2 }, model); + + Assert.True(modelMetadata.HasValidators); + + Assert.False(modelState.IsValid); + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "").Value; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "[0]").Value; + Assert.Equal("1", entry.AttemptedValue); + Assert.Equal("1", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "[1]").Value; + Assert.Equal("2", entry.AttemptedValue); + Assert.Equal("2", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private static void Validation_ListOfType_ValidatorOnParameterTestMethod([ConsistentMinLength(3)] List parameter) { } + + private class ConsistentMinLength : ValidationAttribute + { + private readonly int _length; + + public ConsistentMinLength(int length) + { + _length = length; + } + + public override bool IsValid(object value) + { + return value is ICollection collection && collection.Count >= _length; + } + } + + [Fact] + public async Task Validation_CollectionOfType_ValidatorOnElement() + { + // Arrange + var parameterInfo = GetType().GetMethod(nameof(Validation_CollectionOfType_ValidatorOnElementTestMethod), BindingFlags.NonPublic | BindingFlags.Static) + .GetParameters() + .First(); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForParameter(parameterInfo); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?p[0].Id=1&p[1].Id=2"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(1, model[0].Id); + Assert.Equal(2, model[1].Id); + + Assert.True(modelMetadata.HasValidators); + + Assert.False(modelState.IsValid); + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "p[0].Id").Value; + Assert.Equal("1", entry.AttemptedValue); + Assert.Equal("1", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "p[1]").Value; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "p[1].Id").Value; + Assert.Equal("2", entry.AttemptedValue); + Assert.Equal("2", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private static void Validation_CollectionOfType_ValidatorOnElementTestMethod(Collection p) { } + + public class InvalidEvenIds : IValidatableObject + { + public int Id { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Id % 2 == 0) + { + yield return new ValidationResult("Failed validation"); + } + } + } + + [Fact] + public async Task Validation_DictionaryType_NoValidators() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(IDictionary) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter[0].Key=key0¶meter[0].Value=10"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Collection( + model.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("key0", kvp.Key); + Assert.Equal(10, kvp.Value); + }); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "parameter[0].Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "parameter[0].Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + [Fact] + public async Task Validation_DictionaryType_ValueHasValidators() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter[0].Key=key0¶meter[0].Value.NeverValidProperty=value0"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Collection( + model.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("key0", kvp.Key); + Assert.Equal("value0", kvp.Value.NeverValidProperty); + }); + + Assert.False(modelState.IsValid); + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "parameter[0].Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "parameter[0].Value.NeverValidProperty").Value; + Assert.Equal("value0", entry.AttemptedValue); + Assert.Equal("value0", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "parameter[0].Value").Value; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + Assert.Single(entry.Errors); + } + + [Fact] + public async Task Validation_TopLevelProperty_NoValidation() + { + // Arrange + var modelType = typeof(Validation_TopLevelPropertyController); + var propertyInfo = modelType.GetProperty(nameof(Validation_TopLevelPropertyController.Model)); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForProperty(propertyInfo, propertyInfo.PropertyType); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = propertyInfo.Name, + ParameterType = propertyInfo.PropertyType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Model.Id=12"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(12, model.Id); + + Assert.False(modelMetadata.HasValidators); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "Model.Id").Value; + Assert.Equal("12", entry.AttemptedValue); + Assert.Equal("12", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + public class Validation_TopLevelPropertyModel + { + public int Id { get; set; } + } + + private class Validation_TopLevelPropertyController + { + public Validation_TopLevelPropertyModel Model { get; set; } + } + + [Fact] + public async Task Validation_TopLevelProperty_ValidationOnProperty() + { + // Arrange + var modelType = typeof(Validation_TopLevelProperty_ValidationOnPropertyController); + var propertyInfo = modelType.GetProperty(nameof(Validation_TopLevelProperty_ValidationOnPropertyController.Model)); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForProperty(propertyInfo, propertyInfo.PropertyType); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = propertyInfo.Name, + ParameterType = propertyInfo.PropertyType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Model.Id=12"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(12, model.Id); + + Assert.True(modelMetadata.HasValidators); + + Assert.False(modelState.IsValid); + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "Model.Id").Value; + Assert.Equal("12", entry.AttemptedValue); + Assert.Equal("12", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "Model").Value; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + } + + public class Validation_TopLevelProperty_ValidationOnPropertyController + { + [CustomValidation(typeof(Validation_TopLevelProperty_ValidationOnPropertyController), nameof(Validate))] + public Validation_TopLevelPropertyModel Model { get; set; } + + public static ValidationResult Validate(ValidationContext context) + { + return new ValidationResult("Invalid result"); + } + } + + [Fact] + public async Task Validation_InfinitelyRecursiveType_NoValidators() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecursiveModel) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Property1=8"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(8, model.Property1); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "Property1").Value; + Assert.Equal("8", entry.AttemptedValue); + Assert.Equal("8", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + public class RecursiveModel + { + public int Property1 { get; set; } + + public RecursiveModel Property2 { get; set; } + + public RecursiveModel Property3 => new RecursiveModel { Property1 = Property1 }; + } + + [Fact] + public async Task Validation_InifnitelyRecursiveModel_ValidationOnTopLevelParameter() + { + // Arrange + var parameterInfo = GetType().GetMethod(nameof(Validation_InifnitelyRecursiveModel_ValidationOnTopLevelParameterMethod), BindingFlags.NonPublic | BindingFlags.Static) + .GetParameters() + .First(); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForParameter(parameterInfo); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Property1=8"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(8, model.Property1); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "Property1").Value; + Assert.Equal("8", entry.AttemptedValue); + Assert.Equal("8", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private static void Validation_InifnitelyRecursiveModel_ValidationOnTopLevelParameterMethod([Required] RecursiveModel model) { } + private static void AssertRequiredError(string key, ModelError error) { Assert.Equal(ValidationAttributeUtil.GetRequiredErrorMessage(key), error.ErrorMessage); diff --git a/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs index 409605d730..ea544c1d26 100644 --- a/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.Formatters.Xml; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.DependencyInjection; @@ -26,7 +27,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest // Arrange var serviceCollection = new ServiceCollection(); AddHostingServices(serviceCollection); - serviceCollection.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_0); + serviceCollection + .AddMvc() + .AddXmlDataContractSerializerFormatters() + .SetCompatibilityVersion(CompatibilityVersion.Version_2_0); var services = serviceCollection.BuildServiceProvider(); @@ -36,6 +40,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var razorPagesOptions = services.GetRequiredService>().Value; var apiBehaviorOptions = services.GetRequiredService>().Value; var razorViewEngineOptions = services.GetRequiredService>().Value; + var xmlOptions = services.GetRequiredService>().Value; // Assert Assert.False(mvcOptions.AllowCombiningAuthorizeFilters); @@ -50,6 +55,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.True(apiBehaviorOptions.SuppressMapClientErrors); Assert.True(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); Assert.False(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); + Assert.False(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); } [Fact] @@ -58,7 +64,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest // Arrange var serviceCollection = new ServiceCollection(); AddHostingServices(serviceCollection); - serviceCollection.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + serviceCollection + .AddMvc() + .AddXmlDataContractSerializerFormatters() + .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); var services = serviceCollection.BuildServiceProvider(); @@ -68,6 +77,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var razorPagesOptions = services.GetRequiredService>().Value; var apiBehaviorOptions = services.GetRequiredService>().Value; var razorViewEngineOptions = services.GetRequiredService>().Value; + var xmlOptions = services.GetRequiredService>().Value; // Assert Assert.True(mvcOptions.AllowCombiningAuthorizeFilters); @@ -82,6 +92,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.True(apiBehaviorOptions.SuppressMapClientErrors); Assert.True(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); Assert.False(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); + Assert.False(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); } [Fact] @@ -90,7 +101,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest // Arrange var serviceCollection = new ServiceCollection(); AddHostingServices(serviceCollection); - serviceCollection.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + serviceCollection + .AddMvc() + .AddXmlDataContractSerializerFormatters() + .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); var services = serviceCollection.BuildServiceProvider(); @@ -100,6 +114,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var razorPagesOptions = services.GetRequiredService>().Value; var apiBehaviorOptions = services.GetRequiredService>().Value; var razorViewEngineOptions = services.GetRequiredService>().Value; + var xmlOptions = services.GetRequiredService>().Value; // Assert Assert.True(mvcOptions.AllowCombiningAuthorizeFilters); @@ -114,6 +129,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.False(apiBehaviorOptions.SuppressMapClientErrors); Assert.False(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); Assert.True(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); + Assert.True(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); } [Fact] @@ -122,7 +138,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest // Arrange var serviceCollection = new ServiceCollection(); AddHostingServices(serviceCollection); - serviceCollection.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Latest); + serviceCollection + .AddMvc() + .AddXmlDataContractSerializerFormatters() + .SetCompatibilityVersion(CompatibilityVersion.Latest); var services = serviceCollection.BuildServiceProvider(); @@ -132,6 +151,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var razorPagesOptions = services.GetRequiredService>().Value; var apiBehaviorOptions = services.GetRequiredService>().Value; var razorViewEngineOptions = services.GetRequiredService>().Value; + var xmlOptions = services.GetRequiredService>().Value; // Assert Assert.True(mvcOptions.AllowCombiningAuthorizeFilters); @@ -146,6 +166,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.False(apiBehaviorOptions.SuppressMapClientErrors); Assert.False(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); Assert.True(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); + Assert.True(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); } // This just does the minimum needed to be able to resolve these options. diff --git a/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs index e43d50af95..53ba4f52fb 100644 --- a/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Internal; @@ -251,7 +252,8 @@ namespace Microsoft.AspNetCore.Mvc { var excludeFilter = Assert.IsType(provider); Assert.Equal(typeof(XmlNode).FullName, excludeFilter.FullTypeName); - }); + }, + provider => Assert.IsType(provider)); } private static T GetOptions(Action action = null) diff --git a/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs index b4c06b2613..70fea5804a 100644 --- a/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters.Json; using Microsoft.AspNetCore.Mvc.Formatters.Json.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor.Compilation; @@ -381,14 +382,15 @@ namespace Microsoft.AspNetCore.Mvc typeof(IPostConfigureOptions), new[] { - typeof(MvcOptions).Assembly.GetType("Microsoft.AspNetCore.Mvc.Infrastructure.MvcOptionsConfigureCompatibilityOptions", throwOnError: true), + typeof(MvcOptionsConfigureCompatibilityOptions), + typeof(MvcCoreMvcOptionsSetup), } }, { typeof(IPostConfigureOptions), new[] { - typeof(RazorPagesOptions).Assembly.GetType("Microsoft.AspNetCore.Mvc.RazorPages.RazorPagesOptionsConfigureCompatibilityOptions", throwOnError: true), + typeof(RazorPagesOptionsConfigureCompatibilityOptions), } }, { diff --git a/test/WebSites/FormatterWebSite/Controllers/TestApiController.cs b/test/WebSites/FormatterWebSite/Controllers/TestApiController.cs new file mode 100644 index 0000000000..832a5917c6 --- /dev/null +++ b/test/WebSites/FormatterWebSite/Controllers/TestApiController.cs @@ -0,0 +1,16 @@ +// 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 FormatterWebSite.Models; +using Microsoft.AspNetCore.Mvc; + +namespace FormatterWebSite.Controllers +{ + [ApiController] + [Route("[controller]/[action]")] + public class TestApiController : ControllerBase + { + [HttpPost] + public IActionResult PostBookWithNoValidation(BookModelWithNoValidation bookModel) => Ok(); + } +} diff --git a/test/WebSites/FormatterWebSite/Models/BookModelWithNoValidation.cs b/test/WebSites/FormatterWebSite/Models/BookModelWithNoValidation.cs new file mode 100644 index 0000000000..ad13253e03 --- /dev/null +++ b/test/WebSites/FormatterWebSite/Models/BookModelWithNoValidation.cs @@ -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; +using System.Runtime.Serialization; +using Newtonsoft.Json; + +namespace FormatterWebSite.Models +{ + public class BookModelWithNoValidation + { + public Guid Id { get; set; } + + public string Title { get; set; } + + [JsonRequired] + [DataMember(IsRequired = true)] + public string ISBN { get; set; } + } +} diff --git a/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs b/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs index 847a01b428..49e8ab2e91 100644 --- a/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs +++ b/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs @@ -1,10 +1,14 @@ // 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.ComponentModel.DataAnnotations; +using System.Linq; + namespace FormatterWebSite { // A System.Security.Principal.SecurityIdentifier like type that works on xplat - public class RecursiveIdentifier + public class RecursiveIdentifier : IValidatableObject { public RecursiveIdentifier(string identifier) { @@ -14,5 +18,10 @@ namespace FormatterWebSite public string Value { get; } public RecursiveIdentifier AccountIdentifier => new RecursiveIdentifier(Value); + + public IEnumerable Validate(ValidationContext validationContext) + { + return Enumerable.Empty(); + } } } \ No newline at end of file diff --git a/test/WebSites/XmlFormattersWebSite/Controllers/XmlDataContractApiController.cs b/test/WebSites/XmlFormattersWebSite/Controllers/XmlDataContractApiController.cs index dd8f228caa..fc4918943b 100644 --- a/test/WebSites/XmlFormattersWebSite/Controllers/XmlDataContractApiController.cs +++ b/test/WebSites/XmlFormattersWebSite/Controllers/XmlDataContractApiController.cs @@ -1,9 +1,12 @@ // 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.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace XmlFormattersWebSite { @@ -22,7 +25,10 @@ namespace XmlFormattersWebSite // Both kinds of Xml serializers are configured for this application and use custom content-types to do formatter // selection. The globally configured formatters rely on custom content-type to perform conneg which does not play // well the ProblemDetails returning filters that defaults to using application/xml. We'll explicitly select the formatter for this controller. - objectResult.Formatters.Add(new XmlDataContractSerializerOutputFormatter()); + var mvcOptions = context.HttpContext.RequestServices.GetRequiredService>(); + var xmlFormatter = mvcOptions.Value.OutputFormatters.OfType().First(); + + objectResult.Formatters.Add(xmlFormatter); } } } diff --git a/test/WebSites/XmlFormattersWebSite/Controllers/XmlSerializedApiController.cs b/test/WebSites/XmlFormattersWebSite/Controllers/XmlSerializedApiController.cs index 6ee3ec4708..b6e39ad96b 100644 --- a/test/WebSites/XmlFormattersWebSite/Controllers/XmlSerializedApiController.cs +++ b/test/WebSites/XmlFormattersWebSite/Controllers/XmlSerializedApiController.cs @@ -1,9 +1,12 @@ // 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.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace XmlFormattersWebSite { @@ -22,7 +25,10 @@ namespace XmlFormattersWebSite // Both kinds of Xml serializers are configured for this application and use custom content-types to do formatter // selection. The globally configured formatters rely on custom content-type to perform conneg which does not play // well the ProblemDetails returning filters that defaults to using application/xml. We'll explicitly select the formatter for this controller. - objectResult.Formatters.Add(new XmlSerializerOutputFormatter()); + var mvcOptions = context.HttpContext.RequestServices.GetRequiredService>(); + var xmlFormatter = mvcOptions.Value.OutputFormatters.OfType().First(); + + objectResult.Formatters.Add(xmlFormatter); } } } diff --git a/test/WebSites/XmlFormattersWebSite/Startup.cs b/test/WebSites/XmlFormattersWebSite/Startup.cs index 07bda1e37a..03cd9d9bd2 100644 --- a/test/WebSites/XmlFormattersWebSite/Startup.cs +++ b/test/WebSites/XmlFormattersWebSite/Startup.cs @@ -13,47 +13,85 @@ namespace XmlFormattersWebSite { public class Startup { + public virtual CompatibilityVersion CompatibilityVersion => CompatibilityVersion.Latest; + // Set up application services public void ConfigureServices(IServiceCollection services) { // Add MVC services to the services container services.AddMvc() - .SetCompatibilityVersion(CompatibilityVersion.Latest); + .AddXmlDataContractSerializerFormatters() + .AddXmlSerializerFormatters() + .SetCompatibilityVersion(CompatibilityVersion); services.Configure(options => { - options.InputFormatters.Clear(); - options.OutputFormatters.Clear(); - // Since both XmlSerializer and DataContractSerializer based formatters // have supported media types of 'application/xml' and 'text/xml', it // would be difficult for a test to choose a particular formatter based on - // request information (Ex: Accept header). - // So here we instead clear out the default supported media types and create new - // ones which are distinguishable between formatters. - var xmlSerializerInputFormatter = new XmlSerializerInputFormatter(new MvcOptions()); + // request information (Ex: Accept header). + // We'll configure the ones on MvcOptions to use a distinct set of content types. + + XmlSerializerInputFormatter xmlSerializerInputFormatter = null; + XmlSerializerOutputFormatter xmlSerializerOutputFormatter = null; + XmlDataContractSerializerInputFormatter dcsInputFormatter = null; + XmlDataContractSerializerOutputFormatter dcsOutputFormatter = null; + + for (var i = options.InputFormatters.Count - 1; i >= 0; i--) + { + switch (options.InputFormatters[i]) + { + case XmlSerializerInputFormatter formatter: + xmlSerializerInputFormatter = formatter; + break; + + case XmlDataContractSerializerInputFormatter formatter: + dcsInputFormatter = formatter; + break; + + default: + options.InputFormatters.RemoveAt(i); + break; + } + } + + for (var i = options.OutputFormatters.Count - 1; i >= 0; i--) + { + switch (options.OutputFormatters[i]) + { + case XmlSerializerOutputFormatter formatter: + xmlSerializerOutputFormatter = formatter; + break; + + case XmlDataContractSerializerOutputFormatter formatter: + dcsOutputFormatter = formatter; + break; + + default: + options.OutputFormatters.RemoveAt(i); + break; + } + } + xmlSerializerInputFormatter.SupportedMediaTypes.Clear(); - xmlSerializerInputFormatter.SupportedMediaTypes.Add( - new MediaTypeHeaderValue("application/xml-xmlser")); - xmlSerializerInputFormatter.SupportedMediaTypes.Add( - new MediaTypeHeaderValue("text/xml-xmlser")); + xmlSerializerInputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/xml-xmlser")); + xmlSerializerInputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/xml-xmlser")); + xmlSerializerInputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/problem+xml")); - var xmlSerializerOutputFormatter = new XmlSerializerOutputFormatter(); xmlSerializerOutputFormatter.SupportedMediaTypes.Clear(); - xmlSerializerOutputFormatter.SupportedMediaTypes.Add( - new MediaTypeHeaderValue("application/xml-xmlser")); - xmlSerializerOutputFormatter.SupportedMediaTypes.Add( - new MediaTypeHeaderValue("text/xml-xmlser")); + xmlSerializerOutputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/xml-xmlser")); + xmlSerializerOutputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/xml-xmlser")); + xmlSerializerOutputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/problem+xml")); - var dcsInputFormatter = new XmlDataContractSerializerInputFormatter(new MvcOptions()); dcsInputFormatter.SupportedMediaTypes.Clear(); dcsInputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/xml-dcs")); dcsInputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/xml-dcs")); + dcsInputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/problem+xml")); - var dcsOutputFormatter = new XmlDataContractSerializerOutputFormatter(); dcsOutputFormatter.SupportedMediaTypes.Clear(); dcsOutputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/xml-dcs")); dcsOutputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/xml-dcs")); + dcsOutputFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/problem+xml")); options.InputFormatters.Add(dcsInputFormatter); options.InputFormatters.Add(xmlSerializerInputFormatter); diff --git a/test/WebSites/XmlFormattersWebSite/StartupWith21Compat.cs b/test/WebSites/XmlFormattersWebSite/StartupWith21Compat.cs new file mode 100644 index 0000000000..5b96407d99 --- /dev/null +++ b/test/WebSites/XmlFormattersWebSite/StartupWith21Compat.cs @@ -0,0 +1,13 @@ +// 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 Microsoft.AspNetCore.Mvc; + +namespace XmlFormattersWebSite +{ + public class StartupWith21Compat : Startup + { + public override CompatibilityVersion CompatibilityVersion => CompatibilityVersion.Version_2_1; + } +} +