diff --git a/src/Mvc/.gitignore b/src/Mvc/.gitignore index e38f0aa101..d5d42e3255 100644 --- a/src/Mvc/.gitignore +++ b/src/Mvc/.gitignore @@ -37,7 +37,6 @@ node_modules *launchSettings.json *.orig .vscode/ -global.json BenchmarkDotNet.Artifacts/ .idea/ msbuild.binlog diff --git a/src/Mvc/Directory.Build.props b/src/Mvc/Directory.Build.props index fad2bb0610..6c951ddaca 100644 --- a/src/Mvc/Directory.Build.props +++ b/src/Mvc/Directory.Build.props @@ -14,8 +14,8 @@ $(MSBuildThisFileDirectory) $(MSBuildThisFileDirectory)build\Key.snk true - true true + true diff --git a/src/Mvc/Directory.Build.targets b/src/Mvc/Directory.Build.targets index eb03b2565f..73b97f2807 100644 --- a/src/Mvc/Directory.Build.targets +++ b/src/Mvc/Directory.Build.targets @@ -1,6 +1,9 @@ - + $(MicrosoftNETCoreApp21PackageVersion) + $(MicrosoftNETCoreApp22PackageVersion) $(NETStandardLibrary20PackageVersion) + + 99.9 diff --git a/src/Mvc/Mvc.NoFun.sln b/src/Mvc/Mvc.NoFun.sln index 33f4a84d2f..d67abd6f60 100644 --- a/src/Mvc/Mvc.NoFun.sln +++ b/src/Mvc/Mvc.NoFun.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.27130.2036 @@ -35,7 +36,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Ta EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.TagHelpers.Test", "test\Microsoft.AspNetCore.Mvc.TagHelpers.Test\Microsoft.AspNetCore.Mvc.TagHelpers.Test.csproj", "{860119ED-3DB1-424D-8D0A-30132A8A7D96}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.TestCommon", "test\Microsoft.AspNetCore.Mvc.TestCommon\Microsoft.AspNetCore.Mvc.TestCommon.csproj", "{F504357E-C2E1-4818-BA5C-9A2EAC25FEE5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Core.TestCommon", "test\Microsoft.AspNetCore.Mvc.Core.TestCommon\Microsoft.AspNetCore.Mvc.Core.TestCommon.csproj", "{F504357E-C2E1-4818-BA5C-9A2EAC25FEE5}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.IntegrationTests", "test\Microsoft.AspNetCore.Mvc.IntegrationTests\Microsoft.AspNetCore.Mvc.IntegrationTests.csproj", "{864FA09D-1E48-403A-A6C8-4F079D2A30F0}" EndProject @@ -104,13 +105,23 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{44546170-35BF-448F-88F5-4331AE67AEAE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test", "test\Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test\Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test.csproj", "{2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Analyzers", "src\Microsoft.AspNetCore.Mvc.Analyzers\Microsoft.AspNetCore.Mvc.Analyzers.csproj", "{30862895-C1FA-49F5-B69A-B0F9F2ECD0F3}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Analyzers.Experimental", "src\Microsoft.AspNetCore.Mvc.Analyzers.Experimental\Microsoft.AspNetCore.Mvc.Analyzers.Experimental.csproj", "{F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mvc.Analyzers.Test", "test\Mvc.Analyzers.Test\Mvc.Analyzers.Test.csproj", "{829D9A67-2D07-4CE6-86C0-59F2549B0CFA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Analyzers.Test", "test\Microsoft.AspNetCore.Mvc.Analyzers.Test\Microsoft.AspNetCore.Mvc.Analyzers.Test.csproj", "{829D9A67-2D07-4CE6-86C0-59F2549B0CFA}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Views.TestCommon", "test\Microsoft.AspNetCore.Mvc.Views.TestCommon\Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj", "{0772E545-A674-4165-9469-E3D79D88A4A8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Testing", "src\Microsoft.AspNetCore.Mvc.Testing\Microsoft.AspNetCore.Mvc.Testing.csproj", "{92D959F2-66B8-490A-BA33-DA4421EBC948}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Api.Analyzers", "src\Microsoft.AspNetCore.Mvc.Api.Analyzers\Microsoft.AspNetCore.Mvc.Api.Analyzers.csproj", "{1B398182-9EAE-400B-A2BD-EFFAC0168A36}" +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 @@ -498,18 +509,6 @@ Global {28D4DA20-6E13-47F9-80AE-D6AA7699CC35}.Release|Mixed Platforms.Build.0 = Release|Any CPU {28D4DA20-6E13-47F9-80AE-D6AA7699CC35}.Release|x86.ActiveCfg = Release|Any CPU {28D4DA20-6E13-47F9-80AE-D6AA7699CC35}.Release|x86.Build.0 = Release|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Debug|x86.ActiveCfg = Debug|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Debug|x86.Build.0 = Debug|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Release|Any CPU.Build.0 = Release|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Release|x86.ActiveCfg = Release|Any CPU - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD}.Release|x86.Build.0 = Release|Any CPU {30862895-C1FA-49F5-B69A-B0F9F2ECD0F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {30862895-C1FA-49F5-B69A-B0F9F2ECD0F3}.Debug|Any CPU.Build.0 = Debug|Any CPU {30862895-C1FA-49F5-B69A-B0F9F2ECD0F3}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -522,18 +521,6 @@ Global {30862895-C1FA-49F5-B69A-B0F9F2ECD0F3}.Release|Mixed Platforms.Build.0 = Release|Any CPU {30862895-C1FA-49F5-B69A-B0F9F2ECD0F3}.Release|x86.ActiveCfg = Release|Any CPU {30862895-C1FA-49F5-B69A-B0F9F2ECD0F3}.Release|x86.Build.0 = Release|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Debug|x86.ActiveCfg = Debug|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Debug|x86.Build.0 = Debug|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Release|Any CPU.Build.0 = Release|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Release|x86.ActiveCfg = Release|Any CPU - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754}.Release|x86.Build.0 = Release|Any CPU {829D9A67-2D07-4CE6-86C0-59F2549B0CFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {829D9A67-2D07-4CE6-86C0-59F2549B0CFA}.Debug|Any CPU.Build.0 = Debug|Any CPU {829D9A67-2D07-4CE6-86C0-59F2549B0CFA}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -546,6 +533,90 @@ Global {829D9A67-2D07-4CE6-86C0-59F2549B0CFA}.Release|Mixed Platforms.Build.0 = Release|Any CPU {829D9A67-2D07-4CE6-86C0-59F2549B0CFA}.Release|x86.ActiveCfg = Release|Any CPU {829D9A67-2D07-4CE6-86C0-59F2549B0CFA}.Release|x86.Build.0 = Release|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Debug|x86.ActiveCfg = Debug|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Debug|x86.Build.0 = Debug|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Release|Any CPU.Build.0 = Release|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Release|x86.ActiveCfg = Release|Any CPU + {0772E545-A674-4165-9469-E3D79D88A4A8}.Release|x86.Build.0 = Release|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Debug|x86.ActiveCfg = Debug|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Debug|x86.Build.0 = Debug|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Release|Any CPU.Build.0 = Release|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Release|x86.ActiveCfg = Release|Any CPU + {92D959F2-66B8-490A-BA33-DA4421EBC948}.Release|x86.Build.0 = Release|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Debug|x86.Build.0 = Debug|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Release|Any CPU.Build.0 = Release|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Release|x86.ActiveCfg = Release|Any CPU + {1B398182-9EAE-400B-A2BD-EFFAC0168A36}.Release|x86.Build.0 = Release|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Debug|x86.ActiveCfg = Debug|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Debug|x86.Build.0 = Debug|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Release|Any CPU.Build.0 = Release|Any CPU + {71C626FC-6408-494B-A127-38CB64F71324}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {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 @@ -584,10 +655,15 @@ Global {CF322BE1-E1FE-4CFD-8FCA-16A14B905D53} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} {0AB46520-F441-4E01-B444-08F4D23F8B1B} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} {28D4DA20-6E13-47F9-80AE-D6AA7699CC35} = {44546170-35BF-448F-88F5-4331AE67AEAE} - {2E6CDE10-8F96-4B75-B0D9-808F6A01B8BD} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} {30862895-C1FA-49F5-B69A-B0F9F2ECD0F3} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} - {F8FD2D6A-DCD1-4A7B-B599-B728A12A1754} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} {829D9A67-2D07-4CE6-86C0-59F2549B0CFA} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} + {0772E545-A674-4165-9469-E3D79D88A4A8} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} + {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/src/Mvc/Mvc.sln b/src/Mvc/Mvc.sln index da45132198..dcd83f09ca 100644 --- a/src/Mvc/Mvc.sln +++ b/src/Mvc/Mvc.sln @@ -51,8 +51,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagHelpersWebSite", "test\W EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FilesWebSite", "test\WebSites\FilesWebSite\FilesWebSite.csproj", "{0EF9860B-10D7-452F-B0F4-A405B88BEBB3}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorPageExecutionInstrumentationWebSite", "test\WebSites\RazorPageExecutionInstrumentationWebSite\RazorPageExecutionInstrumentationWebSite.csproj", "{2B2B9876-903C-4065-8D62-2EE832BBA106}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationModelWebSite", "test\WebSites\ApplicationModelWebSite\ApplicationModelWebSite.csproj", "{CAE52CB7-0FAC-4B5B-8251-B0FF837DB657}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.WebApiCompatShim", "src\Microsoft.AspNetCore.Mvc.WebApiCompatShim\Microsoft.AspNetCore.Mvc.WebApiCompatShim.csproj", "{23D30B8C-04B1-4577-A604-ED27EA1E4A0E}" @@ -75,7 +73,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControllersFromServicesWebS EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControllersFromServicesClassLibrary", "test\WebSites\ControllersFromServicesClassLibrary\ControllersFromServicesClassLibrary.csproj", "{551DC89E-2A13-4CF2-83D7-1ADD802443D5}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.TestCommon", "test\Microsoft.AspNetCore.Mvc.TestCommon\Microsoft.AspNetCore.Mvc.TestCommon.csproj", "{F504357E-C2E1-4818-BA5C-9A2EAC25FEE5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Core.TestCommon", "test\Microsoft.AspNetCore.Mvc.Core.TestCommon\Microsoft.AspNetCore.Mvc.Core.TestCommon.csproj", "{F504357E-C2E1-4818-BA5C-9A2EAC25FEE5}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CorsWebSite", "test\WebSites\CorsWebSite\CorsWebSite.csproj", "{94BA134D-04B3-48AA-BA55-5A4DB8640F2D}" EndProject @@ -162,14 +160,30 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorBuildWebSite.Views", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Analyzers", "src\Microsoft.AspNetCore.Mvc.Analyzers\Microsoft.AspNetCore.Mvc.Analyzers.csproj", "{87A3E227-C45E-4141-A59F-402908E651FD}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Analyzers.Test", "test\Microsoft.AspNetCore.Mvc.Analyzers.Test\Microsoft.AspNetCore.Mvc.Analyzers.Test.csproj", "{E3E09D2F-1FCF-4396-9B09-5A62CA8CC831}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Analyzers.Experimental", "src\Microsoft.AspNetCore.Mvc.Analyzers.Experimental\Microsoft.AspNetCore.Mvc.Analyzers.Experimental.csproj", "{CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test", "test\Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test\Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test.csproj", "{E83D3745-9BCF-40E8-8D34-AFBA604C2439}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mvc.Analyzers.Test", "test\Mvc.Analyzers.Test\Mvc.Analyzers.Test.csproj", "{E3E09D2F-1FCF-4396-9B09-5A62CA8CC831}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorPagesClassLibrary", "test\WebSites\RazorPagesClassLibrary\RazorPagesClassLibrary.csproj", "{17122147-ADFD-41C8-87D9-CCC582CCA8F9}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Views.TestCommon", "test\Microsoft.AspNetCore.Mvc.Views.TestCommon\Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj", "{51E3E785-A9D1-4196-BAFE-A17FF4304B89}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarkapps", "benchmarkapps", "{2859F266-673A-45A2-9E3C-7B39C6DDD38E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BasicApi", "benchmarkapps\BasicApi\BasicApi.csproj", "{910F023A-88E3-4CB4-8793-AC4005C7B421}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BasicViews", "benchmarkapps\BasicViews\BasicViews.csproj", "{E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mvc.Api.Analyzers.Test", "test\Mvc.Api.Analyzers.Test\Mvc.Api.Analyzers.Test.csproj", "{DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Api.Analyzers", "src\Microsoft.AspNetCore.Mvc.Api.Analyzers\Microsoft.AspNetCore.Mvc.Api.Analyzers.csproj", "{3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}" +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 @@ -330,16 +344,6 @@ Global {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Release|Mixed Platforms.Build.0 = Release|Any CPU {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Release|x86.ActiveCfg = Release|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Debug|x86.ActiveCfg = Debug|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Release|Any CPU.Build.0 = Release|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {2B2B9876-903C-4065-8D62-2EE832BBA106}.Release|x86.ActiveCfg = Release|Any CPU {CAE52CB7-0FAC-4B5B-8251-B0FF837DB657}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CAE52CB7-0FAC-4B5B-8251-B0FF837DB657}.Debug|Any CPU.Build.0 = Debug|Any CPU {CAE52CB7-0FAC-4B5B-8251-B0FF837DB657}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -856,30 +860,6 @@ Global {E3E09D2F-1FCF-4396-9B09-5A62CA8CC831}.Release|Mixed Platforms.Build.0 = Release|Any CPU {E3E09D2F-1FCF-4396-9B09-5A62CA8CC831}.Release|x86.ActiveCfg = Release|Any CPU {E3E09D2F-1FCF-4396-9B09-5A62CA8CC831}.Release|x86.Build.0 = Release|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Debug|x86.ActiveCfg = Debug|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Debug|x86.Build.0 = Debug|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Release|Any CPU.Build.0 = Release|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Release|x86.ActiveCfg = Release|Any CPU - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61}.Release|x86.Build.0 = Release|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Debug|x86.ActiveCfg = Debug|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Debug|x86.Build.0 = Debug|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Release|Any CPU.Build.0 = Release|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Release|x86.ActiveCfg = Release|Any CPU - {E83D3745-9BCF-40E8-8D34-AFBA604C2439}.Release|x86.Build.0 = Release|Any CPU {17122147-ADFD-41C8-87D9-CCC582CCA8F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {17122147-ADFD-41C8-87D9-CCC582CCA8F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {17122147-ADFD-41C8-87D9-CCC582CCA8F9}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -892,6 +872,114 @@ Global {17122147-ADFD-41C8-87D9-CCC582CCA8F9}.Release|Mixed Platforms.Build.0 = Release|Any CPU {17122147-ADFD-41C8-87D9-CCC582CCA8F9}.Release|x86.ActiveCfg = Release|Any CPU {17122147-ADFD-41C8-87D9-CCC582CCA8F9}.Release|x86.Build.0 = Release|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Debug|x86.ActiveCfg = Debug|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Debug|x86.Build.0 = Debug|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|Any CPU.Build.0 = Release|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|x86.ActiveCfg = Release|Any CPU + {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|x86.Build.0 = Release|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Debug|Any CPU.Build.0 = Debug|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Debug|x86.ActiveCfg = Debug|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Debug|x86.Build.0 = Debug|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Release|Any CPU.ActiveCfg = Release|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Release|Any CPU.Build.0 = Release|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Release|x86.ActiveCfg = Release|Any CPU + {910F023A-88E3-4CB4-8793-AC4005C7B421}.Release|x86.Build.0 = Release|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Debug|x86.Build.0 = Debug|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Release|Any CPU.Build.0 = Release|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Release|x86.ActiveCfg = Release|Any CPU + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB}.Release|x86.Build.0 = Release|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Debug|x86.ActiveCfg = Debug|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Debug|x86.Build.0 = Debug|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Release|Any CPU.Build.0 = Release|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Release|x86.ActiveCfg = Release|Any CPU + {DD7B9F20-354C-4D9E-8C8A-8AE6E7595A87}.Release|x86.Build.0 = Release|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Debug|x86.Build.0 = Debug|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Release|Any CPU.Build.0 = Release|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Release|x86.ActiveCfg = Release|Any CPU + {3B550487-10E4-4E6D-9CEF-B1B4CA1253DA}.Release|x86.Build.0 = Release|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Debug|x86.ActiveCfg = Debug|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Debug|x86.Build.0 = Debug|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Release|Any CPU.Build.0 = Release|Any CPU + {D7C6A696-F232-4288-BCCD-367407E4A934}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {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 @@ -913,7 +1001,6 @@ Global {C6304029-78C8-4604-99BE-2078DCA1DD36} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {6DB9B8D0-80F7-4E70-BBB0-0B4C04D79A47} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {0EF9860B-10D7-452F-B0F4-A405B88BEBB3} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} - {2B2B9876-903C-4065-8D62-2EE832BBA106} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {CAE52CB7-0FAC-4B5B-8251-B0FF837DB657} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {23D30B8C-04B1-4577-A604-ED27EA1E4A0E} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} {B2B7BC91-688E-4C1E-A71F-CE948D958DDF} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} @@ -958,9 +1045,16 @@ Global {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {87A3E227-C45E-4141-A59F-402908E651FD} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} {E3E09D2F-1FCF-4396-9B09-5A62CA8CC831} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} - {CBF23034-2249-4FE5-BD48-5F3CEAC0DF61} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} - {E83D3745-9BCF-40E8-8D34-AFBA604C2439} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} {17122147-ADFD-41C8-87D9-CCC582CCA8F9} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} + {51E3E785-A9D1-4196-BAFE-A17FF4304B89} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} + {910F023A-88E3-4CB4-8793-AC4005C7B421} = {2859F266-673A-45A2-9E3C-7B39C6DDD38E} + {E89EB74D-C1CE-456F-B42D-CCF1575E0CFB} = {2859F266-673A-45A2-9E3C-7B39C6DDD38E} + {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/src/Mvc/NuGetPackageVerifier.json b/src/Mvc/NuGetPackageVerifier.json index b153ab1515..f551b83476 100644 --- a/src/Mvc/NuGetPackageVerifier.json +++ b/src/Mvc/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/src/Mvc/README.md b/src/Mvc/README.md index e5aa14d771..775ed6bd0c 100644 --- a/src/Mvc/README.md +++ b/src/Mvc/README.md @@ -3,9 +3,7 @@ ASP.NET Core MVC **Note: For ASP.NET MVC 5.x, Web API 2.x, and Web Pages 3.x (not ASP.NET Core), see https://github.com/aspnet/AspNetWebStack** -AppVeyor: [![AppVeyor](https://ci.appveyor.com/api/projects/status/969jbosi0qwc1awg/branch/dev?svg=true)](https://ci.appveyor.com/project/aspnetci/mvc/branch/dev) - -Travis: [![Travis](https://travis-ci.org/aspnet/Mvc.svg?branch=dev)](https://travis-ci.org/aspnet/Mvc) +Travis: [![Travis](https://travis-ci.org/aspnet/Mvc.svg?branch=release/2.2)](https://travis-ci.org/aspnet/Mvc) ASP.NET Core MVC gives you a powerful, patterns-based way to build dynamic websites that enables a clean separation of concerns and gives you full control over markup for enjoyable, agile development. ASP.NET Core MVC includes many features that enable fast, TDD-friendly development for creating sophisticated applications that use the latest web standards. diff --git a/src/Mvc/benchmarkapps/BasicApi/BasicApi.csproj b/src/Mvc/benchmarkapps/BasicApi/BasicApi.csproj new file mode 100644 index 0000000000..ec6e654069 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/BasicApi.csproj @@ -0,0 +1,43 @@ + + + netcoreapp2.2 + $(TargetFrameworks);net461 + $(BenchmarksTargetFramework) + + $(DefineConstants);GENERATE_SQL_SCRIPTS + $(DefineConstants);__RemoveThisBitTo__GENERATE_SQL_SCRIPTS + + CS8002;$(WarningsNotAsErrors) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mvc/benchmarkapps/BasicApi/Controllers/PetController.cs b/src/Mvc/benchmarkapps/BasicApi/Controllers/PetController.cs new file mode 100644 index 0000000000..ed1ff8f5b9 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Controllers/PetController.cs @@ -0,0 +1,163 @@ +// 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 System.Threading.Tasks; +using BasicApi.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace BasicApi.Controllers +{ + [ApiController] + [Authorize("pet-store-reader")] + [Route("/pet")] + public class PetController : ControllerBase + { + public PetController(BasicApiContext dbContext) + { + DbContext = dbContext; + } + + public BasicApiContext DbContext { get; } + + [HttpGet("{id}", Name = "FindPetById")] + [ProducesResponseType(typeof(Pet), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> FindById(int id) + { + var pet = await DbContext.Pets + .Include(p => p.Category) + .Include(p => p.Images) + .Include(p => p.Tags) + .FirstOrDefaultAsync(p => p.Id == id); + if (pet == null) + { + return new NotFoundResult(); + } + + return pet; + } + + [AllowAnonymous] + [HttpGet("anonymous/{id}")] + [ProducesResponseType(typeof(Pet), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> FindByIdWithoutToken(int id) + { + var pet = await DbContext.Pets + .Include(p => p.Category) + .Include(p => p.Images) + .Include(p => p.Tags) + .FirstOrDefaultAsync(p => p.Id == id); + if (pet == null) + { + return new NotFoundResult(); + } + + return pet; + } + + [HttpGet("findByCategory/{categoryId}")] + [ProducesResponseType(typeof(Pet), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> FindByCategory(int categoryId) + { + var pet = await DbContext.Pets + .Include(p => p.Category) + .Include(p => p.Images) + .Include(p => p.Tags) + .FirstOrDefaultAsync(p => p.Category != null && p.Category.Id == categoryId); + if (pet == null) + { + return new NotFoundResult(); + } + + return pet; + } + + [HttpGet("findByStatus")] + [ProducesResponseType(typeof(Pet), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> FindByStatus(string status) + { + var pet = await DbContext.Pets + .Include(p => p.Category) + .Include(p => p.Images) + .Include(p => p.Tags) + .FirstOrDefaultAsync(p => p.Status == status); + if (pet == null) + { + return new NotFoundResult(); + } + + return pet; + } + + [HttpGet("findByTags")] + [ProducesResponseType(typeof(Pet), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> FindByTags(string[] tags) + { + var pet = await DbContext.Pets + .Include(p => p.Category) + .Include(p => p.Images) + .Include(p => p.Tags) + .FirstOrDefaultAsync(p => p.Tags.Any(t => tags.Contains(t.Name))); + if (pet == null) + { + return new NotFoundResult(); + } + + return pet; + } + + [Authorize("pet-store-writer")] + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task AddPet([FromBody] Pet pet) + { + DbContext.Pets.Add(pet); + await DbContext.SaveChangesAsync(); + + 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) + { + throw new NotImplementedException(); + } + + [Authorize("pet-store-writer")] + [HttpPost("{id}/uploadImage")] + public IActionResult UploadImage(int id, IFormFile file) + { + throw new NotImplementedException(); + } + + [Authorize("pet-store-writer")] + [HttpDelete("{id}")] + public IActionResult DeletePet(int id) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Controllers/TokenController.cs b/src/Mvc/benchmarkapps/BasicApi/Controllers/TokenController.cs new file mode 100644 index 0000000000..803a1d208e --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Controllers/TokenController.cs @@ -0,0 +1,77 @@ +// 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.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace BasicApi.Controllers +{ + public class TokenController : ControllerBase + { + private static readonly Dictionary _identities; + + static TokenController() + { + _identities = new Dictionary(StringComparer.Ordinal); + + var reader = new ClaimsIdentity(); + reader.AddClaim(new Claim(ClaimsIdentity.DefaultNameClaimType, "reader@example.com")); + reader.AddClaim(new Claim("scope", "pet-store-reader")); + _identities.Add("reader@example.com", reader); + + var writer = new ClaimsIdentity(); + writer.AddClaim(new Claim(ClaimsIdentity.DefaultNameClaimType, "writer@example.com")); + writer.AddClaim(new Claim("scope", "pet-store-reader")); + writer.AddClaim(new Claim("scope", "pet-store-writer")); + _identities.Add("writer@example.com", writer); + } + + private readonly SigningCredentials _credentials; + private readonly JwtBearerOptions _options; + + public TokenController( + IOptionsSnapshot options, + SigningCredentials credentials) + { + _options = options.Get(JwtBearerDefaults.AuthenticationScheme); + _credentials = credentials; + } + + [HttpGet("/token")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public IActionResult GetToken(string username) + { + if (username == null || !_identities.TryGetValue(username, out var identity)) + { + return new StatusCodeResult(StatusCodes.Status403Forbidden); + } + + var handler = _options.SecurityTokenValidators.OfType().First(); + var tokenDescriptor = new SecurityTokenDescriptor() + { + Issuer = _options.TokenValidationParameters.ValidIssuer, + Audience = _options.TokenValidationParameters.ValidAudience, + SigningCredentials = _credentials, + Subject = identity + }; + + var securityToken = handler.CreateJwtSecurityToken( + issuer: _options.TokenValidationParameters.ValidIssuer, + audience: _options.TokenValidationParameters.ValidAudience, + signingCredentials: _credentials, + subject: identity); + + var token = handler.WriteToken(securityToken); + return Content(token); + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Migrations/20180609000420_InitialCreate.Designer.cs b/src/Mvc/benchmarkapps/BasicApi/Migrations/20180609000420_InitialCreate.Designer.cs new file mode 100644 index 0000000000..bbcf696281 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Migrations/20180609000420_InitialCreate.Designer.cs @@ -0,0 +1,172 @@ +// +using BasicApi.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace BasicApi.Migrations +{ + [DbContext(typeof(BasicApiContext))] + [Migration("20180609000420_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "2.1.0-rtm-30799") + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("BasicApi.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Name"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + + b.HasData( + new { Id = -1, Name = "Dogs" }, + new { Id = -2, Name = "Cats" }, + new { Id = -3, Name = "Rabbits" }, + new { Id = -4, Name = "Lions" } + ); + }); + + modelBuilder.Entity("BasicApi.Models.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("PetId"); + + b.Property("Url"); + + b.HasKey("Id"); + + b.HasIndex("PetId"); + + b.ToTable("Images"); + + b.HasData( + new { Id = -1, PetId = -1, Url = "http://example.com/pets/-1_1.png" }, + new { Id = -2, PetId = -2, Url = "http://example.com/pets/-2_1.png" }, + new { Id = -3, PetId = -3, Url = "http://example.com/pets/-3_1.png" }, + new { Id = -4, PetId = -4, Url = "http://example.com/pets/-4_1.png" }, + new { Id = -5, PetId = -5, Url = "http://example.com/pets/-5_1.png" }, + new { Id = -6, PetId = -6, Url = "http://example.com/pets/-6_1.png" }, + new { Id = -7, PetId = -7, Url = "http://example.com/pets/-7_1.png" }, + new { Id = -8, PetId = -8, Url = "http://example.com/pets/-8_1.png" }, + new { Id = -9, PetId = -9, Url = "http://example.com/pets/-9_1.png" }, + new { Id = -10, PetId = -10, Url = "http://example.com/pets/-10_1.png" }, + new { Id = -11, PetId = -11, Url = "http://example.com/pets/-11_1.png" }, + new { Id = -12, PetId = -12, Url = "http://example.com/pets/-12_1.png" } + ); + }); + + modelBuilder.Entity("BasicApi.Models.Pet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Age"); + + b.Property("CategoryId"); + + b.Property("HasVaccinations"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50); + + b.Property("Status") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Pets"); + + b.HasData( + new { Id = -1, Age = 1, CategoryId = -1, HasVaccinations = true, Name = "Dogs1", Status = "available" }, + new { Id = -2, Age = 1, CategoryId = -1, HasVaccinations = true, Name = "Dogs2", Status = "available" }, + new { Id = -3, Age = 1, CategoryId = -1, HasVaccinations = true, Name = "Dogs3", Status = "available" }, + new { Id = -4, Age = 1, CategoryId = -2, HasVaccinations = true, Name = "Cats1", Status = "available" }, + new { Id = -5, Age = 1, CategoryId = -2, HasVaccinations = true, Name = "Cats2", Status = "available" }, + new { Id = -6, Age = 1, CategoryId = -2, HasVaccinations = true, Name = "Cats3", Status = "available" }, + new { Id = -7, Age = 1, CategoryId = -3, HasVaccinations = true, Name = "Rabbits1", Status = "available" }, + new { Id = -8, Age = 1, CategoryId = -3, HasVaccinations = true, Name = "Rabbits2", Status = "available" }, + new { Id = -9, Age = 1, CategoryId = -3, HasVaccinations = true, Name = "Rabbits3", Status = "available" }, + new { Id = -10, Age = 1, CategoryId = -4, HasVaccinations = true, Name = "Lions1", Status = "available" }, + new { Id = -11, Age = 1, CategoryId = -4, HasVaccinations = true, Name = "Lions2", Status = "available" }, + new { Id = -12, Age = 1, CategoryId = -4, HasVaccinations = true, Name = "Lions3", Status = "available" } + ); + }); + + modelBuilder.Entity("BasicApi.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Name"); + + b.Property("PetId"); + + b.HasKey("Id"); + + b.HasIndex("PetId"); + + b.ToTable("Tags"); + + b.HasData( + new { Id = -1, Name = "Tag1", PetId = -1 }, + new { Id = -2, Name = "Tag1", PetId = -2 }, + new { Id = -3, Name = "Tag1", PetId = -3 }, + new { Id = -4, Name = "Tag1", PetId = -4 }, + new { Id = -5, Name = "Tag1", PetId = -5 }, + new { Id = -6, Name = "Tag1", PetId = -6 }, + new { Id = -7, Name = "Tag1", PetId = -7 }, + new { Id = -8, Name = "Tag1", PetId = -8 }, + new { Id = -9, Name = "Tag1", PetId = -9 }, + new { Id = -10, Name = "Tag1", PetId = -10 }, + new { Id = -11, Name = "Tag1", PetId = -11 }, + new { Id = -12, Name = "Tag1", PetId = -12 } + ); + }); + + modelBuilder.Entity("BasicApi.Models.Image", b => + { + b.HasOne("BasicApi.Models.Pet") + .WithMany("Images") + .HasForeignKey("PetId"); + }); + + modelBuilder.Entity("BasicApi.Models.Pet", b => + { + b.HasOne("BasicApi.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + }); + + modelBuilder.Entity("BasicApi.Models.Tag", b => + { + b.HasOne("BasicApi.Models.Pet") + .WithMany("Tags") + .HasForeignKey("PetId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Migrations/20180609000420_InitialCreate.cs b/src/Mvc/benchmarkapps/BasicApi/Migrations/20180609000420_InitialCreate.cs new file mode 100644 index 0000000000..2dd749ebbc --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Migrations/20180609000420_InitialCreate.cs @@ -0,0 +1,218 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace BasicApi.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(nullable: false) +#if !NET461 + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn) +#endif + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .Annotation("Sqlite:Autoincrement", true) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + Name = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Pets", + columns: table => new + { + Id = table.Column(nullable: false) +#if !NET461 + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn) +#endif + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .Annotation("Sqlite:Autoincrement", true) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + Age = table.Column(nullable: false), + CategoryId = table.Column(nullable: true), + HasVaccinations = table.Column(nullable: false), + Name = table.Column(maxLength: 50, nullable: false), + Status = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Pets", x => x.Id); + table.ForeignKey( + name: "FK_Pets_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Images", + columns: table => new + { + Id = table.Column(nullable: false) +#if !NET461 + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn) +#endif + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .Annotation("Sqlite:Autoincrement", true) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + Url = table.Column(nullable: true), + PetId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Images", x => x.Id); + table.ForeignKey( + name: "FK_Images_Pets_PetId", + column: x => x.PetId, + principalTable: "Pets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Tags", + columns: table => new + { + Id = table.Column(nullable: false) +#if !NET461 + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn) +#endif + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .Annotation("Sqlite:Autoincrement", true) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + Name = table.Column(nullable: true), + PetId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Tags", x => x.Id); + table.ForeignKey( + name: "FK_Tags_Pets_PetId", + column: x => x.PetId, + principalTable: "Pets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + table: "Categories", + columns: new[] { "Id", "Name" }, + values: new object[,] + { + { -1, "Dogs" }, + { -2, "Cats" }, + { -3, "Rabbits" }, + { -4, "Lions" } + }); + + migrationBuilder.InsertData( + table: "Pets", + columns: new[] { "Id", "Age", "CategoryId", "HasVaccinations", "Name", "Status" }, + values: new object[,] + { + { -1, 1, -1, true, "Dogs1", "available" }, + { -2, 1, -1, true, "Dogs2", "available" }, + { -3, 1, -1, true, "Dogs3", "available" }, + + { -4, 1, -2, true, "Cats1", "available" }, + + { -5, 1, -2, true, "Cats2", "available" }, + + { -6, 1, -2, true, "Cats3", "available" }, + + { -7, 1, -3, true, "Rabbits1", "available" }, + + { -8, 1, -3, true, "Rabbits2", "available" }, + + { -9, 1, -3, true, "Rabbits3", "available" }, + + { -10, 1, -4, true, "Lions1", "available" }, + { -11, 1, -4, true, "Lions2", "available" }, + { -12, 1, -4, true, "Lions3", "available" } + }); + + migrationBuilder.InsertData( + table: "Images", + columns: new[] { "Id", "PetId", "Url" }, + values: new object[,] + { + { -1, -1, "http://example.com/pets/-1_1.png" }, + { -2, -2, "http://example.com/pets/-2_1.png" }, + { -11, -11, "http://example.com/pets/-11_1.png" }, + { -3, -3, "http://example.com/pets/-3_1.png" }, + { -4, -4, "http://example.com/pets/-4_1.png" }, + + { -10, -10, "http://example.com/pets/-10_1.png" }, + + { -5, -5, "http://example.com/pets/-5_1.png" }, + + { -6, -6, "http://example.com/pets/-6_1.png" }, + + { -12, -12, "http://example.com/pets/-12_1.png" }, + + { -7, -7, "http://example.com/pets/-7_1.png" }, + { -9, -9, "http://example.com/pets/-9_1.png" }, + { -8, -8, "http://example.com/pets/-8_1.png" } + }); + + migrationBuilder.InsertData( + table: "Tags", + columns: new[] { "Id", "Name", "PetId" }, + values: new object[,] + { + { -11, "Tag1", -11 }, + { -10, "Tag1", -10 }, + { -9, "Tag1", -9 }, + { -6, "Tag1", -6 }, + { -7, "Tag1", -7 }, + { -5, "Tag1", -5 }, + { -4, "Tag1", -4 }, + { -3, "Tag1", -3 }, + { -2, "Tag1", -2 }, + { -1, "Tag1", -1 }, + { -8, "Tag1", -8 }, + { -12, "Tag1", -12 } + }); + + migrationBuilder.CreateIndex( + name: "IX_Images_PetId", + table: "Images", + column: "PetId"); + + migrationBuilder.CreateIndex( + name: "IX_Pets_CategoryId", + table: "Pets", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Tags_PetId", + table: "Tags", + column: "PetId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Images"); + + migrationBuilder.DropTable( + name: "Tags"); + + migrationBuilder.DropTable( + name: "Pets"); + + migrationBuilder.DropTable( + name: "Categories"); + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Migrations/BasicApiContextModelSnapshot.cs b/src/Mvc/benchmarkapps/BasicApi/Migrations/BasicApiContextModelSnapshot.cs new file mode 100644 index 0000000000..95a4d584ed --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Migrations/BasicApiContextModelSnapshot.cs @@ -0,0 +1,170 @@ +// +using BasicApi.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace BasicApi.Migrations +{ + [DbContext(typeof(BasicApiContext))] + partial class BasicApiContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "2.1.0-rtm-30799") + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("BasicApi.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Name"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + + b.HasData( + new { Id = -1, Name = "Dogs" }, + new { Id = -2, Name = "Cats" }, + new { Id = -3, Name = "Rabbits" }, + new { Id = -4, Name = "Lions" } + ); + }); + + modelBuilder.Entity("BasicApi.Models.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("PetId"); + + b.Property("Url"); + + b.HasKey("Id"); + + b.HasIndex("PetId"); + + b.ToTable("Images"); + + b.HasData( + new { Id = -1, PetId = -1, Url = "http://example.com/pets/-1_1.png" }, + new { Id = -2, PetId = -2, Url = "http://example.com/pets/-2_1.png" }, + new { Id = -3, PetId = -3, Url = "http://example.com/pets/-3_1.png" }, + new { Id = -4, PetId = -4, Url = "http://example.com/pets/-4_1.png" }, + new { Id = -5, PetId = -5, Url = "http://example.com/pets/-5_1.png" }, + new { Id = -6, PetId = -6, Url = "http://example.com/pets/-6_1.png" }, + new { Id = -7, PetId = -7, Url = "http://example.com/pets/-7_1.png" }, + new { Id = -8, PetId = -8, Url = "http://example.com/pets/-8_1.png" }, + new { Id = -9, PetId = -9, Url = "http://example.com/pets/-9_1.png" }, + new { Id = -10, PetId = -10, Url = "http://example.com/pets/-10_1.png" }, + new { Id = -11, PetId = -11, Url = "http://example.com/pets/-11_1.png" }, + new { Id = -12, PetId = -12, Url = "http://example.com/pets/-12_1.png" } + ); + }); + + modelBuilder.Entity("BasicApi.Models.Pet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Age"); + + b.Property("CategoryId"); + + b.Property("HasVaccinations"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50); + + b.Property("Status") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Pets"); + + b.HasData( + new { Id = -1, Age = 1, CategoryId = -1, HasVaccinations = true, Name = "Dogs1", Status = "available" }, + new { Id = -2, Age = 1, CategoryId = -1, HasVaccinations = true, Name = "Dogs2", Status = "available" }, + new { Id = -3, Age = 1, CategoryId = -1, HasVaccinations = true, Name = "Dogs3", Status = "available" }, + new { Id = -4, Age = 1, CategoryId = -2, HasVaccinations = true, Name = "Cats1", Status = "available" }, + new { Id = -5, Age = 1, CategoryId = -2, HasVaccinations = true, Name = "Cats2", Status = "available" }, + new { Id = -6, Age = 1, CategoryId = -2, HasVaccinations = true, Name = "Cats3", Status = "available" }, + new { Id = -7, Age = 1, CategoryId = -3, HasVaccinations = true, Name = "Rabbits1", Status = "available" }, + new { Id = -8, Age = 1, CategoryId = -3, HasVaccinations = true, Name = "Rabbits2", Status = "available" }, + new { Id = -9, Age = 1, CategoryId = -3, HasVaccinations = true, Name = "Rabbits3", Status = "available" }, + new { Id = -10, Age = 1, CategoryId = -4, HasVaccinations = true, Name = "Lions1", Status = "available" }, + new { Id = -11, Age = 1, CategoryId = -4, HasVaccinations = true, Name = "Lions2", Status = "available" }, + new { Id = -12, Age = 1, CategoryId = -4, HasVaccinations = true, Name = "Lions3", Status = "available" } + ); + }); + + modelBuilder.Entity("BasicApi.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Name"); + + b.Property("PetId"); + + b.HasKey("Id"); + + b.HasIndex("PetId"); + + b.ToTable("Tags"); + + b.HasData( + new { Id = -1, Name = "Tag1", PetId = -1 }, + new { Id = -2, Name = "Tag1", PetId = -2 }, + new { Id = -3, Name = "Tag1", PetId = -3 }, + new { Id = -4, Name = "Tag1", PetId = -4 }, + new { Id = -5, Name = "Tag1", PetId = -5 }, + new { Id = -6, Name = "Tag1", PetId = -6 }, + new { Id = -7, Name = "Tag1", PetId = -7 }, + new { Id = -8, Name = "Tag1", PetId = -8 }, + new { Id = -9, Name = "Tag1", PetId = -9 }, + new { Id = -10, Name = "Tag1", PetId = -10 }, + new { Id = -11, Name = "Tag1", PetId = -11 }, + new { Id = -12, Name = "Tag1", PetId = -12 } + ); + }); + + modelBuilder.Entity("BasicApi.Models.Image", b => + { + b.HasOne("BasicApi.Models.Pet") + .WithMany("Images") + .HasForeignKey("PetId"); + }); + + modelBuilder.Entity("BasicApi.Models.Pet", b => + { + b.HasOne("BasicApi.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + }); + + modelBuilder.Entity("BasicApi.Models.Tag", b => + { + b.HasOne("BasicApi.Models.Pet") + .WithMany("Tags") + .HasForeignKey("PetId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Models/BasicApiContext.cs b/src/Mvc/benchmarkapps/BasicApi/Models/BasicApiContext.cs new file mode 100644 index 0000000000..35eb47619f --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Models/BasicApiContext.cs @@ -0,0 +1,190 @@ +// 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.EntityFrameworkCore; + +namespace BasicApi.Models +{ + public class BasicApiContext : DbContext + { + public BasicApiContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Categories { get; set; } + + public DbSet Images { get; set; } + + public DbSet Pets { get; set; } + + public DbSet Tags { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + var id = -1; + var categories = new[] + { + new Category { Id = id--, Name = "Dogs" }, + new Category { Id = id--, Name = "Cats" }, + new Category { Id = id--, Name = "Rabbits" }, + new Category { Id = id, Name = "Lions" }, + }; + + id = -1; + var categoryId = -1; + var pets = new[] + { + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id--, + Name = "Dogs1", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id--, + Name = "Dogs2", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId--, + HasVaccinations = true, + Id = id--, + Name = "Dogs3", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id--, + Name = "Cats1", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id--, + Name = "Cats2", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId--, + HasVaccinations = true, + Id = id--, + Name = "Cats3", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id--, + Name = "Rabbits1", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id--, + Name = "Rabbits2", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId--, + HasVaccinations = true, + Id = id--, + Name = "Rabbits3", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id--, + Name = "Lions1", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id--, + Name = "Lions2", + Status = "available", + }, + new + { + Age = 1, + CategoryId = categoryId, + HasVaccinations = true, + Id = id, + Name = "Lions3", + Status = "available", + }, + }; + + id = -1; + var images = new[] + { + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id--}_1.png" }, + new { Id = id, PetId = id, Url = $"http://example.com/pets/{id}_1.png" }, + }; + + id = -1; + var tags = new[] + { + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id--, Name = "Tag1" }, + new { Id = id, PetId = id, Name = "Tag1" }, + }; + + modelBuilder.Entity().HasData(categories); + modelBuilder.Entity().HasData(pets); + modelBuilder.Entity().HasData(images); + modelBuilder.Entity().HasData(tags); + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Models/Category.cs b/src/Mvc/benchmarkapps/BasicApi/Models/Category.cs new file mode 100644 index 0000000000..f718410fcf --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Models/Category.cs @@ -0,0 +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. + +namespace BasicApi.Models +{ + public class Category + { + public int Id { get; set; } + + public string Name { get; set; } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Models/Image.cs b/src/Mvc/benchmarkapps/BasicApi/Models/Image.cs new file mode 100644 index 0000000000..a5497539bb --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Models/Image.cs @@ -0,0 +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. + +namespace BasicApi.Models +{ + public class Image + { + public int Id { get; set; } + + public string Url { get; set; } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Models/Pet.cs b/src/Mvc/benchmarkapps/BasicApi/Models/Pet.cs new file mode 100644 index 0000000000..8ca2a6f1ca --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Models/Pet.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 System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace BasicApi.Models +{ + public class Pet + { + public int Id { get; set; } + + [Range(0, 150)] + public int Age { get; set; } + + public Category Category { get; set; } + + public bool HasVaccinations { get; set; } + + [Required] + [StringLength(50, MinimumLength = 2)] + public string Name { get; set; } + + public List Images { get; set; } + + public List Tags { get; set; } + + [Required] + public string Status { get; set; } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Models/Tag.cs b/src/Mvc/benchmarkapps/BasicApi/Models/Tag.cs new file mode 100644 index 0000000000..1875b57cce --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Models/Tag.cs @@ -0,0 +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. + +namespace BasicApi.Models +{ + public class Tag + { + public int Id { get; set; } + + public string Name { get; set; } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/Startup.cs b/src/Mvc/benchmarkapps/BasicApi/Startup.cs new file mode 100644 index 0000000000..52fa10105a --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/Startup.cs @@ -0,0 +1,246 @@ +// 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; +#if GENERATE_SQL_SCRIPTS +using System.Linq; +#endif +using System.Security.Cryptography; +using BasicApi.Models; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json.Serialization; +using Npgsql; + +namespace BasicApi +{ + public class Startup + { + private bool _isSQLite; + + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + var rsa = new RSACryptoServiceProvider(2048); + var key = new RsaSecurityKey(rsa.ExportParameters(true)); + + services.AddSingleton(new SigningCredentials( + key, + SecurityAlgorithms.RsaSha256Signature)); + + services.AddAuthentication().AddJwtBearer(options => + { + options.TokenValidationParameters.IssuerSigningKey = key; + options.TokenValidationParameters.ValidAudience = "Myself"; + options.TokenValidationParameters.ValidIssuer = "BasicApi"; + }); + + var connectionString = Configuration["ConnectionString"]; + var databaseType = Configuration["Database"]; + if (string.IsNullOrEmpty(databaseType)) + { + // Use SQLite when running outside a benchmark test or if benchmarks user specified "None". + // ("None" is not passed to the web application.) + databaseType = "SQLite"; + } + else if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentException("Connection string must be specified for {databaseType}."); + } + + switch (databaseType.ToUpper()) + { +#if !NET461 + case "MYSQL": + services + .AddEntityFrameworkMySql() + .AddDbContextPool(options => options.UseMySql(connectionString)); + break; +#endif + + case "POSTGRESQL": + var settings = new NpgsqlConnectionStringBuilder(connectionString); + if (!settings.NoResetOnClose) + { + throw new ArgumentException("No Reset On Close=true must be specified for Npgsql."); + } + if (settings.Enlist) + { + throw new ArgumentException("Enlist=false must be specified for Npgsql."); + } + + services + .AddEntityFrameworkNpgsql() + .AddDbContextPool(options => options.UseNpgsql(connectionString)); + break; + + case "SQLITE": + _isSQLite = true; + services + .AddEntityFrameworkSqlite() + .AddDbContextPool(options => options.UseSqlite("Data Source=BasicApi.db")); + break; + + case "SQLSERVER": + services + .AddEntityFrameworkSqlServer() + .AddDbContextPool(options => options.UseSqlServer(connectionString)); + break; + + default: + throw new ArgumentException($"Application does not support database type {databaseType}."); + } + + services.AddAuthorization(options => + { + options.AddPolicy( + "pet-store-reader", + builder => builder + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .RequireClaim("scope", "pet-store-reader")); + + options.AddPolicy( + "pet-store-writer", + builder => builder + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .RequireClaim("scope", "pet-store-writer")); + }); + + services + .AddMvcCore() + .AddAuthorization() + .AddJsonFormatters(json => json.ContractResolver = new CamelCasePropertyNamesContractResolver()) + .AddDataAnnotations(); + } + + public void Configure(IApplicationBuilder app, IApplicationLifetime lifetime) + { + var services = app.ApplicationServices; + CreateDatabaseTables(services); + if (_isSQLite) + { + lifetime.ApplicationStopping.Register(() => DropDatabase(services)); + } + else + { + lifetime.ApplicationStopping.Register(() => DropDatabaseTables(services)); + } + + app.Use(next => async context => + { + try + { + await next(context); + } + catch (Exception ex) + { + Console.WriteLine(ex); + throw; + } + }); + + app.UseAuthentication(); + app.UseMvc(); + } + + private void CreateDatabaseTables(IServiceProvider services) + { + using (var serviceScope = services.GetRequiredService().CreateScope()) + { + using (var dbContext = serviceScope.ServiceProvider.GetRequiredService()) + { +#if GENERATE_SQL_SCRIPTS + var migrator = dbContext.GetService(); + var script = migrator.GenerateScript( + fromMigration: Migration.InitialDatabase, + toMigration: dbContext.Database.GetMigrations().LastOrDefault()); + Console.WriteLine("Create script:"); + Console.WriteLine(script); +#endif + + dbContext.Database.Migrate(); + } + } + } + + // Don't leave SQLite's .db file behind. + public static void DropDatabase(IServiceProvider services) + { + using (var serviceScope = services.GetRequiredService().CreateScope()) + { + using (var dbContext = serviceScope.ServiceProvider.GetRequiredService()) + { +#if GENERATE_SQL_SCRIPTS + var migrator = dbContext.GetService(); + var script = migrator.GenerateScript( + fromMigration: dbContext.Database.GetAppliedMigrations().LastOrDefault(), + toMigration: Migration.InitialDatabase); + Console.WriteLine("Delete script:"); + Console.WriteLine(script); +#endif + + dbContext.Database.EnsureDeleted(); + } + } + } + + private void DropDatabaseTables(IServiceProvider services) + { + using (var serviceScope = services.GetRequiredService().CreateScope()) + { + using (var dbContext = serviceScope.ServiceProvider.GetRequiredService()) + { + var migrator = dbContext.GetService(); +#if GENERATE_SQL_SCRIPTS + var script = migrator.GenerateScript( + fromMigration: dbContext.Database.GetAppliedMigrations().LastOrDefault(), + toMigration: Migration.InitialDatabase); + Console.WriteLine("Delete script:"); + Console.WriteLine(script); +#endif + + migrator.Migrate(Migration.InitialDatabase); + } + } + } + + public static void Main(string[] args) + { + var host = CreateWebHostBuilder(args) + .Build(); + + host.Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) + { + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build(); + + return new WebHostBuilder() + .UseKestrel() + .UseUrls("http://+:5000") + .UseConfiguration(configuration) + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup(); + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicApi/benchmarks.json b/src/Mvc/benchmarkapps/BasicApi/benchmarks.json new file mode 100644 index 0000000000..aff5eb280d --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/benchmarks.json @@ -0,0 +1,54 @@ +{ + "Default": { + "Client": "Wrk", + "Headers": { + "Cache-Control": "no-cache" + }, + "PresetHeaders": "Json", + "ReadyStateText": "Application started.", + "Source": { + "BranchOrCommit": "release/2.2", + "Project": "benchmarkapps/BasicApi/BasicApi.csproj", + "Repository": "https://github.com/aspnet/mvc.git" + } + }, + "BasicApi.GetToken": { + "Path": "/token", + "PresetHeaders": "Plaintext", + "Query": "?username=reader@example.com" + }, + "BasicApi.GetUsingQueryString": { + "ClientProperties": { + "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/release/2.2/benchmarkapps/BasicApi/getWithToken.lua" + }, + "Path": "/pet/findByStatus", + "Query": "?status=available" + }, + "BasicApi.GetUsingRouteValue": { + "ClientProperties": { + "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/release/2.2/benchmarkapps/BasicApi/getWithToken.lua" + }, + "Path": "/pet/-1" + }, + "BasicApi.GetUsingRouteValueWithoutAuthorization": { + "ClientProperties": { + "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/release/2.2/benchmarkapps/BasicApi/getWithToken.lua" + }, + "Path": "/pet/anonymous/-1" + }, + "BasicApi.GetUsingRouteValueWithoutToken": { + "Path": "/pet/anonymous/-1" + }, + "BasicApi.Post": { + "ClientProperties": { + "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/release/2.2/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/src/Mvc/benchmarkapps/BasicApi/getWithToken.lua b/src/Mvc/benchmarkapps/BasicApi/getWithToken.lua new file mode 100644 index 0000000000..6bccd6e763 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/getWithToken.lua @@ -0,0 +1,51 @@ +-- script that retrieves an authentication token to send in all future requests +-- keep this file and postJsonWithToken.lua in sync with respect to token handling + +-- use token for at most maxRequests, default throughout test +local counter = 0 +local maxRequests = -1 + +-- request access necessary for both reading and writing by default +local username = "writer@example.com" + +-- marker that we have completed the first request +local token = nil + +function init(args) + if args[1] ~= nil then + maxRequests = args[1] + print("Max requests: " .. maxRequests) + end + if args[2] ~= nil then + username = args[2] + end + + local path = "/token?username=" .. username + + -- initialize first (empty) request + req = wrk.format("GET", path, nil, "") +end + +function request() + return req +end + +function response(status, headers, body) + if not token and status == 200 then + token = body + wrk.headers["Authorization"] = "Bearer " .. token + req = wrk.format() + return + end + + if not token then + print("Failed initial request! status: " .. status) + wrk.thread:stop() + end + + if counter == maxRequests then + wrk.thread:stop() + end + + counter = counter + 1 +end diff --git a/src/Mvc/benchmarkapps/BasicApi/postJsonWithToken.lua b/src/Mvc/benchmarkapps/BasicApi/postJsonWithToken.lua new file mode 100644 index 0000000000..50ce722414 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/postJsonWithToken.lua @@ -0,0 +1,83 @@ +-- script that retrieves an authentication token to send in all future requests and adds a body for those requests +-- keep this file and getWithToken.lua in sync with respect to token handling + +-- do not use wrk's default request +local req = nil + +-- use token for at most maxRequests, default throughout test +local counter = 0 +local maxRequests = -1 + +-- request access necessary for both reading and writing by default +local username = "writer@example.com" + +-- marker that we have completed the first request +local token = nil + +function init(args) + if args[1] ~= nil then + maxRequests = args[1] + print("Max requests: " .. maxRequests) + end + if args[2] ~= nil then + username = args[2] + end + + local path = "/token?username=" .. username + + -- initialize first (empty) request + req = wrk.format("GET", path, nil, "") +end + +function request() + return req +end + +function response(status, headers, body) + if not token and status == 200 then + token = body + wrk.headers["Authorization"] = "Bearer " .. token + wrk.headers["Content-Type"] = "application/json" + wrk.method = "POST" + wrk.body = [[ +{ + "category": { + "name": "Cats" + }, + "images": [ + { + "url": "http://example.com/images/fluffy1.png" + }, + { + "url": "http://example.com/images/fluffy2.png" + }, + ], + "tags": [ + { + "name": "orange" + }, + { + "name": "kitty" + } + ], + "age": 2, + "hasVaccinations": "true", + "name": "fluffy", + "status": "available" +}]] + + req = wrk.format() + return + end + + if not token then + print("Failed initial request! status: " .. status) + wrk.thread:stop() + end + + if counter == maxRequests then + wrk.thread:stop() + end + + counter = counter + 1 +end diff --git a/src/Mvc/benchmarkapps/BasicApi/runtimeconfig.template.json b/src/Mvc/benchmarkapps/BasicApi/runtimeconfig.template.json new file mode 100644 index 0000000000..0976c49b2c --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicApi/runtimeconfig.template.json @@ -0,0 +1,5 @@ +{ + "configProperties": { + "System.GC.Server": true + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/BasicViews.csproj b/src/Mvc/benchmarkapps/BasicViews/BasicViews.csproj new file mode 100644 index 0000000000..6e62f39598 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/BasicViews.csproj @@ -0,0 +1,44 @@ + + + netcoreapp2.2 + $(TargetFrameworks);net461 + $(BenchmarksTargetFramework) + + $(DefineConstants);GENERATE_SQL_SCRIPTS + $(DefineConstants);__RemoveThisBitTo__GENERATE_SQL_SCRIPTS + + CS8002;$(WarningsNotAsErrors) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mvc/benchmarkapps/BasicViews/BasicViewsContext.cs b/src/Mvc/benchmarkapps/BasicViews/BasicViewsContext.cs new file mode 100644 index 0000000000..1380f5d7a6 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/BasicViewsContext.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.EntityFrameworkCore; + +namespace BasicViews +{ + public class BasicViewsContext : DbContext + { + public BasicViewsContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet People { get; set; } + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/Components/CurrentUser.cs b/src/Mvc/benchmarkapps/BasicViews/Components/CurrentUser.cs new file mode 100644 index 0000000000..2763be9178 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Components/CurrentUser.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 Microsoft.AspNetCore.Mvc; + +namespace BasicViews.Components +{ + public class CurrentUser : ViewComponent + { + private static readonly string[] Names = { "Curly", "Curly Joe", "Joe", "Larry", "Moe", "Shemp" }; + private static int index = 0; + + public string Invoke() + { + index = index++ / Names.Length; + return Names[index]; + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/Controllers/HomeController.cs b/src/Mvc/benchmarkapps/BasicViews/Controllers/HomeController.cs new file mode 100644 index 0000000000..afb583a171 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Controllers/HomeController.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.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace BasicViews.Controllers +{ + public class HomeController : Controller + { + private readonly BasicViewsContext _context; + + public HomeController(BasicViewsContext context) + { + _context = context; + } + + [HttpGet] + public IActionResult Index() + { + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Index(Person person) + { + if (ModelState.IsValid) + { + _context.Add(person); + await _context.SaveChangesAsync(); + } + + return View(person); + } + + [HttpGet] + public IActionResult IndexWithoutToken() + { + return View(viewName: nameof(Index)); + } + + [HttpPost] + [IgnoreAntiforgeryToken] + public async Task IndexWithoutToken(Person person) + { + if (ModelState.IsValid) + { + _context.Add(person); + await _context.SaveChangesAsync(); + } + + return View(viewName: nameof(Index), model: person); + } + + [HttpGet] + public IActionResult HtmlHelpers() + { + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task HtmlHelpers(Person person) + { + if (ModelState.IsValid) + { + _context.Add(person); + await _context.SaveChangesAsync(); + } + + return View(person); + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/Migrations/20180609000611_InitialCreate.Designer.cs b/src/Mvc/benchmarkapps/BasicViews/Migrations/20180609000611_InitialCreate.Designer.cs new file mode 100644 index 0000000000..61d6e7f3c4 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Migrations/20180609000611_InitialCreate.Designer.cs @@ -0,0 +1,44 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace BasicViews.Migrations +{ + [DbContext(typeof(BasicViewsContext))] + [Migration("20180609000611_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "2.1.0-rtm-30799") + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("BasicViews.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Age"); + + b.Property("BirthDate"); + + b.Property("Name") + .HasMaxLength(27); + + b.HasKey("Id"); + + b.ToTable("People"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/Migrations/20180609000611_InitialCreate.cs b/src/Mvc/benchmarkapps/BasicViews/Migrations/20180609000611_InitialCreate.cs new file mode 100644 index 0000000000..9eba030339 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Migrations/20180609000611_InitialCreate.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace BasicViews.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "People", + columns: table => new + { + Id = table.Column(nullable: false) +#if !NET461 + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn) +#endif + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .Annotation("Sqlite:Autoincrement", true) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + Name = table.Column(maxLength: 27, nullable: true), + Age = table.Column(nullable: false), + BirthDate = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_People", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "People"); + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/Migrations/BasicViewsContextModelSnapshot.cs b/src/Mvc/benchmarkapps/BasicViews/Migrations/BasicViewsContextModelSnapshot.cs new file mode 100644 index 0000000000..4b3deaf71b --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Migrations/BasicViewsContextModelSnapshot.cs @@ -0,0 +1,42 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace BasicViews.Migrations +{ + [DbContext(typeof(BasicViewsContext))] + partial class BasicViewsContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "2.1.0-rtm-30799") + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("BasicViews.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Age"); + + b.Property("BirthDate"); + + b.Property("Name") + .HasMaxLength(27); + + b.HasKey("Id"); + + b.ToTable("People"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/Person.cs b/src/Mvc/benchmarkapps/BasicViews/Person.cs new file mode 100644 index 0000000000..f2118d36be --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Person.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; + +namespace BasicViews +{ + public class Person + { + public int Id { get; set; } + + [StringLength(27, MinimumLength = 2)] + public string Name { get; set; } + + [Range(10, 54)] + public int Age { get; set; } + + public DateTimeOffset BirthDate { get; set; } + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/Startup.cs b/src/Mvc/benchmarkapps/BasicViews/Startup.cs new file mode 100644 index 0000000000..8d24470a28 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Startup.cs @@ -0,0 +1,207 @@ +// 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; +#if GENERATE_SQL_SCRIPTS +using System.Linq; +#endif +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace BasicViews +{ + public class Startup + { + private bool _isSQLite; + + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + var connectionString = Configuration["ConnectionString"]; + var databaseType = Configuration["Database"]; + if (string.IsNullOrEmpty(databaseType)) + { + // Use SQLite when running outside a benchmark test or if benchmarks user specified "None". + // ("None" is not passed to the web application.) + databaseType = "SQLite"; + } + else if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentException("Connection string must be specified for {databaseType}."); + } + + switch (databaseType.ToUpper()) + { +#if !NET461 + case "MYSQL": + services + .AddEntityFrameworkMySql() + .AddDbContextPool(options => options.UseMySql(connectionString)); + break; +#endif + + case "POSTGRESQL": + var settings = new NpgsqlConnectionStringBuilder(connectionString); + if (!settings.NoResetOnClose) + { + throw new ArgumentException("No Reset On Close=true must be specified for Npgsql."); + } + if (settings.Enlist) + { + throw new ArgumentException("Enlist=false must be specified for Npgsql."); + } + + services + .AddEntityFrameworkNpgsql() + .AddDbContextPool(options => options.UseNpgsql(connectionString)); + break; + + case "SQLITE": + _isSQLite = true; + services + .AddEntityFrameworkSqlite() + .AddDbContextPool(options => options.UseSqlite("Data Source=BasicViews.db")); + break; + + case "SQLSERVER": + services + .AddEntityFrameworkSqlServer() + .AddDbContextPool(options => options.UseSqlServer(connectionString)); + break; + + default: + throw new ArgumentException($"Application does not support database type {databaseType}."); + } + + services.AddMvc(); + } + + public void Configure(IApplicationBuilder app, IApplicationLifetime lifetime) + { + var services = app.ApplicationServices; + CreateDatabaseTables(services); + if (_isSQLite) + { + lifetime.ApplicationStopping.Register(() => DropDatabase(services)); + } + else + { + lifetime.ApplicationStopping.Register(() => DropDatabaseTables(services)); + } + + app.Use(next => async context => + { + try + { + await next(context); + } + catch (Exception ex) + { + Console.WriteLine(ex); + throw; + } + }); + + app.UseStaticFiles(); + app.UseMvcWithDefaultRoute(); + } + + private void CreateDatabaseTables(IServiceProvider services) + { + using (var serviceScope = services.GetRequiredService().CreateScope()) + { + using (var dbContext = serviceScope.ServiceProvider.GetRequiredService()) + { +#if GENERATE_SQL_SCRIPTS + var migrator = dbContext.GetService(); + var script = migrator.GenerateScript( + fromMigration: Migration.InitialDatabase, + toMigration: dbContext.Database.GetMigrations().LastOrDefault()); + Console.WriteLine("Create script:"); + Console.WriteLine(script); +#endif + + dbContext.Database.Migrate(); + } + } + } + + // Don't leave SQLite's .db file behind. + public static void DropDatabase(IServiceProvider services) + { + using (var serviceScope = services.GetRequiredService().CreateScope()) + { + using (var dbContext = serviceScope.ServiceProvider.GetRequiredService()) + { +#if GENERATE_SQL_SCRIPTS + var migrator = dbContext.GetService(); + var script = migrator.GenerateScript( + fromMigration: dbContext.Database.GetAppliedMigrations().LastOrDefault(), + toMigration: Migration.InitialDatabase); + Console.WriteLine("Delete script:"); + Console.WriteLine(script); +#endif + + dbContext.Database.EnsureDeleted(); + } + } + } + + private void DropDatabaseTables(IServiceProvider services) + { + using (var serviceScope = services.GetRequiredService().CreateScope()) + { + using (var dbContext = serviceScope.ServiceProvider.GetRequiredService()) + { + var migrator = dbContext.GetService(); +#if GENERATE_SQL_SCRIPTS + var script = migrator.GenerateScript( + fromMigration: dbContext.Database.GetAppliedMigrations().LastOrDefault(), + toMigration: Migration.InitialDatabase); + Console.WriteLine("Delete script:"); + Console.WriteLine(script); +#endif + + migrator.Migrate(Migration.InitialDatabase); + } + } + } + + public static void Main(string[] args) + { + var host = CreateWebHostBuilder(args) + .Build(); + + host.Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) + { + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build(); + + return new WebHostBuilder() + .UseKestrel() + .UseUrls("http://+:5000") + .UseConfiguration(configuration) + .UseIISIntegration() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup(); + } + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/Views/Home/HtmlHelpers.cshtml b/src/Mvc/benchmarkapps/BasicViews/Views/Home/HtmlHelpers.cshtml new file mode 100644 index 0000000000..971bb99626 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Views/Home/HtmlHelpers.cshtml @@ -0,0 +1,23 @@ +@using BasicViews +@model Person + +@Html.ValidationSummary() + +@using (Html.BeginForm()) +{ +
+ @Html.LabelFor(p => p.Name) + @Html.EditorFor(p => p.Name) +
+
+ @Html.LabelFor(p => p.Age) + @Html.EditorFor(p => p.Age) +
+
+ @Html.LabelFor(p => p.BirthDate) + @Html.EditorFor(p => p.BirthDate) +
+ + + @Html.AntiForgeryToken() +} \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/BasicViews/Views/Home/Index.cshtml b/src/Mvc/benchmarkapps/BasicViews/Views/Home/Index.cshtml new file mode 100644 index 0000000000..e0720d852a --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Views/Home/Index.cshtml @@ -0,0 +1,21 @@ +@using BasicViews +@model Person + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
\ No newline at end of file diff --git a/src/Mvc/benchmarkapps/BasicViews/Views/Shared/_Layout.cshtml b/src/Mvc/benchmarkapps/BasicViews/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000000..34517d4aa0 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Views/Shared/_Layout.cshtml @@ -0,0 +1,55 @@ + + + + + + MVC with views + + + + + + + + + + +
+

Hello @await Component.InvokeAsync("CurrentUser")!

+ +
+ @RenderBody() +
+
+ + + + + + + + + @RenderSection("scripts", required: false) + + diff --git a/src/Mvc/benchmarkapps/BasicViews/Views/_ViewImports.cshtml b/src/Mvc/benchmarkapps/BasicViews/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..9018c7897f --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/BasicViews/Views/_ViewStart.cshtml b/src/Mvc/benchmarkapps/BasicViews/Views/_ViewStart.cshtml new file mode 100644 index 0000000000..a5f10045db --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/Mvc/benchmarkapps/BasicViews/benchmarks.json b/src/Mvc/benchmarkapps/BasicViews/benchmarks.json new file mode 100644 index 0000000000..6a50d9386d --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/benchmarks.json @@ -0,0 +1,39 @@ +{ + "Default": { + "Client": "Wrk", + "Headers": { + "Cache-Control": "no-cache" + }, + "PresetHeaders": "Html", + "ReadyStateText": "Application started.", + "Source": { + "BranchOrCommit": "release/2.2", + "Project": "benchmarkapps/BasicViews/BasicViews.csproj", + "Repository": "https://github.com/aspnet/mvc.git" + } + }, + "BasicViews.GetHtmlHelpers": { + "Path": "/Home/HtmlHelpers" + }, + "BasicViews.GetTagHelpers": { + "Path": "/Home/Index" + }, + "BasicViews.Post": { + "ClientProperties": { + "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/release/2.2/benchmarkapps/BasicViews/postWithToken.lua" + }, + "Path": "/Home/Index" + }, + "BasicViews.PostIgnoringToken": { + "ClientProperties": { + "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/release/2.2/benchmarkapps/BasicViews/postWithToken.lua" + }, + "Path": "/Home/IndexWithoutToken" + }, + "BasicViews.PostWithoutToken": { + "ClientProperties": { + "Scripts": "https://raw.githubusercontent.com/aspnet/Mvc/release/2.2/benchmarkapps/BasicViews/post.lua" + }, + "Path": "/Home/IndexWithoutToken" + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/post.lua b/src/Mvc/benchmarkapps/BasicViews/post.lua new file mode 100644 index 0000000000..9a7640ee6d --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/post.lua @@ -0,0 +1,7 @@ +-- script that POSTs body for requests + +function init(args) + wrk.body = "Age=12&BirthDate=2006-03-01T09%3A51%3A43.041-07%3A00&Name=George" + wrk.headers["Content-Type"] = "application/x-www-form-urlencoded" + wrk.method = "POST" +end diff --git a/src/Mvc/benchmarkapps/BasicViews/postWithToken.lua b/src/Mvc/benchmarkapps/BasicViews/postWithToken.lua new file mode 100644 index 0000000000..a67309c671 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/postWithToken.lua @@ -0,0 +1,55 @@ +-- script that retrieves an antiforgery token to send in all future requests and adds a body for those requests + +-- do not use wrk's default request +local req = nil + +-- use token for at most maxRequests, default throughout test +local counter = 0 +local maxRequests = -1 + +-- marker that we have completed the first request +local token = nil + +function init(args) + -- initialize first (empty) request + req = wrk.format("GET") +end + +function request() + return req +end + +function response(status, headers, body) + if not token and status == 200 then + local cookie = string.gsub(headers["Set-Cookie"], "^([^;]*)(;.*)?$", "%1") + if not cookie or cookie == "" then + print("Unable to find antiforgery cookie in initial response!") + wrk.thread:stop() + end + + token = string.gsub(body, '^.* name="__RequestVerificationToken".* value="([^"]*)"[ >].*$', "%1") + if not token or token == "" then + print("Unable to find antiforgery token in initial response!") + wrk.thread:stop() + end + + wrk.body = "Age=12&BirthDate=2006-03-01T09%3A51%3A43.041-07%3A00&Name=George&__RequestVerificationToken=" .. token + wrk.headers["Content-Type"] = "application/x-www-form-urlencoded" + wrk.headers["Cookie"] = cookie + wrk.method = "POST" + + req = wrk.format() + return + end + + if not token then + print("Failed initial request! status: " .. status) + wrk.thread:stop() + end + + if counter == maxRequests then + wrk.thread:stop() + end + + counter = counter + 1 +end diff --git a/src/Mvc/benchmarkapps/BasicViews/runtimeconfig.template.json b/src/Mvc/benchmarkapps/BasicViews/runtimeconfig.template.json new file mode 100644 index 0000000000..0976c49b2c --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/runtimeconfig.template.json @@ -0,0 +1,5 @@ +{ + "configProperties": { + "System.GC.Server": true + } +} diff --git a/src/Mvc/benchmarkapps/BasicViews/web.config b/src/Mvc/benchmarkapps/BasicViews/web.config new file mode 100644 index 0000000000..be088b9ef4 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/web.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Mvc/benchmarkapps/BasicViews/wwwroot/css/site.css b/src/Mvc/benchmarkapps/BasicViews/wwwroot/css/site.css new file mode 100644 index 0000000000..7621c24947 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/wwwroot/css/site.css @@ -0,0 +1,6 @@ +label { + font-size: 1.2em; +} +.test-it { + float: right; +} \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/BasicViews/wwwroot/css/site.min.css b/src/Mvc/benchmarkapps/BasicViews/wwwroot/css/site.min.css new file mode 100644 index 0000000000..fda38a3c0e --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/wwwroot/css/site.min.css @@ -0,0 +1,6 @@ +label { + font-size: 1.3em; +} +.test-it { + float: right; +} \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/BasicViews/wwwroot/js/site.js b/src/Mvc/benchmarkapps/BasicViews/wwwroot/js/site.js new file mode 100644 index 0000000000..894706a762 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/wwwroot/js/site.js @@ -0,0 +1,3 @@ +console.log("Hello World"); +function test() { +} \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/BasicViews/wwwroot/js/site.min.js b/src/Mvc/benchmarkapps/BasicViews/wwwroot/js/site.min.js new file mode 100644 index 0000000000..121d23bfa6 --- /dev/null +++ b/src/Mvc/benchmarkapps/BasicViews/wwwroot/js/site.min.js @@ -0,0 +1,3 @@ +console.log("Hello Minified World"); +function test() { +} \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/README.md b/src/Mvc/benchmarkapps/README.md new file mode 100644 index 0000000000..c2e3f97a6d --- /dev/null +++ b/src/Mvc/benchmarkapps/README.md @@ -0,0 +1,15 @@ +## Purpose + +These projects assist in Benchmarking MVC. +They makes it easier to test local changes than having the App in the Benchmarks repo by letting us make changes in MVC branches and use the example commandline below to run the benchmarks against our branches. + +## Usage + +1. Push changes you would like to test to a branch on GitHub +2. Clone aspnet/benchmarks repo to your machine or install the global BenchmarksDriver tool https://www.nuget.org/packages/BenchmarksDriver/ +3. If cloned go to the BenchmarksDriver project +4. Use the following command as a guideline for running a test using your changes + +`benchmarks --server --client -j https://raw.githubusercontent.com/aspnet/MVC/{your branch}/benchmarkaps/BasicApi/BasicApi.json` + +5. For more info/commands see https://github.com/aspnet/benchmarks/blob/master/src/BenchmarksDriver/README.md diff --git a/src/Mvc/benchmarkapps/RazorRendering/Data/DataA.cs b/src/Mvc/benchmarkapps/RazorRendering/Data/DataA.cs new file mode 100644 index 0000000000..42cbf300ee --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Data/DataA.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Html; + +namespace Data +{ + public class DataA + { + public DataA(int id, HtmlString icon, HtmlString html, string name, int seconds, int max, float perHour) + { + Id = id; + Icon = icon; + Html = html; + Name = name; + Seconds = seconds; + Max = max; + PerHour = perHour; + } + + public int Id { get; } + public HtmlString Icon { get; } + public HtmlString Html { get; } + public string Name { get; } + public int Seconds { get; } + public int Max { get; } + public float PerHour { get; } + } +} diff --git a/src/Mvc/benchmarkapps/RazorRendering/Data/DataB.cs b/src/Mvc/benchmarkapps/RazorRendering/Data/DataB.cs new file mode 100644 index 0000000000..be2830e67f --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Data/DataB.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.AspNetCore.Html; + +namespace Data +{ + public class DataB + { + public DataB(int id, HtmlString icon, string name, int value, DateTimeOffset startDate, DateTimeOffset completeDate) + { + Id = id; + Icon = icon; + Name = name; + Value = value; + StartDate = startDate; + CompleteDate = completeDate; + } + + public int Id { get; } + public HtmlString Icon { get; } + public string Name { get; } + public int Value { get; } + public DateTimeOffset StartDate { get; } + public DateTimeOffset CompleteDate { get; } + } +} diff --git a/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/PageA.cshtml b/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/PageA.cshtml new file mode 100644 index 0000000000..036af8cb1f --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/PageA.cshtml @@ -0,0 +1,138 @@ +@page +@model Pages.PageA +@using static System.Convert + +@section Subcategories { + +} + +@section Tabs { + Sub cat A + Sub cat B + Sub cat C + Sub cat D +} + +@switch (Model.Value) +{ + case 0: +

@Model.Name Type A

+ break; + case 1: +

@Model.Name Type B

+ break; + case 2: +

@Model.Name Type C

+ break; +} + + +

Something Something

+
+ + + + + + @if (Model.Value3 != 0) + { + + } + + + @{ + foreach (var data in Model.Data1) + { + + + + + @if (Model.Value3 != 0) + { + + } + + + } + } +
SomethingSomething SomethingSomething
@data.Icon@data.Name@data.Html + @(new TimeSpan(0, 0, (int)data.Seconds))
+ (@data.PerHour.ToString("N2") p/h) +
+
+ + + + max + +
+
+
+ +

Something something something

+
+@{ + if (Model.Data2.Count > 0) + { + + + + + + + + + + + + + @foreach (var data in Model.Data2) + { + var StartDate = data.StartDate; + var CompleteDate = data.CompleteDate; + + + + + + + @{ + float percentage = 100f; + + var totalTime = CompleteDate.Subtract(StartDate).TotalMilliseconds; + if (totalTime > 1000) + { + percentage = 100f * (float)DateTimeOffset.UtcNow.Subtract(StartDate).TotalMilliseconds / (float)totalTime; + } + + percentage = MathF.Min(100f, MathF.Max(0f, percentage)); + + var startDate = ToInt64(StartDate.Subtract(DateTime.UnixEpoch).Ticks / (double)10000); + var endDate = ToInt64(CompleteDate.Subtract(DateTime.UnixEpoch).Ticks / (double)10000); + } + + + + + + } +
SomethingASomethingBSomethingCSomethingDSomethingESomethingF
@data.Icon@data.Name@data.Value@StartDate.ToString("dd MMM HH:mm:ss") +
+
+
+
@CompleteDate.ToString("dd MMM HH:mm:ss") + @(CompleteDate.Subtract(DateTime.UtcNow).ToString()) + +
+ + +
+
+ } + else + { + Something @Model.Name something something something something. + } + +} +
\ No newline at end of file diff --git a/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/PageA.cshtml.cs b/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/PageA.cshtml.cs new file mode 100644 index 0000000000..8f2d98dc78 --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/PageA.cshtml.cs @@ -0,0 +1,35 @@ +using Data; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Pages +{ + public class PageA : Page + { + public int Value { get; } = 0; + public int Value1 { get; } = 0; + public int Value2 { get; } = 0; + public int Value3 { get; } = 1; + public bool Condition { get; } = true; + + public string Name { get; } = "A Name"; + + public List Data1 { get; } + public List Data2 { get; } + + public PageA(List dataA, List dataB, ILogger logger) : base(logger) + { + Data1 = dataA; + Data2 = dataB; + } + + public async Task OnGetAsync() + { + PageTitle = "PageA Title"; + PageIcon = "sicon dialogue_pagea"; + await Task.Delay(0); + } + } + +} \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/_Subcategories.cshtml b/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/_Subcategories.cshtml new file mode 100644 index 0000000000..3681e4e9cd --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Pages/Category/_Subcategories.cshtml @@ -0,0 +1,12 @@ +@model Pages.Page + +
+
+
+
+
+
+
+
+
+
diff --git a/src/Mvc/benchmarkapps/RazorRendering/Pages/Page.cs b/src/Mvc/benchmarkapps/RazorRendering/Pages/Page.cs new file mode 100644 index 0000000000..e02d03b5de --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Pages/Page.cs @@ -0,0 +1,26 @@ +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Pages +{ + public class Page : PageModel + { + public ILogger Logger { get; } + + public string PageIcon { get; protected set; } + public string PageTitle { get; protected set; } + public string PageUrl { get; protected set; } + + public Page(ILogger logger) + { + Logger = logger; + } + + + public void AddErrorMessage(string message) + { + Response.Headers.Add("X-Error-Message", UrlEncoder.Default.Encode(message)); + } + } +} \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/RazorRendering/Pages/Shared/_Layout.cshtml b/src/Mvc/benchmarkapps/RazorRendering/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000000..6e3fbe8720 --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Pages/Shared/_Layout.cshtml @@ -0,0 +1,31 @@ +@model Pages.Page + +@RenderSection("Subcategories") +
+
@Model.PageTitle
+@{ + var hasTabs = IsSectionDefined("Tabs"); +} +
+ @if (hasTabs) + { +
+ @RenderSection("Tabs", required: false) +
+ } +
+
+
+
+
+ @RenderBody() +
+
+
+
+
+
+
+
+
+
diff --git a/src/Mvc/benchmarkapps/RazorRendering/Pages/_ViewImports.cshtml b/src/Mvc/benchmarkapps/RazorRendering/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..b0ad3a90b5 --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Pages/_ViewImports.cshtml @@ -0,0 +1,2 @@ + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/RazorRendering/Pages/_ViewStart.cshtml b/src/Mvc/benchmarkapps/RazorRendering/Pages/_ViewStart.cshtml new file mode 100644 index 0000000000..d641c67f33 --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/RazorRendering/RazorRendering.csproj b/src/Mvc/benchmarkapps/RazorRendering/RazorRendering.csproj new file mode 100644 index 0000000000..dc531b3fdf --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/RazorRendering.csproj @@ -0,0 +1,25 @@ + + + netcoreapp2.2 + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mvc/benchmarkapps/RazorRendering/Readme.md b/src/Mvc/benchmarkapps/RazorRendering/Readme.md new file mode 100644 index 0000000000..15640cadc8 --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Readme.md @@ -0,0 +1 @@ +Url: /Category/PageA \ No newline at end of file diff --git a/src/Mvc/benchmarkapps/RazorRendering/Startup.cs b/src/Mvc/benchmarkapps/RazorRendering/Startup.cs new file mode 100644 index 0000000000..3092f1639c --- /dev/null +++ b/src/Mvc/benchmarkapps/RazorRendering/Startup.cs @@ -0,0 +1,78 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using Data; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Html; +using Microsoft.Extensions.Configuration; +using System.IO; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddScoped>(_ => DataA); + services.AddScoped>(_ => DataB); + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + app.UseMvc(); + } + + private static List DataA = GenerateDataA(); + + private static List GenerateDataA() + { + var dataA = new List(); + + foreach (var i in Enumerable.Range(0, 100)) + { + dataA.Add(new DataA(i, new HtmlString(i.ToString()), new HtmlString(i.ToString()), i.ToString(), i, i, 60f / i)); + } + + return dataA; + } + + private static List DataB = GenerateDataB(); + + private static List GenerateDataB() + { + var utc = DateTimeOffset.UtcNow; + var dataB = new List(); + foreach (var i in Enumerable.Range(0, 100)) + { + dataB.Add(new DataB(i, new HtmlString(i.ToString()), i.ToString(), i, utc, utc)); + } + + return dataB; + } + + public static void Main(string[] args) + { + var host = CreateWebHostBuilder(args) + .Build(); + + host.Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) + { + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build(); + + return new WebHostBuilder() + .UseKestrel() + .UseUrls("http://+:5000") + .UseConfiguration(configuration) + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup(); + } +} \ No newline at end of file diff --git a/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ActionSelectorBenchmark.cs b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ActionSelectorBenchmark.cs index ec9c2c5a4b..d207316ddc 100644 --- a/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ActionSelectorBenchmark.cs +++ b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ActionSelectorBenchmark.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; @@ -105,7 +106,7 @@ namespace Microsoft.AspNetCore.Mvc.Performance var state = routeContext.RouteData.PushState(MockRouter.Instance, routeValues, null); - var actual = NaiveSelectCandiates(_actions, routeContext.RouteData.Values); + var actual = NaiveSelectCandidates(_actions, routeContext.RouteData.Values); Verify(expected, actual); state.Restore(); @@ -113,7 +114,7 @@ namespace Microsoft.AspNetCore.Mvc.Performance } // A naive implementation we can use to generate match data for inputs, and for a baseline. - private static IReadOnlyList NaiveSelectCandiates(ActionDescriptor[] actions, RouteValueDictionary routeValues) + private static IReadOnlyList NaiveSelectCandidates(ActionDescriptor[] actions, RouteValueDictionary routeValues) { var results = new List(); for (var i = 0; i < actions.Length; i++) @@ -123,8 +124,8 @@ namespace Microsoft.AspNetCore.Mvc.Performance var isMatch = true; foreach (var kvp in action.RouteValues) { - var routeValue = Convert.ToString(routeValues[kvp.Key]) ?? string.Empty; - + var routeValue = Convert.ToString(routeValues[kvp.Key], CultureInfo.InvariantCulture) ?? + string.Empty; if (string.IsNullOrEmpty(kvp.Value) && string.IsNullOrEmpty(routeValue)) { // Match @@ -156,7 +157,7 @@ namespace Microsoft.AspNetCore.Mvc.Performance var routeValues = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in new RouteValueDictionary(obj)) { - routeValues.Add(kvp.Key, Convert.ToString(kvp.Value) ?? string.Empty); + routeValues.Add(kvp.Key, Convert.ToString(kvp.Value, CultureInfo.InvariantCulture) ?? string.Empty); } return new ActionDescriptor() @@ -175,7 +176,7 @@ namespace Microsoft.AspNetCore.Mvc.Performance { var action = actions[i]; var routeValues = new RouteValueDictionary(action.RouteValues); - var matches = NaiveSelectCandiates(actions, routeValues); + var matches = NaiveSelectCandidates(actions, routeValues); if (matches.Count == 0) { throw new InvalidOperationException("This should have at least one match."); @@ -193,7 +194,7 @@ namespace Microsoft.AspNetCore.Mvc.Performance // Make one of the route values not match. routeValues[routeValues.First().Key] = ((string)routeValues.First().Value) + "fkdkfdkkf"; - var matches = NaiveSelectCandiates(actions, routeValues); + var matches = NaiveSelectCandidates(actions, routeValues); if (matches.Count != 0) { throw new InvalidOperationException("This should have 0 matches."); diff --git a/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/MvcEndpointDatasourceBenchmark.cs b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/MvcEndpointDatasourceBenchmark.cs new file mode 100644 index 0000000000..028293dd28 --- /dev/null +++ b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/MvcEndpointDatasourceBenchmark.cs @@ -0,0 +1,142 @@ +// 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 BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Microsoft.AspNetCore.Mvc.Performance +{ + public class MvcEndpointDataSourceBenchmark + { + private const string DefaultRoute = "{Controller=Home}/{Action=Index}/{id?}"; + + // Attribute routes can't have controller and action as parameters, so we edit the + // route template in the test to make it more realistic. + private const string ControllerReplacementToken = "{Controller=Home}"; + private const string ActionReplacementToken = "{Action=Index}"; + + private MockActionDescriptorCollectionProvider _conventionalActionProvider; + private MockActionDescriptorCollectionProvider _attributeActionProvider; + private List _conventionalEndpointInfos; + + [Params(1, 100, 1000)] + public int ActionCount; + + [GlobalSetup] + public void Setup() + { + _conventionalActionProvider = new MockActionDescriptorCollectionProvider( + Enumerable.Range(0, ActionCount).Select(i => CreateConventionalRoutedAction(i)).ToList() + ); + + _attributeActionProvider = new MockActionDescriptorCollectionProvider( + Enumerable.Range(0, ActionCount).Select(i => CreateAttributeRoutedAction(i)).ToList() + ); + + _conventionalEndpointInfos = new List + { + new MvcEndpointInfo( + "Default", + DefaultRoute, + new RouteValueDictionary(), + new Dictionary(), + new RouteValueDictionary(), + new MockParameterPolicyFactory()) + }; + } + + [Benchmark] + public void AttributeRouteEndpoints() + { + var endpointDataSource = CreateMvcEndpointDataSource(_attributeActionProvider); + var endpoints = endpointDataSource.Endpoints; + } + + [Benchmark] + public void ConventionalEndpoints() + { + var endpointDataSource = CreateMvcEndpointDataSource(_conventionalActionProvider); + endpointDataSource.ConventionalEndpointInfos.AddRange(_conventionalEndpointInfos); + var endpoints = endpointDataSource.Endpoints; + } + + private ActionDescriptor CreateAttributeRoutedAction(int id) + { + var routeValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Controller"] = "Controller" + id, + ["Action"] = "Index" + }; + + var template = DefaultRoute + .Replace(ControllerReplacementToken, routeValues["Controller"]) + .Replace(ActionReplacementToken, routeValues["Action"]); + + return new ActionDescriptor + { + RouteValues = routeValues, + DisplayName = "Action " + id, + AttributeRouteInfo = new AttributeRouteInfo() + { + Template = template, + } + }; + } + + private ActionDescriptor CreateConventionalRoutedAction(int id) + { + return new ActionDescriptor + { + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Controller"] = "Controller" + id, + ["Action"] = "Index" + }, + DisplayName = "Action " + id + }; + } + + private MvcEndpointDataSource CreateMvcEndpointDataSource( + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + { + var dataSource = new MvcEndpointDataSource( + actionDescriptorCollectionProvider, + new MvcEndpointInvokerFactory(new ActionInvokerFactory(Array.Empty())), + new MockParameterPolicyFactory()); + + return dataSource; + } + + private class MockActionDescriptorCollectionProvider : IActionDescriptorCollectionProvider + { + public MockActionDescriptorCollectionProvider(List actionDescriptors) + { + ActionDescriptors = new ActionDescriptorCollection(actionDescriptors, 0); + } + + public ActionDescriptorCollection ActionDescriptors { get; } + } + + private class MockParameterPolicyFactory : ParameterPolicyFactory + { + public override IParameterPolicy Create(RoutePatternParameterPart parameter, string inlineText) + { + throw new NotImplementedException(); + } + + public override IParameterPolicy Create(RoutePatternParameterPart parameter, IParameterPolicy parameterPolicy) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorBenchmarkBase.cs b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorBenchmarkBase.cs new file mode 100644 index 0000000000..cfa12d5f1b --- /dev/null +++ b/src/Mvc/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/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorByteArrayBenchmark.cs b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorByteArrayBenchmark.cs new file mode 100644 index 0000000000..8880aa5a05 --- /dev/null +++ b/src/Mvc/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/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorModelWithValidatedProperties.cs b/src/Mvc/benchmarks/Microsoft.AspNetCore.Mvc.Performance/ValidationVisitorModelWithValidatedProperties.cs new file mode 100644 index 0000000000..58c4127232 --- /dev/null +++ b/src/Mvc/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/src/Mvc/build/dependencies.props b/src/Mvc/build/dependencies.props index 3e21512352..063add026b 100644 --- a/src/Mvc/build/dependencies.props +++ b/src/Mvc/build/dependencies.props @@ -2,102 +2,115 @@ $(MSBuildAllProjects);$(MSBuildThisFileFullPath) - - - + + 0.9.9 0.10.13 - 2.1.3-rtm-15802 + 2.1.1 + 2.1.1 + 2.1.1 + 0.43.0 + 2.1.1.1 + 2.1.1 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-a-rtm-fix-wildcard-16567 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.0.0 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-a-rtm-allow-required-parameters-17081 + 2.2.0-a-rtm-allow-required-parameters-17081 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 5.2.6 + 15.6.82 2.8.0 2.8.0 + 2.2.0-rtm-35519 1.7.0 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 2.1.0 - 2.0.0 - 2.1.2 - 2.1.1 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.0.9 + 2.1.3 + 2.2.0-preview3-27014-02 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 15.6.1 - 4.7.49 + 4.10.0 2.0.3 1.0.1 + 11.0.2 4.5.0 4.5.0 + 4.3.2 4.5.1 - 0.8.0 + 0.10.0 2.3.1 - 2.4.0-beta.1.build3945 + 2.4.0 - - - - - - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.2 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.0 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.0 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - 2.1.1 - + diff --git a/src/Mvc/build/repo.props b/src/Mvc/build/repo.props index 53e1be33f0..3bd17f1b84 100644 --- a/src/Mvc/build/repo.props +++ b/src/Mvc/build/repo.props @@ -6,18 +6,20 @@ - + + Internal.AspNetCore.Universe.Lineup - 2.1.0-rc1-* + 2.2.0-* https://dotnet.myget.org/F/aspnetcore-dev/api/v3/index.json + diff --git a/src/Mvc/global.json b/src/Mvc/global.json new file mode 100644 index 0000000000..b5a5299b58 --- /dev/null +++ b/src/Mvc/global.json @@ -0,0 +1,8 @@ +{ + "sdk": { + "version": "2.2.100-preview2-009404" + }, + "msbuild-sdks": { + "Internal.AspNetCore.Sdk": "2.2.0-preview2-20181003.2" + } +} diff --git a/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj b/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj index 116e4a2f66..d9e021ff7a 100644 --- a/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj +++ b/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj @@ -1,7 +1,7 @@ - + - netcoreapp2.1 + netcoreapp2.2 $(TargetFrameworks);net461 diff --git a/src/Mvc/samples/MvcSandbox/Startup.cs b/src/Mvc/samples/MvcSandbox/Startup.cs index b963a03a6f..d9f96bf08b 100644 --- a/src/Mvc/samples/MvcSandbox/Startup.cs +++ b/src/Mvc/samples/MvcSandbox/Startup.cs @@ -14,7 +14,7 @@ namespace MvcSandbox // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddMvc().SetCompatibilityVersion(Microsoft.AspNetCore.Mvc.CompatibilityVersion.Version_2_1); + services.AddMvc().SetCompatibilityVersion(Microsoft.AspNetCore.Mvc.CompatibilityVersion.Latest); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/src/Mvc/src/Directory.Build.props b/src/Mvc/src/Directory.Build.props index 4b89a431e7..a111c45919 100644 --- a/src/Mvc/src/Directory.Build.props +++ b/src/Mvc/src/Directory.Build.props @@ -2,6 +2,6 @@ - + diff --git a/src/Mvc/src/GetDocumentInsider/AnsiConsole.cs b/src/Mvc/src/GetDocumentInsider/AnsiConsole.cs new file mode 100644 index 0000000000..306b4ff452 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/AnsiConstants.cs b/src/Mvc/src/GetDocumentInsider/AnsiConstants.cs new file mode 100644 index 0000000000..b54e15b751 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/AnsiTextWriter.cs b/src/Mvc/src/GetDocumentInsider/AnsiTextWriter.cs new file mode 100644 index 0000000000..066f711e27 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/CommandException.cs b/src/Mvc/src/GetDocumentInsider/CommandException.cs new file mode 100644 index 0000000000..c1437b038b --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandArgument.cs b/src/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandArgument.cs new file mode 100644 index 0000000000..3346ea0ecb --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandLineApplication.cs b/src/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandLineApplication.cs new file mode 100644 index 0000000000..facbb68ad0 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandLineApplicationExtensions.cs b/src/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandLineApplicationExtensions.cs new file mode 100644 index 0000000000..1c43455ee1 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandOption.cs b/src/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandOption.cs new file mode 100644 index 0000000000..5ba4b78ae3 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandOptionType.cs b/src/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandOptionType.cs new file mode 100644 index 0000000000..5f7d37f029 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandParsingException.cs b/src/Mvc/src/GetDocumentInsider/CommandLineUtils/CommandParsingException.cs new file mode 100644 index 0000000000..c735ecbf12 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/Commands/CommandBase.cs b/src/Mvc/src/GetDocumentInsider/Commands/CommandBase.cs new file mode 100644 index 0000000000..ac9a4b1a37 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/Commands/GetDocumentCommand.cs b/src/Mvc/src/GetDocumentInsider/Commands/GetDocumentCommand.cs new file mode 100644 index 0000000000..11c381aac6 --- /dev/null +++ b/src/Mvc/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 = "GenerateAsync"; + 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(), + OutputPath = _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/Mvc/src/GetDocumentInsider/Commands/GetDocumentCommandContext.cs b/src/Mvc/src/GetDocumentInsider/Commands/GetDocumentCommandContext.cs new file mode 100644 index 0000000000..0cd0bd7f57 --- /dev/null +++ b/src/Mvc/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 OutputPath { get; set; } + + public string Service { get; set; } + } +} diff --git a/src/Mvc/src/GetDocumentInsider/Commands/GetDocumentCommandWorker.cs b/src/Mvc/src/GetDocumentInsider/Commands/GetDocumentCommandWorker.cs new file mode 100644 index 0000000000..af02bd7cc3 --- /dev/null +++ b/src/Mvc/src/GetDocumentInsider/Commands/GetDocumentCommandWorker.cs @@ -0,0 +1,217 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; + +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 + { + Type serviceType = null; + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + serviceType = assembly.GetType(serviceName, throwOnError: false); + if (serviceType != null) + { + break; + } + } + + // As part of the aspnet/Mvc#8425 fix, make all warnings in this method errors unless the file already + // exists. + if (serviceType == null) + { + Reporter.WriteWarning(Resources.FormatServiceTypeNotFound(serviceName)); + return false; + } + + var method = serviceType.GetMethod(methodName, new[] { typeof(string), typeof(TextWriter) }); + if (method == null) + { + Reporter.WriteWarning(Resources.FormatMethodNotFound(methodName, serviceName)); + return false; + } + else if (!typeof(Task).IsAssignableFrom(method.ReturnType)) + { + Reporter.WriteWarning(Resources.FormatMethodReturnTypeUnsupported( + methodName, + serviceName, + method.ReturnType, + typeof(Task))); + return false; + } + + var service = services.GetService(serviceType); + if (service == null) + { + Reporter.WriteWarning(Resources.FormatServiceNotFound(serviceName)); + return false; + } + + // Create the output FileStream last to avoid corrupting an existing file or writing partial data. + var stream = new MemoryStream(); + using (var writer = new StreamWriter(stream)) + { + var resultTask = (Task)method.Invoke(service, new object[] { documentName, writer }); + if (resultTask == null) + { + Reporter.WriteWarning( + Resources.FormatMethodReturnedNull(methodName, serviceName, nameof(Task))); + return false; + } + + var finished = Task.WhenAny(resultTask, Task.Delay(TimeSpan.FromMinutes(1))); + if (!ReferenceEquals(resultTask, finished)) + { + Reporter.WriteWarning(Resources.FormatMethodTimedOut(methodName, serviceName, 1)); + return false; + } + + writer.Flush(); + stream.Position = 0L; + using (var outStream = File.Create(context.OutputPath)) + { + stream.CopyTo(outStream); + } + } + + return true; + } + catch (AggregateException ex) when (ex.InnerException != null) + { + foreach (var innerException in ex.Flatten().InnerExceptions) + { + Reporter.WriteWarning(FormatException(innerException)); + } + } + catch (Exception ex) + { + Reporter.WriteWarning(FormatException(ex)); + } + + File.Delete(context.OutputPath); + + 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/Mvc/src/GetDocumentInsider/Commands/HelpCommandBase.cs b/src/Mvc/src/GetDocumentInsider/Commands/HelpCommandBase.cs new file mode 100644 index 0000000000..55e84272ac --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/Commands/ProjectCommandBase.cs b/src/Mvc/src/GetDocumentInsider/Commands/ProjectCommandBase.cs new file mode 100644 index 0000000000..8e60d9603f --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/GetDocumentInsider.csproj b/src/Mvc/src/GetDocumentInsider/GetDocumentInsider.csproj new file mode 100644 index 0000000000..03d6440c1f --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/ProductInfo.cs b/src/Mvc/src/GetDocumentInsider/ProductInfo.cs new file mode 100644 index 0000000000..c57bc65d10 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/Program.cs b/src/Mvc/src/GetDocumentInsider/Program.cs new file mode 100644 index 0000000000..6d144dc5d5 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/Properties/Resources.Designer.cs b/src/Mvc/src/GetDocumentInsider/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..488d4ae93c --- /dev/null +++ b/src/Mvc/src/GetDocumentInsider/Properties/Resources.Designer.cs @@ -0,0 +1,324 @@ +// +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); + + /// + /// Unable to find service type '{0}' in loaded assemblies. + /// + internal static string ServiceTypeNotFound + { + get => GetString("ServiceTypeNotFound"); + } + + /// + /// Unable to find service type '{0}' in loaded assemblies. + /// + internal static string FormatServiceTypeNotFound(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("ServiceTypeNotFound"), p0); + + /// + /// Unable to find method named '{0}' in '{1}' implementation. + /// + internal static string MethodNotFound + { + get => GetString("MethodNotFound"); + } + + /// + /// Unable to find method named '{0}' in '{1}' implementation. + /// + internal static string FormatMethodNotFound(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("MethodNotFound"), p0, p1); + + /// + /// Unable to find service of type '{0}' in dependency injection container. + /// + internal static string ServiceNotFound + { + get => GetString("ServiceNotFound"); + } + + /// + /// Unable to find service of type '{0}' in dependency injection container. + /// + internal static string FormatServiceNotFound(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("ServiceNotFound"), p0); + + /// + /// Method '{0}' of service '{1}' returned null. Must return a non-null '{2}'. + /// + internal static string MethodReturnedNull + { + get => GetString("MethodReturnedNull"); + } + + /// + /// Method '{0}' of service '{1}' returned null. Must return a non-null '{2}'. + /// + internal static string FormatMethodReturnedNull(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("MethodReturnedNull"), p0, p1, p2); + + /// + /// Method '{0}' of service '{1}' has unsupported return type '{2}'. Must return a '{3}'. + /// + internal static string MethodReturnTypeUnsupported + { + get => GetString("MethodReturnTypeUnsupported"); + } + + /// + /// Method '{0}' of service '{1}' has unsupported return type '{2}'. Must return a '{3}'. + /// + internal static string FormatMethodReturnTypeUnsupported(object p0, object p1, object p2, object p3) + => string.Format(CultureInfo.CurrentCulture, GetString("MethodReturnTypeUnsupported"), p0, p1, p2, p3); + + /// + /// Method '{0}' of service '{1}' timed out. Must complete execution within {2} minute. + /// + internal static string MethodTimedOut + { + get => GetString("MethodTimedOut"); + } + + /// + /// Method '{0}' of service '{1}' timed out. Must complete execution within {2} minute. + /// + internal static string FormatMethodTimedOut(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("MethodTimedOut"), 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/Mvc/src/GetDocumentInsider/Reporter.cs b/src/Mvc/src/GetDocumentInsider/Reporter.cs new file mode 100644 index 0000000000..9a589fcc67 --- /dev/null +++ b/src/Mvc/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/Mvc/src/GetDocumentInsider/Resources.resx b/src/Mvc/src/GetDocumentInsider/Resources.resx new file mode 100644 index 0000000000..facc644154 --- /dev/null +++ b/src/Mvc/src/GetDocumentInsider/Resources.resx @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + Unable to find service type '{0}' in loaded assemblies. + + + Unable to find method named '{0}' in '{1}' implementation. + + + Unable to find service of type '{0}' in dependency injection container. + + + Method '{0}' of service '{1}' returned null. Must return a non-null '{2}'. + + + Method '{0}' of service '{1}' has unsupported return type '{2}'. Must return a '{3}'. + + + Method '{0}' of service '{1}' timed out. Must complete execution within {2} minute. + + \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Abstractions/ActionDescriptor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Abstractions/ActionDescriptor.cs index a1ced7244b..276f3179f3 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Abstractions/ActionDescriptor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Abstractions/ActionDescriptor.cs @@ -36,6 +36,11 @@ namespace Microsoft.AspNetCore.Mvc.Abstractions /// public IList ActionConstraints { get; set; } + /// + /// Gets or sets the endpoint metadata for this action. + /// + public IList EndpointMetadata { get; set; } + /// /// The set of parameters associated with this action. /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ActionConstraints/ActionSelectorCandidate.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ActionConstraints/ActionSelectorCandidate.cs index c2251cd371..7cf58a4845 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ActionConstraints/ActionSelectorCandidate.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ActionConstraints/ActionSelectorCandidate.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.ActionConstraints /// /// A candidate action for action selection. /// - public struct ActionSelectorCandidate + public readonly struct ActionSelectorCandidate { /// /// Creates a new . diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ActionContext.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ActionContext.cs index f7735c1637..744a439dc7 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ActionContext.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ActionContext.cs @@ -31,15 +31,11 @@ namespace Microsoft.AspNetCore.Mvc /// The to copy. public ActionContext(ActionContext actionContext) : this( - actionContext.HttpContext, - actionContext.RouteData, - actionContext.ActionDescriptor, - actionContext.ModelState) + actionContext?.HttpContext, + actionContext?.RouteData, + actionContext?.ActionDescriptor, + actionContext?.ModelState) { - if (actionContext == null) - { - throw new ArgumentNullException(nameof(actionContext)); - } } /// @@ -136,4 +132,4 @@ namespace Microsoft.AspNetCore.Mvc get; set; } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ApiExplorer/ApiParameterDescription.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ApiExplorer/ApiParameterDescription.cs index 1f07d2b7ab..f020c6361a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ApiExplorer/ApiParameterDescription.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ApiExplorer/ApiParameterDescription.cs @@ -41,5 +41,23 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer /// Gets or sets the parameter descriptor. /// public ParameterDescriptor ParameterDescriptor { get; set; } + + /// + /// Gets or sets a value that determines if the parameter is required. + /// + /// + /// A parameter is considered required if + /// + /// it's bound from the request body (). + /// it's a required route value. + /// it has annotations (e.g. BindRequiredAttribute) that indicate it's required. + /// + /// + public bool IsRequired { get; set; } + + /// + /// Gets or sets the default value for a parameter. + /// + public object DefaultValue { get; set; } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/IUrlHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/IUrlHelper.cs index fc329aadc4..8a0a75db9e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/IUrlHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/IUrlHelper.cs @@ -1,6 +1,7 @@ // 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.Http; using Microsoft.AspNetCore.Mvc.Routing; namespace Microsoft.AspNetCore.Mvc @@ -19,14 +20,22 @@ namespace Microsoft.AspNetCore.Mvc /// Generates a URL with an absolute path for an action method, which contains the action /// name, controller name, route values, protocol to use, host name, and fragment specified by /// . Generates an absolute URL if and - /// are non-null. + /// are non-null. See the remarks section for important security information. /// /// The context object for the generated URLs for an action method. /// The generated URL. + /// + /// + /// The value of should be a trusted value. Relying on the value of the current request + /// can allow untrusted input to influence the resulting URI unless the Host header has been validated. + /// See the deployment documentation for instructions on how to properly validate the Host header in + /// your deployment environment. + /// + /// string Action(UrlActionContext actionContext); /// - /// Converts a virtual (relative) path to an application absolute path. + /// Converts a virtual (relative, starting with ~/) path to an application absolute path. /// /// /// If the specified content path does not start with the tilde (~) character, @@ -65,19 +74,36 @@ namespace Microsoft.AspNetCore.Mvc /// Generates a URL with an absolute path, which contains the route name, route values, protocol to use, host /// name, and fragment specified by . Generates an absolute URL if /// and are non-null. + /// See the remarks section for important security information. /// /// The context object for the generated URLs for a route. /// The generated URL. + /// + /// + /// The value of should be a trusted value. Relying on the value of the current request + /// can allow untrusted input to influence the resulting URI unless the Host header has been validated. + /// See the deployment documentation for instructions on how to properly validate the Host header in + /// your deployment environment. + /// + /// string RouteUrl(UrlRouteContext routeContext); /// /// Generates an absolute URL for the specified and route /// , which contains the protocol (such as "http" or "https") and host name from the - /// current request. + /// current request. See the remarks section for important security information. /// /// The name of the route that is used to generate URL. /// An object that contains route values. /// The generated absolute URL. + /// + /// + /// This method uses the value of to populate the host section of the generated URI. + /// Relying on the value of the current request can allow untrusted input to influence the resulting URI unless + /// the Host header has been validated. See the deployment documentation for instructions on how to properly + /// validate the Host header in your deployment environment. + /// + /// string Link(string routeName, object values); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Microsoft.AspNetCore.Mvc.Abstractions.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Microsoft.AspNetCore.Mvc.Abstractions.csproj index 4ebbaeec61..34f0f5e6a1 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Microsoft.AspNetCore.Mvc.Abstractions.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Microsoft.AspNetCore.Mvc.Abstractions.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC abstractions and interfaces for action invocation and dispatching, authorization, action filters, formatters, model binding, routing, validation, and more. @@ -12,11 +12,12 @@ Microsoft.AspNetCore.Mvc.IActionResult - - - - + + + + + diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/EnumGroupAndName.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/EnumGroupAndName.cs index 0ad0117e5d..d4a141ed00 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/EnumGroupAndName.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/EnumGroupAndName.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// /// An abstraction used when grouping enum values for . /// - public struct EnumGroupAndName + public readonly struct EnumGroupAndName { private readonly Func _name; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/ModelBindingMessageProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/ModelBindingMessageProvider.cs index 5ab37cfd8b..c91bfe4cb0 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/ModelBindingMessageProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/ModelBindingMessageProvider.cs @@ -14,7 +14,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// Error message the model binding system adds when a property with an associated /// BindRequiredAttribute is not bound. /// - /// Default is "A value for the '{0}' property was not provided.". + /// + /// Default is "A value for the '{0}' parameter or property was not provided.". + /// public virtual Func MissingBindRequiredValueAccessor { get; } /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/ModelMetadataIdentity.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/ModelMetadataIdentity.cs index 913576e6a8..655bd9cb74 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/ModelMetadataIdentity.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/ModelMetadataIdentity.cs @@ -11,8 +11,20 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// /// A key type which identifies a . /// - public struct ModelMetadataIdentity : IEquatable + public readonly struct ModelMetadataIdentity : IEquatable { + private ModelMetadataIdentity( + Type modelType, + string name = null, + Type containerType = null, + ParameterInfo parameterInfo = null) + { + ModelType = modelType; + Name = name; + ContainerType = containerType; + ParameterInfo = parameterInfo; + } + /// /// Creates a for the provided model . /// @@ -25,10 +37,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata throw new ArgumentNullException(nameof(modelType)); } - return new ModelMetadataIdentity() - { - ModelType = modelType, - }; + return new ModelMetadataIdentity(modelType); } /// @@ -58,12 +67,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(name)); } - return new ModelMetadataIdentity() - { - ModelType = modelType, - Name = name, - ContainerType = containerType, - }; + return new ModelMetadataIdentity(modelType, name, containerType); } /// @@ -81,7 +85,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// The . /// The model type. /// A . - private static ModelMetadataIdentity ForParameter(ParameterInfo parameter, Type modelType) + public static ModelMetadataIdentity ForParameter(ParameterInfo parameter, Type modelType) { if (parameter == null) { @@ -93,24 +97,19 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata throw new ArgumentNullException(nameof(modelType)); } - return new ModelMetadataIdentity() - { - Name = parameter.Name, - ModelType = modelType, - ParameterInfo = parameter, - }; + return new ModelMetadataIdentity(modelType, parameter.Name, parameterInfo: parameter); } /// /// Gets the defining the model property represented by the current /// instance, or null if the current instance does not represent a property. /// - public Type ContainerType { get; private set; } + public Type ContainerType { get; } /// /// Gets the represented by the current instance. /// - public Type ModelType { get; private set; } + public Type ModelType { get; } /// /// Gets a value indicating the kind of metadata represented by the current instance. @@ -138,13 +137,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// Gets the name of the current instance if it represents a parameter or property, or null if /// the current instance represents a type. /// - public string Name { get; private set; } + public string Name { get; } /// /// Gets a descriptor for the parameter, or null if this instance /// does not represent a parameter. /// - public ParameterInfo ParameterInfo { get; private set; } + public ParameterInfo ParameterInfo { get; } /// public bool Equals(ModelMetadataIdentity other) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelBindingContext.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelBindingContext.cs index 50c0149cc2..bc362837ca 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelBindingContext.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelBindingContext.cs @@ -67,6 +67,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// public abstract string ModelName { get; set; } + /// + /// Gets or sets the name of the top-level model. This is not reset to when value + /// providers have no match for that model. + /// + public string OriginalModelName { get; protected set; } + /// /// Gets or sets the used to capture values /// for properties in the object graph of the model when binding. @@ -156,7 +162,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// by caller when child binding context state should be popped off of /// the . /// - public struct NestedScope : IDisposable + public readonly struct NestedScope : IDisposable { private readonly ModelBindingContext _context; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelBindingResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelBindingResult.cs index de57b8bf88..2bfc3dd5fd 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelBindingResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelBindingResult.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// /// Contains the result of model binding. /// - public struct ModelBindingResult : IEquatable + public readonly struct ModelBindingResult : IEquatable { /// /// Creates a representing a failed model binding operation. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs index f78815a7d1..05f5f4cdfe 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs +++ b/src/Mvc/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. /// @@ -340,8 +349,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// Gets a value indicating whether is a complex type. /// /// - /// A complex type is defined as a which has a - /// that can convert from . + /// A complex type is defined as a without a that can convert + /// from . Most POCO and types are therefore complex. Most, if + /// not all, BCL value types are simple types. /// public bool IsComplexType { get; private set; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadataProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadataProvider.cs index 1b4f01cd6d..147ccc45f5 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadataProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadataProvider.cs @@ -32,5 +32,27 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// The . /// A instance describing the . public abstract ModelMetadata GetMetadataForParameter(ParameterInfo parameter); + + /// + /// Supplies metadata describing a parameter. + /// + /// The + /// The actual model type. + /// A instance describing the . + public virtual ModelMetadata GetMetadataForParameter(ParameterInfo parameter, Type modelType) + { + throw new NotSupportedException(); + } + + /// + /// Supplies metadata describing a property. + /// + /// The . + /// The actual model type. + /// A instance describing the . + public virtual ModelMetadata GetMetadataForProperty(PropertyInfo propertyInfo, Type modelType) + { + throw new NotSupportedException(); + } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelStateDictionary.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelStateDictionary.cs index 970e5b6975..ad4d64c3ae 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelStateDictionary.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelStateDictionary.cs @@ -147,7 +147,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding { get { - return ValidationState == ModelValidationState.Valid || ValidationState == ModelValidationState.Skipped; + var state = ValidationState; + return state == ModelValidationState.Valid || state == ModelValidationState.Skipped; } } @@ -1003,7 +1004,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding } } - public struct PrefixEnumerable : IEnumerable> + public readonly struct PrefixEnumerable : IEnumerable> { private readonly ModelStateDictionary _dictionary; private readonly string _prefix; @@ -1136,7 +1137,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding } } - public struct KeyEnumerable : IEnumerable + public readonly struct KeyEnumerable : IEnumerable { private readonly ModelStateDictionary _dictionary; @@ -1191,7 +1192,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding } } - public struct ValueEnumerable : IEnumerable + public readonly struct ValueEnumerable : IEnumerable { private readonly ModelStateDictionary _dictionary; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ValueProviderResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ValueProviderResult.cs index c9bf19ad4c..3a0b8e1fa8 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ValueProviderResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ValueProviderResult.cs @@ -25,7 +25,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// regardless of whether a single value or multiple values were submitted. /// /// - public struct ValueProviderResult : IEquatable, IEnumerable + public readonly struct ValueProviderResult : IEquatable, IEnumerable { private static readonly CultureInfo _invariantCulture = CultureInfo.InvariantCulture; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Properties/AssemblyInfo.cs index 3af3e8d9b2..f28aa5fed6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Properties/AssemblyInfo.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Properties/AssemblyInfo.cs @@ -3,5 +3,35 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Core, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Cors, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.DataAnnotations, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Json, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Xml, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Localization, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Razor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.TagHelpers, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Testing, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ViewFeatures, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] + [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Abstractions.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer.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.Cors.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.DataAnnotations.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Json.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Xml.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.IntegrationTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Localization.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Razor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.TagHelpers.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ViewFeatures.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Views.TestCommon, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] + [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Routing/AttributeRouteInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Routing/AttributeRouteInfo.cs index 5531b44ced..f2fc4ea77b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Routing/AttributeRouteInfo.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Abstractions/Routing/AttributeRouteInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.AspNetCore.Routing; + namespace Microsoft.AspNetCore.Mvc.Routing { /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ActionsMustNotBeAsyncVoidAnalyzer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ActionsMustNotBeAsyncVoidAnalyzer.cs deleted file mode 100644 index 90b2032abb..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ActionsMustNotBeAsyncVoidAnalyzer.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class ActionsMustNotBeAsyncVoidAnalyzer : ControllerAnalyzerBase - { - public static readonly string ReturnTypeKey = "ReturnType"; - - public ActionsMustNotBeAsyncVoidAnalyzer() - : base(DiagnosticDescriptors.MVC7003_ActionsMustNotBeAsyncVoid) - { - } - - protected override void InitializeWorker(ControllerAnalyzerContext analyzerContext) - { - analyzerContext.Context.RegisterSyntaxNodeAction(context => - { - var methodSyntax = (MethodDeclarationSyntax)context.Node; - var method = context.SemanticModel.GetDeclaredSymbol(methodSyntax, context.CancellationToken); - - if (!analyzerContext.IsControllerAction(method)) - { - return; - } - - if (!method.IsAsync || !method.ReturnsVoid) - { - return; - } - - var returnType = analyzerContext.SystemThreadingTask.ToMinimalDisplayString( - context.SemanticModel, - methodSyntax.ReturnType.SpanStart); - - var properties = ImmutableDictionary.Create(StringComparer.Ordinal) - .Add(ReturnTypeKey, returnType); - - var location = methodSyntax.ReturnType.GetLocation(); - context.ReportDiagnostic(Diagnostic.Create( - SupportedDiagnostic, - location, - properties: properties)); - - }, SyntaxKind.MethodDeclaration); - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ActionsMustNotBeAsyncVoidFixProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ActionsMustNotBeAsyncVoidFixProvider.cs deleted file mode 100644 index f36c18d0e6..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ActionsMustNotBeAsyncVoidFixProvider.cs +++ /dev/null @@ -1,58 +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.Collections.Immutable; -using System.Composition; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Editing; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - [ExportCodeFixProvider(LanguageNames.CSharp)] - [Shared] - public class ActionsMustNotBeAsyncVoidFixProvider : CodeFixProvider - { - public sealed override ImmutableArray FixableDiagnosticIds => - ImmutableArray.Create(DiagnosticDescriptors.MVC7003_ActionsMustNotBeAsyncVoid.Id); - - public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; - - public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - if (context.Diagnostics.Length == 0) - { - return; - } - - if (!context.Diagnostics[0].Properties.TryGetValue(ActionsMustNotBeAsyncVoidAnalyzer.ReturnTypeKey, out var returnTypeName)) - { - return; - } - - var rootNode = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - - const string title = "Fix async void usage."; - context.RegisterCodeFix( - CodeAction.Create( - title, - createChangedDocument: CreateChangedDocumentAsync, - equivalenceKey: title), - context.Diagnostics); - - async Task CreateChangedDocumentAsync(CancellationToken cancellationToken) - { - var returnTypeSyntax = rootNode.FindNode(context.Span); - - var editor = await DocumentEditor.CreateAsync(context.Document, cancellationToken).ConfigureAwait(false); - editor.ReplaceNode(returnTypeSyntax, SyntaxFactory.IdentifierName(returnTypeName)); - - return editor.GetChangedDocument(); - } - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsAreAttributeRoutedAnalyzer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsAreAttributeRoutedAnalyzer.cs deleted file mode 100644 index 59a8eec020..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsAreAttributeRoutedAnalyzer.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class ApiActionsAreAttributeRoutedAnalyzer : ApiControllerAnalyzerBase - { - internal const string MethodNameKey = "MethodName"; - - public ApiActionsAreAttributeRoutedAnalyzer() - : base(DiagnosticDescriptors.MVC7000_ApiActionsMustBeAttributeRouted) - { - } - - protected override void InitializeWorker(ApiControllerAnalyzerContext analyzerContext) - { - analyzerContext.Context.RegisterSymbolAction(context => - { - var method = (IMethodSymbol)context.Symbol; - - if (!analyzerContext.IsApiAction(method)) - { - return; - } - - foreach (var attribute in method.GetAttributes()) - { - if (attribute.AttributeClass.IsAssignableFrom(analyzerContext.RouteAttribute)) - { - return; - } - } - - var properties = ImmutableDictionary.Create(StringComparer.Ordinal) - .Add(MethodNameKey, method.Name); - - var location = method.Locations.Length > 0 ? method.Locations[0] : Location.None; - context.ReportDiagnostic(Diagnostic.Create( - SupportedDiagnostic, - location, - properties: properties)); - - }, SymbolKind.Method); - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsAreAttributeRoutedFixProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsAreAttributeRoutedFixProvider.cs deleted file mode 100644 index e97e593a9f..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsAreAttributeRoutedFixProvider.cs +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Immutable; -using System.Composition; -using System.Diagnostics; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Editing; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - [ExportCodeFixProvider(LanguageNames.CSharp)] - [Shared] - public class ApiActionsAreAttributeRoutedFixProvider : CodeFixProvider - { - private static readonly RouteAttributeInfo[] RouteAttributes = new[] - { - new RouteAttributeInfo("HttpGet", TypeNames.HttpGetAttribute, new[] { "Get", "Find" }), - new RouteAttributeInfo("HttpPost", TypeNames.HttpPostAttribute, new[] { "Post", "Create", "Update" }), - new RouteAttributeInfo("HttpDelete", TypeNames.HttpDeleteAttribute, new[] { "Delete", "Remove" }), - new RouteAttributeInfo("HttpPut", TypeNames.HttpPutAttribute, new[] { "Put", "Create", "Update" }), - }; - - public sealed override ImmutableArray FixableDiagnosticIds => - ImmutableArray.Create(DiagnosticDescriptors.MVC7000_ApiActionsMustBeAttributeRouted.Id); - - public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; - - public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - if (context.Diagnostics.Length == 0) - { - return; - } - - var rootNode = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - - Debug.Assert(context.Diagnostics.Length == 1); - var diagnostic = context.Diagnostics[0]; - var methodName = diagnostic.Properties[ApiActionsAreAttributeRoutedAnalyzer.MethodNameKey]; - - var matchedByKeyword = false; - foreach (var routeInfo in RouteAttributes) - { - foreach (var keyword in routeInfo.KeyWords) - { - // Determine if the method starts with a conventional key and only show relevant routes. - // For e.g. FindPetByCategory would result in HttpGet attribute. - if (methodName.StartsWith(keyword, StringComparison.Ordinal)) - { - matchedByKeyword = true; - - var title = $"Add {routeInfo.Name} attribute"; - context.RegisterCodeFix( - CodeAction.Create( - title, - createChangedDocument: cancellationToken => CreateChangedDocumentAsync(routeInfo.Type, cancellationToken), - equivalenceKey: title), - context.Diagnostics); - } - } - } - - if (!matchedByKeyword) - { - foreach (var routeInfo in RouteAttributes) - { - var title = $"Add {routeInfo.Name} attribute"; - context.RegisterCodeFix( - CodeAction.Create( - title, - createChangedDocument: cancellationToken => CreateChangedDocumentAsync(routeInfo.Type, cancellationToken), - equivalenceKey: title), - context.Diagnostics); - } - } - - async Task CreateChangedDocumentAsync(string attributeName, CancellationToken cancellationToken) - { - var methodNode = (MethodDeclarationSyntax)rootNode.FindNode(context.Span); - - var editor = await DocumentEditor.CreateAsync(context.Document, cancellationToken).ConfigureAwait(false); - var compilation = editor.SemanticModel.Compilation; - var attributeMetadata = compilation.GetTypeByMetadataName(attributeName); - var fromRouteAttribute = compilation.GetTypeByMetadataName(TypeNames.FromRouteAttribute); - - attributeName = attributeMetadata.ToMinimalDisplayString(editor.SemanticModel, methodNode.SpanStart); - - // Remove the Attribute suffix from type names e.g. "HttpGetAttribute" -> "HttpGet" - if (attributeName.EndsWith("Attribute", StringComparison.Ordinal)) - { - attributeName = attributeName.Substring(0, attributeName.Length - "Attribute".Length); - } - - var method = editor.SemanticModel.GetDeclaredSymbol(methodNode); - - var attribute = SyntaxFactory.Attribute( - SyntaxFactory.ParseName(attributeName)); - - var route = GetRoute(fromRouteAttribute, method); - if (!string.IsNullOrEmpty(route)) - { - attribute = attribute.AddArgumentListArguments( - SyntaxFactory.AttributeArgument( - SyntaxFactory.LiteralExpression( - SyntaxKind.StringLiteralExpression, - SyntaxFactory.Literal(route)))); - } - - editor.AddAttribute(methodNode, attribute); - return editor.GetChangedDocument(); - } - } - - private static string GetRoute(ITypeSymbol fromRouteAttribute, IMethodSymbol method) - { - StringBuilder routeNameBuilder = null; - - foreach (var parameter in method.Parameters) - { - if (IsIdParameter(parameter.Name) || parameter.HasAttribute(fromRouteAttribute)) - { - if (routeNameBuilder == null) - { - routeNameBuilder = new StringBuilder(parameter.Name.Length + 2); - } - else - { - routeNameBuilder.Append("/"); - } - - routeNameBuilder - .Append("{") - .Append(parameter.Name) - .Append("}"); - } - } - - return routeNameBuilder?.ToString(); - } - - private static bool IsIdParameter(string name) - { - // Check if the parameter is named "id" (e.g. int id) or ends in Id (e.g. personId) - if (name == null || name.Length < 2) - { - return false; - } - - if (string.Equals("id", name, StringComparison.Ordinal)) - { - return true; - } - - if (name.Length > 3 && name.EndsWith("Id", StringComparison.Ordinal) && char.IsLower(name[name.Length - 3])) - { - return true; - } - - return false; - } - - private struct RouteAttributeInfo - { - public RouteAttributeInfo(string name, string type, string[] keywords) - { - Name = name; - Type = type; - KeyWords = keywords; - } - - public string Name { get; } - - public string Type { get; } - - public string[] KeyWords { get; } - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer.cs deleted file mode 100644 index 67eb82bd2b..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer : ApiControllerAnalyzerBase - { - public ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer() - : base(DiagnosticDescriptors.MVC7001_ApiActionsHaveBadModelStateFilter) - { - } - - protected override void InitializeWorker(ApiControllerAnalyzerContext analyzerContext) - { - analyzerContext.Context.RegisterSyntaxNodeAction(context => - { - var methodSyntax = (MethodDeclarationSyntax)context.Node; - if (methodSyntax.Body == null) - { - // Ignore expression bodied methods. - return; - } - - var method = context.SemanticModel.GetDeclaredSymbol(methodSyntax, context.CancellationToken); - if (!analyzerContext.IsApiAction(method)) - { - return; - } - - if (method.ReturnsVoid || method.ReturnType == analyzerContext.SystemThreadingTaskOfT) - { - // Void or Task returning methods. We don't have to check anything here since we're specifically - // looking for return BadRequest(..); - return; - } - - // Only look for top level statements that look like "if (!ModelState.IsValid)" - foreach (var memberAccessSyntax in methodSyntax.Body.DescendantNodes().OfType()) - { - var ancestorIfStatement = memberAccessSyntax.FirstAncestorOrSelf(); - if (ancestorIfStatement == null) - { - // Node's not in an if statement. - continue; - } - - var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccessSyntax, context.CancellationToken); - - if (!(symbolInfo.Symbol is IPropertySymbol property) || - (property.ContainingType != analyzerContext.ModelStateDictionary) || - !string.Equals(property.Name, "IsValid", StringComparison.Ordinal) || - !IsFalseExpression(memberAccessSyntax)) - { - continue; - } - - var containingBlock = (SyntaxNode)ancestorIfStatement; - if (containingBlock.Parent.Kind() == SyntaxKind.ElseClause) - { - containingBlock = containingBlock.Parent; - } - context.ReportDiagnostic(Diagnostic.Create(SupportedDiagnostic, containingBlock.GetLocation())); - return; - } - }, SyntaxKind.MethodDeclaration); - } - - private static bool IsFalseExpression(MemberAccessExpressionSyntax memberAccessSyntax) - { - switch (memberAccessSyntax.Parent.Kind()) - { - case SyntaxKind.LogicalNotExpression: - // !ModelState.IsValid - return true; - case SyntaxKind.EqualsExpression: - var binaryExpression = (BinaryExpressionSyntax)memberAccessSyntax.Parent; - // ModelState.IsValid == false - // false == ModelState.IsValid - return binaryExpression.Left.Kind() == SyntaxKind.FalseLiteralExpression || - binaryExpression.Right.Kind() == SyntaxKind.FalseLiteralExpression; - } - - return false; - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProvider.cs deleted file mode 100644 index 3637c47217..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProvider.cs +++ /dev/null @@ -1,46 +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.Collections.Immutable; -using System.Composition; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.Editing; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - [ExportCodeFixProvider(LanguageNames.CSharp)] - [Shared] - public class ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProvider : CodeFixProvider - { - public sealed override ImmutableArray FixableDiagnosticIds => - ImmutableArray.Create(DiagnosticDescriptors.MVC7001_ApiActionsHaveBadModelStateFilter.Id); - - public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; - - public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - const string title = "Remove ModelState.IsValid check"; - var rootNode = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - - context.RegisterCodeFix( - CodeAction.Create( - title, - createChangedDocument: CreateChangedDocumentAsync, - equivalenceKey: title), - context.Diagnostics); - - async Task CreateChangedDocumentAsync(CancellationToken cancellationToken) - { - var editor = await DocumentEditor.CreateAsync(context.Document, cancellationToken).ConfigureAwait(false); - var node = rootNode.FindNode(context.Span); - editor.RemoveNode(node); - - return editor.GetChangedDocument(); - } - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsShouldUseActionResultOfTAnalyzer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsShouldUseActionResultOfTAnalyzer.cs deleted file mode 100644 index ef00b99527..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsShouldUseActionResultOfTAnalyzer.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Immutable; -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class ApiActionsShouldUseActionResultOfTAnalyzer : ApiControllerAnalyzerBase - { - public static readonly string ReturnTypeKey = "ReturnType"; - - public ApiActionsShouldUseActionResultOfTAnalyzer() - : base(DiagnosticDescriptors.MVC7002_ApiActionsShouldReturnActionResultOf) - { - } - - protected override void InitializeWorker(ApiControllerAnalyzerContext analyzerContext) - { - analyzerContext.Context.RegisterSyntaxNodeAction(context => - { - var methodSyntax = (MethodDeclarationSyntax)context.Node; - if (methodSyntax.Body == null) - { - // Ignore expression bodied methods. - } - - var method = context.SemanticModel.GetDeclaredSymbol(methodSyntax, context.CancellationToken); - if (!analyzerContext.IsApiAction(method)) - { - return; - } - - if (method.ReturnsVoid || method.ReturnType.Kind != SymbolKind.NamedType) - { - return; - } - - var declaredReturnType = method.ReturnType; - var namedReturnType = (INamedTypeSymbol)method.ReturnType; - var isTaskOActionResult = false; - if (namedReturnType.ConstructedFrom?.IsAssignableFrom(analyzerContext.SystemThreadingTaskOfT) ?? false) - { - // Unwrap Task. - isTaskOActionResult = true; - declaredReturnType = namedReturnType.TypeArguments[0]; - } - - if (!declaredReturnType.IsAssignableFrom(analyzerContext.IActionResult)) - { - // Method signature does not look like IActionResult MyAction or SomeAwaitable. - // Nothing to do here. - return; - } - - // Method returns an IActionResult. Determine if the method block returns an ObjectResult - foreach (var returnStatement in methodSyntax.DescendantNodes().OfType()) - { - var returnType = context.SemanticModel.GetTypeInfo(returnStatement.Expression, context.CancellationToken); - if (returnType.Type == null || returnType.Type.Kind == SymbolKind.ErrorType) - { - continue; - } - - ImmutableDictionary properties = null; - if (returnType.Type.IsAssignableFrom(analyzerContext.ObjectResult)) - { - // Check if the method signature looks like "return Ok(userModelInstance)". If so, we can infer the type of userModelInstance - if (returnStatement.Expression is InvocationExpressionSyntax invocation && - invocation.ArgumentList.Arguments.Count == 1) - { - var typeInfo = context.SemanticModel.GetTypeInfo(invocation.ArgumentList.Arguments[0].Expression); - var desiredReturnType = analyzerContext.ActionResultOfT.Construct(typeInfo.Type); - if (isTaskOActionResult) - { - desiredReturnType = analyzerContext.SystemThreadingTaskOfT.Construct(desiredReturnType); - } - - var desiredReturnTypeString = desiredReturnType.ToMinimalDisplayString( - context.SemanticModel, - methodSyntax.ReturnType.SpanStart); - - properties = ImmutableDictionary.Create(StringComparer.Ordinal) - .Add(ReturnTypeKey, desiredReturnTypeString); - } - - context.ReportDiagnostic(Diagnostic.Create( - SupportedDiagnostic, - methodSyntax.ReturnType.GetLocation(), - properties: properties)); - } - } - }, SyntaxKind.MethodDeclaration); - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsShouldUseActionResultOfTCodeFixProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsShouldUseActionResultOfTCodeFixProvider.cs deleted file mode 100644 index 792b47227b..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiActionsShouldUseActionResultOfTCodeFixProvider.cs +++ /dev/null @@ -1,55 +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.Collections.Immutable; -using System.Composition; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Editing; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - [ExportCodeFixProvider(LanguageNames.CSharp)] - [Shared] - public class ApiActionsShouldUseActionResultOfTCodeFixProvider : CodeFixProvider - { - public sealed override ImmutableArray FixableDiagnosticIds => - ImmutableArray.Create(DiagnosticDescriptors.MVC7002_ApiActionsShouldReturnActionResultOf.Id); - - public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; - - public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - var rootNode = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - - foreach (var diagnostic in context.Diagnostics) - { - if (diagnostic.Properties.TryGetValue("ReturnType", out var returnTypeName)) - { - - var title = $"Make return type {returnTypeName}"; - context.RegisterCodeFix( - CodeAction.Create( - title, - createChangedDocument: cancellationToken => CreateChangedDocumentAsync(returnTypeName, cancellationToken), - equivalenceKey: title), - context.Diagnostics); - } - } - - async Task CreateChangedDocumentAsync(string returnTypeName, CancellationToken cancellationToken) - { - var returnType = rootNode.FindNode(context.Span); - - var editor = await DocumentEditor.CreateAsync(context.Document, cancellationToken).ConfigureAwait(false); - editor.ReplaceNode(returnType, SyntaxFactory.IdentifierName(returnTypeName)); - - return editor.GetChangedDocument(); - } - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiControllerAnalyzerBase.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiControllerAnalyzerBase.cs deleted file mode 100644 index b70d1409de..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiControllerAnalyzerBase.cs +++ /dev/null @@ -1,39 +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.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public abstract class ApiControllerAnalyzerBase : DiagnosticAnalyzer - { - public ApiControllerAnalyzerBase(DiagnosticDescriptor diagnostic) - { - SupportedDiagnostics = ImmutableArray.Create(diagnostic); - } - - protected DiagnosticDescriptor SupportedDiagnostic => SupportedDiagnostics[0]; - - public override ImmutableArray SupportedDiagnostics { get; } - - public sealed override void Initialize(AnalysisContext context) - { - context.RegisterCompilationStartAction(compilationContext => - { - var analyzerContext = new ApiControllerAnalyzerContext(compilationContext); - - // Only do work if ApiControllerAttribute is defined. - if (analyzerContext.ApiControllerAttribute == null) - { - return; - } - - InitializeWorker(analyzerContext); - }); - } - - protected abstract void InitializeWorker(ApiControllerAnalyzerContext analyzerContext); - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiControllerAnalyzerContext.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiControllerAnalyzerContext.cs deleted file mode 100644 index 1ab36e3dac..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ApiControllerAnalyzerContext.cs +++ /dev/null @@ -1,64 +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.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public class ApiControllerAnalyzerContext - { -#pragma warning disable RS1012 // Start action has no registered actions. - public ApiControllerAnalyzerContext(CompilationStartAnalysisContext context) -#pragma warning restore RS1012 // Start action has no registered actions. - { - Context = context; - ApiControllerAttribute = context.Compilation.GetTypeByMetadataName(TypeNames.ApiControllerAttribute); - } - - public CompilationStartAnalysisContext Context { get; } - - public INamedTypeSymbol ApiControllerAttribute { get; } - - private INamedTypeSymbol _routeAttribute; - public INamedTypeSymbol RouteAttribute => GetType(TypeNames.IRouteTemplateProvider, ref _routeAttribute); - - private INamedTypeSymbol _actionResultOfT; - public INamedTypeSymbol ActionResultOfT => GetType(TypeNames.ActionResultOfT, ref _actionResultOfT); - - private INamedTypeSymbol _systemThreadingTask; - public INamedTypeSymbol SystemThreadingTask => GetType(TypeNames.Task, ref _systemThreadingTask); - - private INamedTypeSymbol _systemThreadingTaskOfT; - public INamedTypeSymbol SystemThreadingTaskOfT => GetType(TypeNames.TaskOfT, ref _systemThreadingTaskOfT); - - private INamedTypeSymbol _objectResult; - public INamedTypeSymbol ObjectResult => GetType(TypeNames.ObjectResult, ref _objectResult); - - private INamedTypeSymbol _iActionResult; - public INamedTypeSymbol IActionResult => GetType(TypeNames.IActionResult, ref _iActionResult); - - public INamedTypeSymbol _modelState; - public INamedTypeSymbol ModelStateDictionary => GetType(TypeNames.ModelStateDictionary, ref _modelState); - - public INamedTypeSymbol _nonActionAttribute; - public INamedTypeSymbol NonActionAttribute => GetType(TypeNames.NonActionAttribute, ref _nonActionAttribute); - - - private INamedTypeSymbol GetType(string name, ref INamedTypeSymbol cache) => - cache = cache ?? Context.Compilation.GetTypeByMetadataName(name); - - public bool IsApiAction(IMethodSymbol method) - { - return - method.ContainingType.HasAttribute(ApiControllerAttribute, inherit: true) && - method.DeclaredAccessibility == Accessibility.Public && - method.MethodKind == MethodKind.Ordinary && - !method.IsGenericMethod && - !method.IsAbstract && - !method.IsStatic && - !method.HasAttribute(NonActionAttribute); - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/CodeAnalysisExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/CodeAnalysisExtensions.cs deleted file mode 100644 index a8ae5b8a0f..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/CodeAnalysisExtensions.cs +++ /dev/null @@ -1,78 +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.Diagnostics; -using Microsoft.CodeAnalysis; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - internal static class CodeAnalysisExtensions - { - public static bool HasAttribute(this ITypeSymbol typeSymbol, ITypeSymbol attribute, bool inherit) - { - while (typeSymbol != null) - { - if (typeSymbol.HasAttribute(attribute)) - { - return true; - } - - typeSymbol = typeSymbol.BaseType; - } - - return false; - } - - public static bool HasAttribute(this ISymbol symbol, ITypeSymbol attribute) - { - Debug.Assert(symbol != null); - Debug.Assert(attribute != null); - - foreach (var declaredAttribute in symbol.GetAttributes()) - { - if (declaredAttribute.AttributeClass == attribute) - { - return true; - } - } - - return false; - } - - public static bool IsAssignableFrom(this ITypeSymbol source, INamedTypeSymbol target) - { - Debug.Assert(source != null); - Debug.Assert(target != null); - - if (source == target) - { - return true; - } - - if (target.TypeKind == TypeKind.Interface) - { - foreach (var @interface in source.AllInterfaces) - { - if (@interface == target) - { - return true; - } - } - - return false; - } - - do - { - if (source == target) - { - return true; - } - - source = source.BaseType; - } while (source != null); - - return false; - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ControllerAnalyzerBase.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ControllerAnalyzerBase.cs deleted file mode 100644 index 71c497fa29..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ControllerAnalyzerBase.cs +++ /dev/null @@ -1,39 +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.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public abstract class ControllerAnalyzerBase : DiagnosticAnalyzer - { - public ControllerAnalyzerBase(DiagnosticDescriptor diagnostic) - { - SupportedDiagnostics = ImmutableArray.Create(diagnostic); - } - - protected DiagnosticDescriptor SupportedDiagnostic => SupportedDiagnostics[0]; - - public override ImmutableArray SupportedDiagnostics { get; } - - public sealed override void Initialize(AnalysisContext context) - { - context.RegisterCompilationStartAction(compilationContext => - { - var analyzerContext = new ControllerAnalyzerContext(compilationContext); - - // Only do work if ControllerAttribute is defined. - if (analyzerContext.ControllerAttribute == null) - { - return; - } - - InitializeWorker(analyzerContext); - }); - } - - protected abstract void InitializeWorker(ControllerAnalyzerContext analyzerContext); - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ControllerAnalyzerContext.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ControllerAnalyzerContext.cs deleted file mode 100644 index 8a9d403a8a..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/ControllerAnalyzerContext.cs +++ /dev/null @@ -1,47 +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.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public class ControllerAnalyzerContext - { -#pragma warning disable RS1012 // Start action has no registered actions. - public ControllerAnalyzerContext(CompilationStartAnalysisContext context) -#pragma warning restore RS1012 // Start action has no registered actions. - { - Context = context; - ControllerAttribute = Context.Compilation.GetTypeByMetadataName(TypeNames.ControllerAttribute); - } - - public CompilationStartAnalysisContext Context { get; } - - public INamedTypeSymbol ControllerAttribute { get; } - - private INamedTypeSymbol _systemThreadingTask; - public INamedTypeSymbol SystemThreadingTask => GetType(TypeNames.Task, ref _systemThreadingTask); - - private INamedTypeSymbol _systemThreadingTaskOfT; - public INamedTypeSymbol SystemThreadingTaskOfT => GetType(TypeNames.TaskOfT, ref _systemThreadingTaskOfT); - - public INamedTypeSymbol _nonActionAttribute; - public INamedTypeSymbol NonActionAttribute => GetType(TypeNames.NonActionAttribute, ref _nonActionAttribute); - - private INamedTypeSymbol GetType(string name, ref INamedTypeSymbol cache) => - cache = cache ?? Context.Compilation.GetTypeByMetadataName(name); - - public bool IsControllerAction(IMethodSymbol method) - { - return - method.ContainingType.HasAttribute(ControllerAttribute, inherit: true) && - method.DeclaredAccessibility == Accessibility.Public && - method.MethodKind == MethodKind.Ordinary && - !method.IsGenericMethod && - !method.IsAbstract && - !method.IsStatic && - !method.HasAttribute(NonActionAttribute); - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/DiagnosticDescriptors.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/DiagnosticDescriptors.cs deleted file mode 100644 index 7c8100234c..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/DiagnosticDescriptors.cs +++ /dev/null @@ -1,46 +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.CodeAnalysis; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public static class DiagnosticDescriptors - { - public static readonly DiagnosticDescriptor MVC7000_ApiActionsMustBeAttributeRouted = - new DiagnosticDescriptor( - "MVC7000", - "Actions on types annotated with ApiControllerAttribute must be attribute routed.", - "Actions on types annotated with ApiControllerAttribute must be attribute routed.", - "Usage", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - public static readonly DiagnosticDescriptor MVC7001_ApiActionsHaveBadModelStateFilter = - new DiagnosticDescriptor( - "MVC7001", - "Actions on types annotated with ApiControllerAttribute do not require explicit ModelState validity check.", - "Actions on types annotated with ApiControllerAttribute do not require explicit ModelState validity check.", - "Usage", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - public static readonly DiagnosticDescriptor MVC7002_ApiActionsShouldReturnActionResultOf = - new DiagnosticDescriptor( - "MVC7002", - "Actions on types annotated with ApiControllerAttribute should return ActionResult.", - "Actions on types annotated with ApiControllerAttribute should return ActionResult.", - "Usage", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - public static readonly DiagnosticDescriptor MVC7003_ActionsMustNotBeAsyncVoid = - new DiagnosticDescriptor( - "MVC7003", - "Controller actions must not have async void signature.", - "Controller actions must not have async void signature.", - "Usage", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.csproj deleted file mode 100644 index 62ac6da6a1..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - CSharp Analyzers for ASP.NET Core MVC. - aspnetcore;aspnetcoremvc - - false - $(ExperimentalVersionPrefix) - $(ExperimentalVersionSuffix) - $(ExperimentalPackageVersion) - - netstandard1.3 - false - false - false - - - - - - - - - - - diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/TypeNames.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/TypeNames.cs deleted file mode 100644 index 810c63a7bd..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers.Experimental/TypeNames.cs +++ /dev/null @@ -1,38 +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. - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - internal static class TypeNames - { - public const string ControllerAttribute = "Microsoft.AspNetCore.Mvc.ControllerAttribute"; - - public const string ApiControllerAttribute = "Microsoft.AspNetCore.Mvc.ApiControllerAttribute"; - - public const string NonActionAttribute = "Microsoft.AspNetCore.Mvc.NonActionAttribute"; - - public const string IRouteTemplateProvider = "Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider"; - - public const string ActionResultOfT = "Microsoft.AspNetCore.Mvc.ActionResult`1"; - - public const string Task = "System.Threading.Tasks.Task"; - - public const string TaskOfT = "System.Threading.Tasks.Task`1"; - - public const string ObjectResult = "Microsoft.AspNetCore.Mvc.ObjectResult"; - - public const string IActionResult = "Microsoft.AspNetCore.Mvc.IActionResult"; - - public const string ModelStateDictionary = "Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary"; - - public const string HttpGetAttribute = "Microsoft.AspNetCore.Mvc.HttpGetAttribute"; - - public const string HttpPostAttribute = "Microsoft.AspNetCore.Mvc.HttpPostAttribute"; - - public const string HttpPutAttribute = "Microsoft.AspNetCore.Mvc.HttpPutAttribute"; - - public const string HttpDeleteAttribute = "Microsoft.AspNetCore.Mvc.HttpDeleteAttribute"; - - public const string FromRouteAttribute = "Microsoft.AspNetCore.Mvc.FromRouteAttribute"; - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/AttributesShouldNotBeAppliedToPageModelAnalyzer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/AttributesShouldNotBeAppliedToPageModelAnalyzer.cs new file mode 100644 index 0000000000..4dc7b6c85d --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/AttributesShouldNotBeAppliedToPageModelAnalyzer.cs @@ -0,0 +1,166 @@ +// 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.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class AttributesShouldNotBeAppliedToPageModelAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( + DiagnosticDescriptors.MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods, + DiagnosticDescriptors.MVC1002_RouteAttributesShouldNotBeAppliedToPageHandlerMethods, + DiagnosticDescriptors.MVC1003_RouteAttributesShouldNotBeAppliedToPageModels); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStartAnalysisContext => + { + var typeCache = new TypeCache(compilationStartAnalysisContext.Compilation); + if (typeCache.PageModelAttribute == null || typeCache.PageModelAttribute.TypeKind == TypeKind.Error) + { + // No-op if we can't find types we care about. + return; + } + + InitializeWorker(compilationStartAnalysisContext, typeCache); + }); + } + + private void InitializeWorker(CompilationStartAnalysisContext compilationStartAnalysisContext, TypeCache typeCache) + { + compilationStartAnalysisContext.RegisterSymbolAction(symbolAnalysisContext => + { + var method = (IMethodSymbol)symbolAnalysisContext.Symbol; + + var declaringType = method.ContainingType; + if (!IsPageModel(declaringType, typeCache.PageModelAttribute) || !IsPageHandlerMethod(method)) + { + return; + } + + ReportFilterDiagnostic(ref symbolAnalysisContext, method, typeCache.IFilterMetadata); + ReportFilterDiagnostic(ref symbolAnalysisContext, method, typeCache.AuthorizeAttribute); + ReportFilterDiagnostic(ref symbolAnalysisContext, method, typeCache.AllowAnonymousAttribute); + + ReportRouteDiagnostic(ref symbolAnalysisContext, method, typeCache.IRouteTemplateProvider); + }, SymbolKind.Method); + + compilationStartAnalysisContext.RegisterSymbolAction(symbolAnalysisContext => + { + var type = (INamedTypeSymbol)symbolAnalysisContext.Symbol; + if (!IsPageModel(type, typeCache.PageModelAttribute)) + { + return; + } + + ReportRouteDiagnosticOnModel(ref symbolAnalysisContext, type, typeCache.IRouteTemplateProvider); + }, SymbolKind.NamedType); + } + + private bool IsPageHandlerMethod(IMethodSymbol method) + { + return method.MethodKind == MethodKind.Ordinary && + !method.IsStatic && + !method.IsGenericMethod && + method.DeclaredAccessibility == Accessibility.Public; + } + + private static bool IsPageModel(INamedTypeSymbol type, INamedTypeSymbol pageAttributeModel) + { + return type.TypeKind == TypeKind.Class && + !type.IsStatic && + type.HasAttribute(pageAttributeModel, inherit: true); + } + + private static void ReportRouteDiagnosticOnModel(ref SymbolAnalysisContext symbolAnalysisContext, INamedTypeSymbol typeSymbol, INamedTypeSymbol routeAttribute) + { + var attribute = GetAttribute(typeSymbol, routeAttribute); + if (attribute != null) + { + var location = GetAttributeLocation(ref symbolAnalysisContext, attribute); + + symbolAnalysisContext.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MVC1003_RouteAttributesShouldNotBeAppliedToPageModels, + location, + attribute.AttributeClass.Name)); + } + } + + private static void ReportRouteDiagnostic(ref SymbolAnalysisContext symbolAnalysisContext, IMethodSymbol method, INamedTypeSymbol routeAttribute) + { + var attribute = GetAttribute(method, routeAttribute); + if (attribute != null) + { + var location = GetAttributeLocation(ref symbolAnalysisContext, attribute); + + symbolAnalysisContext.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MVC1002_RouteAttributesShouldNotBeAppliedToPageHandlerMethods, + location, + attribute.AttributeClass.Name)); + } + } + + private static void ReportFilterDiagnostic(ref SymbolAnalysisContext symbolAnalysisContext, IMethodSymbol method, INamedTypeSymbol filterAttribute) + { + var attribute = GetAttribute(method, filterAttribute); + if (attribute != null) + { + var location = GetAttributeLocation(ref symbolAnalysisContext, attribute); + + symbolAnalysisContext.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods, + location, + attribute.AttributeClass.Name)); + } + } + + private static AttributeData GetAttribute(ISymbol symbol, INamedTypeSymbol attributeType) + { + foreach (var attribute in symbol.GetAttributes()) + { + if (attributeType.IsAssignableFrom(attribute.AttributeClass)) + { + return attribute; + } + } + + return null; + } + + private static Location GetAttributeLocation(ref SymbolAnalysisContext symbolAnalysisContext, AttributeData attribute) + { + var syntax = attribute.ApplicationSyntaxReference.GetSyntax(symbolAnalysisContext.CancellationToken); + return syntax?.GetLocation() ?? Location.None; + } + + private class TypeCache + { + public TypeCache(Compilation compilation) + { + PageModelAttribute = compilation.GetTypeByMetadataName(SymbolNames.PageModelAttributeType); + IFilterMetadata = compilation.GetTypeByMetadataName(SymbolNames.IFilterMetadataType); + AuthorizeAttribute = compilation.GetTypeByMetadataName(SymbolNames.AuthorizeAttribute); + AllowAnonymousAttribute = compilation.GetTypeByMetadataName(SymbolNames.AllowAnonymousAttribute); + IRouteTemplateProvider = compilation.GetTypeByMetadataName(SymbolNames.IRouteTemplateProvider); + } + + public INamedTypeSymbol PageModelAttribute { get; } + + public INamedTypeSymbol IFilterMetadata { get; } + + public INamedTypeSymbol AuthorizeAttribute { get; } + + public INamedTypeSymbol AllowAnonymousAttribute { get; } + + public INamedTypeSymbol IRouteTemplateProvider { get; } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/CodeAnalysisExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/CodeAnalysisExtensions.cs new file mode 100644 index 0000000000..72da1b1f4c --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/CodeAnalysisExtensions.cs @@ -0,0 +1,149 @@ +// 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.Linq; + +namespace Microsoft.CodeAnalysis +{ + internal static class CodeAnalysisExtensions + { + public static bool HasAttribute(this ITypeSymbol typeSymbol, ITypeSymbol attribute, bool inherit) + => GetAttributes(typeSymbol, attribute, inherit).Any(); + + public static bool HasAttribute(this IMethodSymbol methodSymbol, ITypeSymbol attribute, bool inherit) + => GetAttributes(methodSymbol, attribute, inherit).Any(); + + public static IEnumerable GetAttributes(this ISymbol symbol, ITypeSymbol attribute) + { + foreach (var declaredAttribute in symbol.GetAttributes()) + { + if (attribute.IsAssignableFrom(declaredAttribute.AttributeClass)) + { + yield return declaredAttribute; + } + } + } + + public static IEnumerable GetAttributes(this IMethodSymbol methodSymbol, ITypeSymbol attribute, bool inherit) + { + Debug.Assert(methodSymbol != null); + Debug.Assert(attribute != null); + + while (methodSymbol != null) + { + foreach (var attributeData in GetAttributes(methodSymbol, attribute)) + { + yield return attributeData; + } + + if (!inherit) + { + break; + } + + methodSymbol = methodSymbol.IsOverride ? methodSymbol.OverriddenMethod : null; + } + } + + public static IEnumerable GetAttributes(this ITypeSymbol typeSymbol, ITypeSymbol attribute, bool inherit) + { + Debug.Assert(typeSymbol != null); + Debug.Assert(attribute != null); + + foreach (var type in GetTypeHierarchy(typeSymbol)) + { + foreach (var attributeData in GetAttributes(type, attribute)) + { + yield return attributeData; + } + + if (!inherit) + { + break; + } + } + } + + public static bool HasAttribute(this IPropertySymbol propertySymbol, ITypeSymbol attribute, bool inherit) + { + Debug.Assert(propertySymbol != null); + Debug.Assert(attribute != null); + + if (!inherit) + { + return HasAttribute(propertySymbol, attribute); + } + + while (propertySymbol != null) + { + if (propertySymbol.HasAttribute(attribute)) + { + return true; + } + + propertySymbol = propertySymbol.IsOverride ? propertySymbol.OverriddenProperty : null; + } + + return false; + } + + public static bool IsAssignableFrom(this ITypeSymbol source, ITypeSymbol target) + { + Debug.Assert(source != null); + Debug.Assert(target != null); + + if (source == target) + { + return true; + } + + if (source.TypeKind == TypeKind.Interface) + { + foreach (var @interface in target.AllInterfaces) + { + if (source == @interface) + { + return true; + } + } + + return false; + } + + foreach (var type in target.GetTypeHierarchy()) + { + if (source == type) + { + return true; + } + } + + return false; + } + + public static bool HasAttribute(this ISymbol symbol, ITypeSymbol attribute) + { + foreach (var declaredAttribute in symbol.GetAttributes()) + { + if (attribute.IsAssignableFrom(declaredAttribute.AttributeClass)) + { + return true; + } + } + + return false; + } + + private static IEnumerable GetTypeHierarchy(this ITypeSymbol typeSymbol) + { + while (typeSymbol != null) + { + yield return typeSymbol; + + typeSymbol = typeSymbol.BaseType; + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/DiagnosticDescriptors.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/DiagnosticDescriptors.cs index 89465662b4..8b0aa761d1 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/DiagnosticDescriptors.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/DiagnosticDescriptors.cs @@ -15,5 +15,42 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods = + new DiagnosticDescriptor( + "MVC1001", + "Filters cannot be applied to page handler methods.", + "'{0}' cannot be applied to Razor Page handler methods. It may be applied either to the Razor Page model or applied globally.", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MVC1002_RouteAttributesShouldNotBeAppliedToPageHandlerMethods = + new DiagnosticDescriptor( + "MVC1002", + "Route attributes cannot be applied to page handler methods.", + "'{0}' cannot be applied to Razor Page handler methods. Routes for Razor Pages must be declared using the @page directive or using conventions.", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MVC1003_RouteAttributesShouldNotBeAppliedToPageModels = + new DiagnosticDescriptor( + "MVC1003", + "Route attributes cannot be applied to page models.", + "'{0}' cannot be applied to a Razor Page model. Routes for Razor Pages must be declared using the @page directive or using conventions.", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MVC1004_ParameterNameCollidesWithTopLevelProperty = + new DiagnosticDescriptor( + "MVC1004", + "Rename model bound parameter.", + "Property on type '{0}' has the same name as parameter '{1}'. This may result in incorrect model binding. Consider renaming the parameter or using a model binding attribute to override the name.", + "Naming", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: "https://aka.ms/AA20pbc"); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/Microsoft.AspNetCore.Mvc.Analyzers.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/Microsoft.AspNetCore.Mvc.Analyzers.csproj index 310c88685a..7e796c0673 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/Microsoft.AspNetCore.Mvc.Analyzers.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/Microsoft.AspNetCore.Mvc.Analyzers.csproj @@ -1,4 +1,4 @@ - + CSharp Analyzers for ASP.NET Core MVC. aspnetcore;aspnetcoremvc @@ -14,6 +14,10 @@ + + + + diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/MvcFacts.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/MvcFacts.cs new file mode 100644 index 0000000000..fc06eda6bb --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/MvcFacts.cs @@ -0,0 +1,142 @@ +// 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 Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + internal static class MvcFacts + { + public static bool IsController(INamedTypeSymbol type, INamedTypeSymbol controllerAttribute, INamedTypeSymbol nonControllerAttribute) + { + Debug.Assert(type != null); + Debug.Assert(controllerAttribute != null); + Debug.Assert(nonControllerAttribute != null); + + if (type.TypeKind != TypeKind.Class) + { + return false; + } + + if (type.IsAbstract) + { + return false; + } + + // We only consider public top-level classes as controllers. + if (type.DeclaredAccessibility != Accessibility.Public) + { + return false; + } + + if (type.ContainingType != null) + { + return false; + } + + if (type.IsGenericType || type.IsUnboundGenericType) + { + return false; + } + + if (type.HasAttribute(nonControllerAttribute, inherit: true)) + { + return false; + } + + if (!type.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) && + !type.HasAttribute(controllerAttribute, inherit: true)) + { + return false; + } + + return true; + } + + public static bool IsControllerAction(IMethodSymbol method, INamedTypeSymbol nonActionAttribute, IMethodSymbol disposableDispose) + { + Debug.Assert(method != null); + Debug.Assert(nonActionAttribute != null); + + if (method.MethodKind != MethodKind.Ordinary) + { + return false; + } + + if (method.HasAttribute(nonActionAttribute, inherit: true)) + { + return false; + } + + // Overridden methods from Object class, e.g. Equals(Object), GetHashCode(), etc., are not valid. + if (GetDeclaringType(method).SpecialType == SpecialType.System_Object) + { + return false; + } + + if (IsIDisposableDispose(method, disposableDispose)) + { + return false; + } + + if (method.IsStatic) + { + return false; + } + + if (method.IsAbstract) + { + return false; + } + + if (method.IsGenericMethod) + { + return false; + } + + return method.DeclaredAccessibility == Accessibility.Public; + } + + private static INamedTypeSymbol GetDeclaringType(IMethodSymbol method) + { + while (method.IsOverride) + { + method = method.OverriddenMethod; + } + + return method.ContainingType; + } + + private static bool IsIDisposableDispose(IMethodSymbol method, IMethodSymbol disposableDispose) + { + if (method.Name != disposableDispose.Name) + { + return false; + } + + if (!method.ReturnsVoid) + { + return false; + } + + if (method.Parameters.Length != disposableDispose.Parameters.Length) + { + return false; + } + + // Explicit implementation + for (var i = 0; i < method.ExplicitInterfaceImplementations.Length; i++) + { + if (method.ExplicitInterfaceImplementations[i].ContainingType.SpecialType == SpecialType.System_IDisposable) + { + return true; + } + } + + var implementedMethod = method.ContainingType.FindImplementationForInterfaceMember(disposableDispose); + return implementedMethod == method; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..9b669e0fb2 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Mvc.Analyzers.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs index 9c49dd24e6..42d3b18af4 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs @@ -5,12 +5,60 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers { internal static class SymbolNames { - public const string IHtmlHelperType = "Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper"; + public const string AllowAnonymousAttribute = "Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute"; + + public const string ApiConventionMethodAttribute = "Microsoft.AspNetCore.Mvc.ApiConventionMethodAttribute"; + + public const string ApiConventionNameMatchAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorer.ApiConventionNameMatchAttribute"; + + public const string ApiConventionTypeMatchAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorer.ApiConventionTypeMatchAttribute"; + + public const string ApiConventionTypeAttribute = "Microsoft.AspNetCore.Mvc.ApiConventionTypeAttribute"; + + public const string ActionResultOfT = "Microsoft.AspNetCore.Mvc.ActionResult`1"; + + public const string AuthorizeAttribute = "Microsoft.AspNetCore.Authorization.AuthorizeAttribute"; + + public const string BindAttribute = "Microsoft.AspNetCore.Mvc.BindAttribute"; + + public const string ControllerAttribute = "Microsoft.AspNetCore.Mvc.ControllerAttribute"; + + public const string DefaultStatusCodeAttribute = "Microsoft.AspNetCore.Mvc.Infrastructure.DefaultStatusCodeAttribute"; + + public const string FromBodyAttribute = "Microsoft.AspNetCore.Mvc.FromBodyAttribute"; public const string HtmlHelperPartialExtensionsType = "Microsoft.AspNetCore.Mvc.Rendering.HtmlHelperPartialExtensions"; + public const string IApiBehaviorMetadata = "Microsoft.AspNetCore.Mvc.IApiBehaviorMetadata"; + + public const string IBinderTypeProviderMetadata = "Microsoft.AspNetCore.Mvc.ModelBinding.IBinderTypeProviderMetadata"; + + public const string IActionResult = "Microsoft.AspNetCore.Mvc.IActionResult"; + + public const string IConvertToActionResult = "Microsoft.AspNetCore.Mvc.IConvertToActionResult"; + + public const string IFilterMetadataType = "Microsoft.AspNetCore.Mvc.Filters.IFilterMetadata"; + + public const string IHtmlHelperType = "Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper"; + + public const string IModelNameProvider = "Microsoft.AspNetCore.Mvc.ModelBinding.IModelNameProvider"; + + public const string IRouteTemplateProvider = "Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider"; + + public const string ModelStateDictionary = "Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary"; + + public const string NonActionAttribute = "Microsoft.AspNetCore.Mvc.NonActionAttribute"; + + public const string NonControllerAttribute = "Microsoft.AspNetCore.Mvc.NonControllerAttribute"; + + public const string PageModelAttributeType = "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageModelAttribute"; + public const string PartialMethod = "Partial"; + public const string ProducesDefaultResponseTypeAttribute = "Microsoft.AspNetCore.Mvc.ProducesDefaultResponseTypeAttribute"; + + public const string ProducesResponseTypeAttribute = "Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute"; + public const string RenderPartialMethod = "RenderPartial"; } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/TopLevelParameterNameAnalyzer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/TopLevelParameterNameAnalyzer.cs new file mode 100644 index 0000000000..ad5aeeaeab --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Analyzers/TopLevelParameterNameAnalyzer.cs @@ -0,0 +1,215 @@ +// 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.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class TopLevelParameterNameAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DiagnosticDescriptors.MVC1004_ParameterNameCollidesWithTopLevelProperty); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStartAnalysisContext => + { + var typeCache = new SymbolCache(compilationStartAnalysisContext.Compilation); + if (typeCache.ControllerAttribute == null || typeCache.ControllerAttribute.TypeKind == TypeKind.Error) + { + // No-op if we can't find types we care about. + return; + } + + InitializeWorker(compilationStartAnalysisContext, typeCache); + }); + } + + private void InitializeWorker(CompilationStartAnalysisContext compilationStartAnalysisContext, SymbolCache symbolCache) + { + compilationStartAnalysisContext.RegisterSymbolAction(symbolAnalysisContext => + { + var method = (IMethodSymbol)symbolAnalysisContext.Symbol; + if (method.MethodKind != MethodKind.Ordinary) + { + return; + } + + if (method.Parameters.Length == 0) + { + return; + } + + if (!MvcFacts.IsController(method.ContainingType, symbolCache.ControllerAttribute, symbolCache.NonControllerAttribute) || + !MvcFacts.IsControllerAction(method, symbolCache.NonActionAttribute, symbolCache.IDisposableDispose)) + { + return; + } + + if (method.ContainingType.HasAttribute(symbolCache.IApiBehaviorMetadata, inherit: true)) + { + // The issue of parameter name collision with properties affects complex model-bound types + // and not input formatting. Ignore ApiController instances since they default to formatting. + return; + } + + for (var i = 0; i < method.Parameters.Length; i++) + { + var parameter = method.Parameters[i]; + if (IsProblematicParameter(symbolCache, parameter)) + { + var location = parameter.Locations.Length != 0 ? + parameter.Locations[0] : + Location.None; + + symbolAnalysisContext.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MVC1004_ParameterNameCollidesWithTopLevelProperty, + location, + parameter.Type.Name, + parameter.Name)); + } + } + }, SymbolKind.Method); + } + + internal static bool IsProblematicParameter(in SymbolCache symbolCache, IParameterSymbol parameter) + { + if (parameter.GetAttributes(symbolCache.FromBodyAttribute).Any()) + { + // Ignore input formatted parameters. + return false; + } + + if (SpecifiesModelType(symbolCache, parameter)) + { + // Ignore parameters that specify a model type. + return false; + } + + var parameterName = GetName(symbolCache, parameter); + + var type = parameter.Type; + while (type != null) + { + foreach (var member in type.GetMembers()) + { + if (member.DeclaredAccessibility != Accessibility.Public || + member.IsStatic || + member.Kind != SymbolKind.Property) + { + continue; + } + + var propertyName = GetName(symbolCache, member); + if (string.Equals(parameterName, propertyName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + type = type.BaseType; + } + + return false; + } + + internal static string GetName(in SymbolCache symbolCache, ISymbol symbol) + { + foreach (var attribute in symbol.GetAttributes(symbolCache.IModelNameProvider)) + { + // BindAttribute uses the Prefix property as an alias for IModelNameProvider.Name + var nameProperty = attribute.AttributeClass == symbolCache.BindAttribute ? "Prefix" : "Name"; + + // All of the built-in attributes (FromQueryAttribute, ModelBinderAttribute etc) only support setting the name via + // a property. We'll ignore constructor values. + for (var i = 0; i < attribute.NamedArguments.Length; i++) + { + var namedArgument = attribute.NamedArguments[i]; + var namedArgumentValue = namedArgument.Value; + if (string.Equals(namedArgument.Key, nameProperty, StringComparison.Ordinal) && + namedArgumentValue.Kind == TypedConstantKind.Primitive && + namedArgumentValue.Type.SpecialType == SpecialType.System_String && + namedArgumentValue.Value is string name) + { + return name; + } + } + } + + return symbol.Name; + } + + internal static bool SpecifiesModelType(in SymbolCache symbolCache, IParameterSymbol parameterSymbol) + { + foreach (var attribute in parameterSymbol.GetAttributes(symbolCache.IBinderTypeProviderMetadata)) + { + // Look for a attribute property named BinderType being assigned. This would match + // [ModelBinder(BinderType = typeof(SomeBinder))] + for (var i = 0; i < attribute.NamedArguments.Length; i++) + { + var namedArgument = attribute.NamedArguments[i]; + var namedArgumentValue = namedArgument.Value; + if (string.Equals(namedArgument.Key, "BinderType", StringComparison.Ordinal) && + namedArgumentValue.Kind == TypedConstantKind.Type) + { + return true; + } + } + + // Look for the binder type being specified in the constructor. This would match + // [ModelBinder(typeof(SomeBinder))] + var constructorParameters = attribute.AttributeConstructor?.Parameters ?? ImmutableArray.Empty; + for (var i = 0; i < constructorParameters.Length; i++) + { + if (string.Equals(constructorParameters[i].Name, "binderType", StringComparison.Ordinal)) + { + // A constructor that requires binderType was used. + return true; + } + } + } + + return false; + } + + internal readonly struct SymbolCache + { + public SymbolCache(Compilation compilation) + { + BindAttribute = compilation.GetTypeByMetadataName(SymbolNames.BindAttribute); + ControllerAttribute = compilation.GetTypeByMetadataName(SymbolNames.ControllerAttribute); + FromBodyAttribute = compilation.GetTypeByMetadataName(SymbolNames.FromBodyAttribute); + IApiBehaviorMetadata = compilation.GetTypeByMetadataName(SymbolNames.IApiBehaviorMetadata); + IBinderTypeProviderMetadata = compilation.GetTypeByMetadataName(SymbolNames.IBinderTypeProviderMetadata); + IModelNameProvider = compilation.GetTypeByMetadataName(SymbolNames.IModelNameProvider); + NonControllerAttribute = compilation.GetTypeByMetadataName(SymbolNames.NonControllerAttribute); + NonActionAttribute = compilation.GetTypeByMetadataName(SymbolNames.NonActionAttribute); + + var disposable = compilation.GetSpecialType(SpecialType.System_IDisposable); + var members = disposable.GetMembers(nameof(IDisposable.Dispose)); + IDisposableDispose = members.Length == 1 ? (IMethodSymbol)members[0] : null; + } + + public INamedTypeSymbol BindAttribute { get; } + public INamedTypeSymbol ControllerAttribute { get; } + public INamedTypeSymbol FromBodyAttribute { get; } + public INamedTypeSymbol IApiBehaviorMetadata { get; } + public INamedTypeSymbol IBinderTypeProviderMetadata { get; } + public INamedTypeSymbol IModelNameProvider { get; } + public INamedTypeSymbol NonControllerAttribute { get; } + public INamedTypeSymbol NonActionAttribute { get; } + public IMethodSymbol IDisposableDispose { get; } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ActualApiResponseMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ActualApiResponseMetadata.cs new file mode 100644 index 0000000000..018967cb10 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ActualApiResponseMetadata.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + internal readonly struct ActualApiResponseMetadata + { + private readonly int? _statusCode; + + public ActualApiResponseMetadata(ReturnStatementSyntax returnStatement, ITypeSymbol returnType) + { + ReturnStatement = returnStatement; + ReturnType = returnType; + _statusCode = null; + } + + public ActualApiResponseMetadata(ReturnStatementSyntax returnStatement, int statusCode, ITypeSymbol returnType) + { + ReturnStatement = returnStatement; + _statusCode = statusCode; + ReturnType = returnType; + } + + public ReturnStatementSyntax ReturnStatement { get; } + + public int StatusCode => _statusCode.Value; + + public bool IsDefaultResponse => _statusCode == null; + + public ITypeSymbol ReturnType { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ActualApiResponseMetadataFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ActualApiResponseMetadataFactory.cs new file mode 100644 index 0000000000..83e1c78546 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ActualApiResponseMetadataFactory.cs @@ -0,0 +1,300 @@ +// 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 System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public static class ActualApiResponseMetadataFactory + { + private static readonly Func _shouldDescendIntoChildren = ShouldDescendIntoChildren; + + /// + /// This method looks at individual return statments and attempts to parse the status code and the return type. + /// Given a for an action, this method inspects return statements in the body. + /// If the returned type is not assignable from IActionResult, it assumes that an "object" value is being returned. e.g. return new Person(); + /// For return statements returning an action result, it attempts to infer the status code and return type. Helper methods in controller, + /// values set in initializer and new-ing up an IActionResult instance are supported. + /// + internal static bool TryGetActualResponseMetadata( + in ApiControllerSymbolCache symbolCache, + SemanticModel semanticModel, + MethodDeclarationSyntax methodSyntax, + CancellationToken cancellationToken, + out IList actualResponseMetadata) + { + actualResponseMetadata = new List(); + + var allReturnStatementsReadable = true; + + foreach (var returnStatementSyntax in methodSyntax.DescendantNodes(_shouldDescendIntoChildren).OfType()) + { + if (returnStatementSyntax.IsMissing || returnStatementSyntax.Expression == null || returnStatementSyntax.Expression.IsMissing) + { + // Ignore malformed return statements. + allReturnStatementsReadable = false; + continue; + } + + var responseMetadata = InspectReturnStatementSyntax( + symbolCache, + semanticModel, + returnStatementSyntax, + cancellationToken); + + if (responseMetadata != null) + { + actualResponseMetadata.Add(responseMetadata.Value); + } + else + { + allReturnStatementsReadable = false; + } + } + + return allReturnStatementsReadable; + } + + internal static ActualApiResponseMetadata? InspectReturnStatementSyntax( + in ApiControllerSymbolCache symbolCache, + SemanticModel semanticModel, + ReturnStatementSyntax returnStatementSyntax, + CancellationToken cancellationToken) + { + var returnExpression = returnStatementSyntax.Expression; + var typeInfo = semanticModel.GetTypeInfo(returnExpression, cancellationToken); + if (typeInfo.Type == null || typeInfo.Type.TypeKind == TypeKind.Error) + { + return null; + } + + var statementReturnType = typeInfo.Type; + + if (!symbolCache.IActionResult.IsAssignableFrom(statementReturnType)) + { + // Return expression is not an instance of IActionResult. Must be returning the "model". + return new ActualApiResponseMetadata(returnStatementSyntax, statementReturnType); + } + + var defaultStatusCodeAttribute = statementReturnType + .GetAttributes(symbolCache.DefaultStatusCodeAttribute, inherit: true) + .FirstOrDefault(); + + var statusCode = GetDefaultStatusCode(defaultStatusCodeAttribute); + ITypeSymbol returnType = null; + switch (returnExpression) + { + case InvocationExpressionSyntax invocation: + { + // Covers the 'return StatusCode(200)' case. + var result = InspectMethodArguments(symbolCache, semanticModel, invocation.Expression, invocation.ArgumentList, cancellationToken); + statusCode = result.statusCode ?? statusCode; + returnType = result.returnType; + break; + } + + case ObjectCreationExpressionSyntax creation: + { + // Read values from 'return new StatusCodeResult(200) case. + var result = InspectMethodArguments(symbolCache, semanticModel, creation, creation.ArgumentList, cancellationToken); + statusCode = result.statusCode ?? statusCode; + returnType = result.returnType; + + // Read values from property assignments e.g. 'return new ObjectResult(...) { StatusCode = 200 }'. + // Property assignments override constructor assigned values and defaults. + result = InspectInitializers(symbolCache, semanticModel, creation.Initializer, cancellationToken); + statusCode = result.statusCode ?? statusCode; + returnType = result.returnType ?? returnType; + break; + } + } + + if (statusCode == null) + { + return null; + } + + return new ActualApiResponseMetadata(returnStatementSyntax, statusCode.Value, returnType); + } + + private static (int? statusCode, ITypeSymbol returnType) InspectInitializers( + in ApiControllerSymbolCache symbolCache, + SemanticModel semanticModel, + InitializerExpressionSyntax initializer, + CancellationToken cancellationToken) + { + int? statusCode = null; + ITypeSymbol typeSymbol = null; + + for (var i = 0; initializer != null && i < initializer.Expressions.Count; i++) + { + var expression = initializer.Expressions[i]; + + if (!(expression is AssignmentExpressionSyntax assignment) || + !(assignment.Left is IdentifierNameSyntax identifier)) + { + continue; + } + + var symbolInfo = semanticModel.GetSymbolInfo(identifier, cancellationToken); + if (symbolInfo.Symbol is IPropertySymbol property) + { + if (IsInterfaceImplementation(property, symbolCache.StatusCodeActionResultStatusProperty) && + TryGetExpressionStatusCode(semanticModel, assignment.Right, cancellationToken, out var statusCodeValue)) + { + // Look for assignments to IStatusCodeActionResult.StatusCode + statusCode = statusCodeValue; + } + else if (HasAttributeNamed(property, ApiSymbolNames.ActionResultObjectValueAttribute)) + { + // Look for assignment to a property annotated with [ActionResultObjectValue] + typeSymbol = GetExpressionObjectType(semanticModel, assignment.Right, cancellationToken); + } + } + } + + return (statusCode, typeSymbol); + } + + private static (int? statusCode, ITypeSymbol returnType) InspectMethodArguments( + in ApiControllerSymbolCache symbolCache, + SemanticModel semanticModel, + ExpressionSyntax expression, + BaseArgumentListSyntax argumentList, + CancellationToken cancellationToken) + { + int? statusCode = null; + ITypeSymbol typeSymbol = null; + + var symbolInfo = semanticModel.GetSymbolInfo(expression, cancellationToken); + + if (symbolInfo.Symbol is IMethodSymbol method) + { + for (var i = 0; i < method.Parameters.Length; i++) + { + var parameter = method.Parameters[i]; + if (HasAttributeNamed(parameter, ApiSymbolNames.ActionResultStatusCodeAttribute)) + { + var argument = argumentList.Arguments[parameter.Ordinal]; + if (TryGetExpressionStatusCode(semanticModel, argument.Expression, cancellationToken, out var statusCodeValue)) + { + statusCode = statusCodeValue; + } + } + + if (HasAttributeNamed(parameter, ApiSymbolNames.ActionResultObjectValueAttribute)) + { + var argument = argumentList.Arguments[parameter.Ordinal]; + typeSymbol = GetExpressionObjectType(semanticModel, argument.Expression, cancellationToken); + } + } + } + + return (statusCode, typeSymbol); + } + + private static ITypeSymbol GetExpressionObjectType(SemanticModel semanticModel, ExpressionSyntax expression, CancellationToken cancellationToken) + { + var typeInfo = semanticModel.GetTypeInfo(expression, cancellationToken); + return typeInfo.Type; + } + + private static bool TryGetExpressionStatusCode( + SemanticModel semanticModel, + ExpressionSyntax expression, + CancellationToken cancellationToken, + out int statusCode) + { + if (expression is LiteralExpressionSyntax literal && literal.Token.Value is int literalStatusCode) + { + // Covers the 'return StatusCode(200)' case. + statusCode = literalStatusCode; + return true; + } + + if (expression is IdentifierNameSyntax || expression is MemberAccessExpressionSyntax) + { + var symbolInfo = semanticModel.GetSymbolInfo(expression, cancellationToken); + + if (symbolInfo.Symbol is IFieldSymbol field && field.HasConstantValue && field.ConstantValue is int constantStatusCode) + { + // Covers the 'return StatusCode(StatusCodes.Status200OK)' case. + // It also covers the 'return StatusCode(StatusCode)' case, where 'StatusCode' is a constant field. + statusCode = constantStatusCode; + return true; + } + + if (symbolInfo.Symbol is ILocalSymbol local && local.HasConstantValue && local.ConstantValue is int localStatusCode) + { + // Covers the 'return StatusCode(statusCode)' case, where 'statusCode' is a local constant. + statusCode = localStatusCode; + return true; + } + } + + statusCode = default; + return false; + } + + private static bool ShouldDescendIntoChildren(SyntaxNode syntaxNode) + { + return !syntaxNode.IsKind(SyntaxKind.LocalFunctionStatement) && + !syntaxNode.IsKind(SyntaxKind.ParenthesizedLambdaExpression) && + !syntaxNode.IsKind(SyntaxKind.SimpleLambdaExpression) && + !syntaxNode.IsKind(SyntaxKind.AnonymousMethodExpression); + } + + internal static int? GetDefaultStatusCode(AttributeData attribute) + { + if (attribute != null && + attribute.ConstructorArguments.Length == 1 && + attribute.ConstructorArguments[0].Kind == TypedConstantKind.Primitive && + attribute.ConstructorArguments[0].Value is int statusCode) + { + return statusCode; + } + + return null; + } + + private static bool IsInterfaceImplementation(IPropertySymbol property, IPropertySymbol statusCodeActionResultStatusProperty) + { + if (property.Name != statusCodeActionResultStatusProperty.Name) + { + return false; + } + + for (var i = 0; i < property.ExplicitInterfaceImplementations.Length; i++) + { + if (property.ExplicitInterfaceImplementations[i] == statusCodeActionResultStatusProperty) + { + return true; + } + } + + var implementedProperty = property.ContainingType.FindImplementationForInterfaceMember(statusCodeActionResultStatusProperty); + return implementedProperty == property; + } + + private static bool HasAttributeNamed(ISymbol symbol, string attributeName) + { + var attributes = symbol.GetAttributes(); + var length = attributes.Length; + for (var i = 0; i < length; i++) + { + if (attributes[i].AttributeClass.Name == attributeName) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/AddResponseTypeAttributeCodeFixAction.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/AddResponseTypeAttributeCodeFixAction.cs new file mode 100644 index 0000000000..e7601b6b7f --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/AddResponseTypeAttributeCodeFixAction.cs @@ -0,0 +1,260 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Simplification; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + /// + /// A that adds one or more ProducesResponseType attributes on the action. + /// 1) It get status codes from ProducesResponseType, ProducesDefaultResponseType, and conventions applied to the action to get the declared metadata. + /// 2) It inspects return statements to get actual metadata. + /// Diffing the two gets us a list of undocumented status codes. + /// We'll attempt to generate a [ProducesResponseType(typeof(SomeModel), 4xx)] if + /// a) the status code is 4xx or later. + /// b) the return statement included a return type. + /// c) the return type wasn't the error type (specified by ProducesErrorResponseType or implicit ProblemDetails) + /// In all other cases, we generate [ProducesResponseType(StatusCode)] + /// + internal sealed class AddResponseTypeAttributeCodeFixAction : CodeAction + { + private readonly Document _document; + private readonly Diagnostic _diagnostic; + + public AddResponseTypeAttributeCodeFixAction(Document document, Diagnostic diagnostic) + { + _document = document; + _diagnostic = diagnostic; + } + + public override string Title => "Add ProducesResponseType attributes."; + + protected override async Task GetChangedDocumentAsync(CancellationToken cancellationToken) + { + var context = await CreateCodeActionContext(cancellationToken).ConfigureAwait(false); + var declaredResponseMetadata = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(context.SymbolCache, context.Method); + var errorResponseType = SymbolApiResponseMetadataProvider.GetErrorResponseType(context.SymbolCache, context.Method); + + var results = CalculateStatusCodesToApply(context, declaredResponseMetadata); + if (results.Count == 0) + { + return _document; + } + + var documentEditor = await DocumentEditor.CreateAsync(_document, cancellationToken).ConfigureAwait(false); + + var addUsingDirective = false; + foreach (var (statusCode, returnType) in results.OrderBy(s => s.statusCode)) + { + AttributeSyntax attributeSyntax; + bool addUsing; + + if (statusCode >= 400 && returnType != null && returnType != errorResponseType) + { + // If a returnType was discovered and is different from the errorResponseType, use it in the result. + attributeSyntax = CreateProducesResponseTypeAttribute(context, statusCode, returnType, out addUsing); + } + else + { + attributeSyntax = CreateProducesResponseTypeAttribute(context, statusCode, out addUsing); + } + + documentEditor.AddAttribute(context.MethodSyntax, attributeSyntax); + addUsingDirective |= addUsing; + } + + if (!declaredResponseMetadata.Any(m => m.IsDefault && m.AttributeSource == context.Method)) + { + // Add a ProducesDefaultResponseTypeAttribute if the method does not already have one. + documentEditor.AddAttribute(context.MethodSyntax, CreateProducesDefaultResponseTypeAttribute()); + } + + var apiConventionMethodAttribute = context.Method.GetAttributes(context.SymbolCache.ApiConventionMethodAttribute).FirstOrDefault(); + + if (apiConventionMethodAttribute != null) + { + // Remove [ApiConventionMethodAttribute] declared on the method since it's no longer required + var attributeSyntax = await apiConventionMethodAttribute + .ApplicationSyntaxReference + .GetSyntaxAsync(cancellationToken) + .ConfigureAwait(false); + + documentEditor.RemoveNode(attributeSyntax); + } + + var document = documentEditor.GetChangedDocument(); + + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if (root is CompilationUnitSyntax compilationUnit && addUsingDirective) + { + const string @namespace = "Microsoft.AspNetCore.Http"; + + var declaredUsings = new HashSet(compilationUnit.Usings.Select(x => x.Name.ToString())); + + if (!declaredUsings.Contains(@namespace)) + { + root = compilationUnit.AddUsings(SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(@namespace))); + } + } + + return document.WithSyntaxRoot(root); + } + + private async Task CreateCodeActionContext(CancellationToken cancellationToken) + { + var root = await _document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var semanticModel = await _document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + var methodReturnStatement = (ReturnStatementSyntax)root.FindNode(_diagnostic.Location.SourceSpan); + var methodSyntax = methodReturnStatement.FirstAncestorOrSelf(); + var method = semanticModel.GetDeclaredSymbol(methodSyntax, cancellationToken); + + var statusCodesType = semanticModel.Compilation.GetTypeByMetadataName(ApiSymbolNames.HttpStatusCodes); + var statusCodeConstants = GetStatusCodeConstants(statusCodesType); + + var symbolCache = new ApiControllerSymbolCache(semanticModel.Compilation); + + var codeActionContext = new CodeActionContext(semanticModel, symbolCache, method, methodSyntax, statusCodeConstants, cancellationToken); + return codeActionContext; + } + + private static Dictionary GetStatusCodeConstants(INamedTypeSymbol statusCodesType) + { + var statusCodeConstants = new Dictionary(); + + if (statusCodesType != null) + { + foreach (var member in statusCodesType.GetMembers()) + { + if (member is IFieldSymbol field && + field.Type.SpecialType == SpecialType.System_Int32 && + field.Name.StartsWith("Status") && + field.HasConstantValue && + field.ConstantValue is int statusCode) + { + statusCodeConstants[statusCode] = field.Name; + } + } + } + + return statusCodeConstants; + } + + private ICollection<(int statusCode, ITypeSymbol typeSymbol)> CalculateStatusCodesToApply(in CodeActionContext context, IList declaredResponseMetadata) + { + if (!ActualApiResponseMetadataFactory.TryGetActualResponseMetadata(context.SymbolCache, context.SemanticModel, context.MethodSyntax, context.CancellationToken, out var actualResponseMetadata)) + { + // If we cannot parse metadata correctly, don't offer fixes. + return Array.Empty<(int, ITypeSymbol)>(); + } + + var statusCodes = new Dictionary(); + foreach (var metadata in actualResponseMetadata) + { + if (DeclaredApiResponseMetadata.TryGetDeclaredMetadata(declaredResponseMetadata, metadata, result: out var declaredMetadata) && + declaredMetadata.AttributeSource == context.Method) + { + // A ProducesResponseType attribute is declared on the method for the current status code. + continue; + } + + var statusCode = metadata.IsDefaultResponse ? 200 : metadata.StatusCode; + statusCodes.Add(statusCode, (statusCode, metadata.ReturnType)); + } + + return statusCodes.Values; + } + + private static AttributeSyntax CreateProducesResponseTypeAttribute(in CodeActionContext context, int statusCode, out bool addUsingDirective) + { + // [ProducesResponseType(StatusCodes.Status400NotFound)] + var statusCodeSyntax = CreateStatusCodeSyntax(context, statusCode, out addUsingDirective); + + return SyntaxFactory.Attribute( + SyntaxFactory.ParseName(ApiSymbolNames.ProducesResponseTypeAttribute) + .WithAdditionalAnnotations(Simplifier.Annotation), + SyntaxFactory.AttributeArgumentList().AddArguments( + + SyntaxFactory.AttributeArgument(statusCodeSyntax))); + } + + private static AttributeSyntax CreateProducesResponseTypeAttribute(in CodeActionContext context, int statusCode, ITypeSymbol typeSymbol, out bool addUsingDirective) + { + // [ProducesResponseType(typeof(ReturnType), StatusCodes.Status400NotFound)] + var statusCodeSyntax = CreateStatusCodeSyntax(context, statusCode, out addUsingDirective); + var responseTypeAttribute = SyntaxFactory.TypeOfExpression( + SyntaxFactory.ParseTypeName(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .WithAdditionalAnnotations(Simplifier.Annotation)); + + return SyntaxFactory.Attribute( + SyntaxFactory.ParseName(ApiSymbolNames.ProducesResponseTypeAttribute) + .WithAdditionalAnnotations(Simplifier.Annotation), + SyntaxFactory.AttributeArgumentList().AddArguments( + SyntaxFactory.AttributeArgument(responseTypeAttribute), + SyntaxFactory.AttributeArgument(statusCodeSyntax))); + } + + private static ExpressionSyntax CreateStatusCodeSyntax(CodeActionContext context, int statusCode, out bool addUsingDirective) + { + if (context.StatusCodeConstants.TryGetValue(statusCode, out var constantName)) + { + addUsingDirective = true; + return SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.ParseTypeName(ApiSymbolNames.HttpStatusCodes) + .WithAdditionalAnnotations(Simplifier.Annotation), + SyntaxFactory.IdentifierName(constantName)); + } + + addUsingDirective = false; + return SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(statusCode)); + } + + private static AttributeSyntax CreateProducesDefaultResponseTypeAttribute() + { + return SyntaxFactory.Attribute( + SyntaxFactory.ParseName(ApiSymbolNames.ProducesDefaultResponseTypeAttribute) + .WithAdditionalAnnotations(Simplifier.Annotation)); + } + + private readonly struct CodeActionContext + { + public CodeActionContext(SemanticModel semanticModel, + ApiControllerSymbolCache symbolCache, + IMethodSymbol method, + MethodDeclarationSyntax methodSyntax, + Dictionary statusCodeConstants, + CancellationToken cancellationToken) + { + SemanticModel = semanticModel; + SymbolCache = symbolCache; + Method = method; + MethodSyntax = methodSyntax; + StatusCodeConstants = statusCodeConstants; + CancellationToken = cancellationToken; + } + + public MethodDeclarationSyntax MethodSyntax { get; } + + public Dictionary StatusCodeConstants { get; } + + public IMethodSymbol Method { get; } + + public SemanticModel SemanticModel { get; } + + public ApiControllerSymbolCache SymbolCache { get; } + + public CancellationToken CancellationToken { get; } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/AddResponseTypeAttributeCodeFixProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/AddResponseTypeAttributeCodeFixProvider.cs new file mode 100644 index 0000000000..a5a2a62613 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/AddResponseTypeAttributeCodeFixProvider.cs @@ -0,0 +1,40 @@ +// 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.Immutable; +using System.Composition; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ExportCodeFixProvider(LanguageNames.CSharp)] + [Shared] + public class AddResponseTypeAttributeCodeFixProvider : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create( + ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode.Id, + ApiDiagnosticDescriptors.API1001_ActionReturnsUndocumentedSuccessResult.Id); + + public sealed override Task RegisterCodeFixesAsync(CodeFixContext context) + { + if (context.Diagnostics.Length == 0) + { + return Task.CompletedTask; + } + + var diagnostic = context.Diagnostics[0]; + if ((diagnostic.Descriptor.Id != ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode.Id) && + (diagnostic.Descriptor.Id != ApiDiagnosticDescriptors.API1001_ActionReturnsUndocumentedSuccessResult.Id)) + { + return Task.CompletedTask; + } + + var codeFix = new AddResponseTypeAttributeCodeFixAction(context.Document, diagnostic); + + context.RegisterCodeFix(codeFix, diagnostic); + return Task.CompletedTask; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer.cs new file mode 100644 index 0000000000..9a8dc1c714 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer.cs @@ -0,0 +1,222 @@ +// 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.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( + ApiDiagnosticDescriptors.API1003_ApiActionsDoNotRequireExplicitModelValidationCheck); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStartAnalysisContext => + { + var symbolCache = new ApiControllerSymbolCache(compilationStartAnalysisContext.Compilation); + if (symbolCache.ApiConventionTypeAttribute == null || symbolCache.ApiConventionTypeAttribute.TypeKind == TypeKind.Error) + { + // No-op if we can't find types we care about. + return; + } + + InitializeWorker(compilationStartAnalysisContext, symbolCache); + }); + } + + private void InitializeWorker(CompilationStartAnalysisContext context, ApiControllerSymbolCache symbolCache) + { + context.RegisterOperationAction(operationAnalysisContext => + { + var ifOperation = (IConditionalOperation)operationAnalysisContext.Operation; + if (!(ifOperation.Syntax is IfStatementSyntax ifStatement)) + { + return; + } + + if (ifOperation.WhenTrue == null || ifOperation.WhenFalse != null) + { + // We only support expressions of the format + // if (!ModelState.IsValid) + // or + // if (ModelState.IsValid == false) + // If the conditional is missing a true condition or has an else expression, skip this operation. + return; + } + + var parent = ifOperation.Parent; + if (parent?.Kind == OperationKind.Block) + { + parent = parent?.Parent; + } + + if (parent?.Kind != OperationKind.MethodBodyOperation) + { + // Only support top-level ModelState IsValid checks. + return; + } + + var trueStatement = UnwrapSingleStatementBlock(ifOperation.WhenTrue); + if (trueStatement.Kind != OperationKind.Return) + { + // We need to verify that the if statement does a ModelState.IsValid check and that the block inside contains + // a single return statement returning a 400. We'l get to it in just a bit + return; + } + + if (!(parent.Syntax is MethodDeclarationSyntax methodSyntax)) + { + return; + } + + var semanticModel = operationAnalysisContext.Compilation.GetSemanticModel(methodSyntax.SyntaxTree); + var methodSymbol = semanticModel.GetDeclaredSymbol(methodSyntax, operationAnalysisContext.CancellationToken); + + if (!ApiControllerFacts.IsApiControllerAction(symbolCache, methodSymbol)) + { + // Not a ApiController. Nothing to do here. + return; + } + + if (!IsModelStateIsValidCheck(symbolCache, ifOperation.Condition)) + { + return; + } + + var returnOperation = (IReturnOperation)trueStatement; + + var returnValue = returnOperation.ReturnedValue; + if (returnValue == null || + !symbolCache.IActionResult.IsAssignableFrom(returnValue.Type)) + { + return; + } + + var returnStatementSyntax = (ReturnStatementSyntax)returnOperation.Syntax; + var actualMetadata = ActualApiResponseMetadataFactory.InspectReturnStatementSyntax( + symbolCache, + semanticModel, + returnStatementSyntax, + operationAnalysisContext.CancellationToken); + + if (actualMetadata == null || actualMetadata.Value.StatusCode != 400) + { + return; + } + + var additionalLocations = new[] + { + ifStatement.GetLocation(), + returnStatementSyntax.GetLocation(), + }; + + operationAnalysisContext.ReportDiagnostic( + Diagnostic.Create( + ApiDiagnosticDescriptors.API1003_ApiActionsDoNotRequireExplicitModelValidationCheck, + ifStatement.GetLocation(), + additionalLocations: additionalLocations)); + }, OperationKind.Conditional); + } + + private bool IsModelStateIsValidCheck(in ApiControllerSymbolCache symbolCache, IOperation condition) + { + switch (condition.Kind) + { + case OperationKind.UnaryOperator: + var operation = ((IUnaryOperation)condition).Operand; + return IsModelStateIsValidPropertyAccessor(symbolCache, operation); + + case OperationKind.BinaryOperator: + var binaryOperation = (IBinaryOperation)condition; + if (binaryOperation.OperatorKind == BinaryOperatorKind.Equals) + { + // (ModelState.IsValid == false) OR (false == ModelState.IsValid) + return EvaluateBinaryOperator(symbolCache, binaryOperation.LeftOperand, binaryOperation.RightOperand, false) || + EvaluateBinaryOperator(symbolCache, binaryOperation.RightOperand, binaryOperation.LeftOperand, false); + } + else if (binaryOperation.OperatorKind == BinaryOperatorKind.NotEquals) + { + // (ModelState.IsValid != true) OR (true != ModelState.IsValid) + return EvaluateBinaryOperator(symbolCache, binaryOperation.LeftOperand, binaryOperation.RightOperand, true) || + EvaluateBinaryOperator(symbolCache, binaryOperation.RightOperand, binaryOperation.LeftOperand, true); + } + return false; + + default: + return false; + } + } + + private bool EvaluateBinaryOperator( + in ApiControllerSymbolCache symbolCache, + IOperation operation, + IOperation otherOperation, + bool expectedConstantValue) + { + if (operation.Kind != OperationKind.Literal) + { + return false; + } + + var constantValue = ((ILiteralOperation)operation).ConstantValue; + if (!constantValue.HasValue || + !(constantValue.Value is bool boolConstantValue) || + boolConstantValue != expectedConstantValue) + { + return false; + } + + return IsModelStateIsValidPropertyAccessor(symbolCache, otherOperation); + } + + private static bool IsModelStateIsValidPropertyAccessor(in ApiControllerSymbolCache symbolCache, IOperation operation) + { + if (operation.Kind != OperationKind.PropertyReference) + { + return false; + } + + var propertyReference = (IPropertyReferenceOperation)operation; + if (propertyReference.Property.Name != "IsValid") + { + return false; + } + + if (propertyReference.Member.ContainingType != symbolCache.ModelStateDictionary) + { + return false; + } + + if (propertyReference.Instance?.Kind != OperationKind.PropertyReference) + { + // Verify this is referring to the ModelState property on the current controller instance + return false; + } + + var modelStatePropertyReference = (IPropertyReferenceOperation)propertyReference.Instance; + if (modelStatePropertyReference.Instance?.Kind != OperationKind.InstanceReference) + { + return false; + } + + return true; + } + + private static IOperation UnwrapSingleStatementBlock(IOperation statement) + { + return statement is IBlockOperation block && block.Operations.Length == 1 ? + block.Operations[0] : + statement; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiActionsDoNotRequireExplicitModelValidationCodeFixProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiActionsDoNotRequireExplicitModelValidationCodeFixProvider.cs new file mode 100644 index 0000000000..a9439c6884 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiActionsDoNotRequireExplicitModelValidationCodeFixProvider.cs @@ -0,0 +1,69 @@ +// 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.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ExportCodeFixProvider(LanguageNames.CSharp)] + [Shared] + public class ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProvider : CodeFixProvider + { + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(ApiDiagnosticDescriptors.API1003_ApiActionsDoNotRequireExplicitModelValidationCheck.Id); + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override Task RegisterCodeFixesAsync(CodeFixContext context) + { + if (context.Diagnostics.Length != 1) + { + return Task.CompletedTask; + } + + var diagnostic = context.Diagnostics[0]; + if (diagnostic.Id != ApiDiagnosticDescriptors.API1003_ApiActionsDoNotRequireExplicitModelValidationCheck.Id) + { + return Task.CompletedTask; + } + + context.RegisterCodeFix(new MyCodeAction(context.Document, context.Span), diagnostic); + return Task.CompletedTask; + } + + private class MyCodeAction : CodeAction + { + private readonly Document _document; + private readonly TextSpan _ifBlockSpan; + + public MyCodeAction(Document document, TextSpan ifBlockSpan) + { + _document = document; + _ifBlockSpan = ifBlockSpan; + } + + public override string EquivalenceKey => Title; + + public override string Title => "Remove ModelState.IsValid check"; + + protected override async Task GetChangedDocumentAsync(CancellationToken cancellationToken) + { + var rootNode = await _document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var editor = await DocumentEditor.CreateAsync(_document, cancellationToken).ConfigureAwait(false); + + var ifBlockSyntax = rootNode.FindNode(_ifBlockSpan); + editor.RemoveNode(ifBlockSyntax); + + return editor.GetChangedDocument(); + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiControllerFacts.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiControllerFacts.cs new file mode 100644 index 0000000000..d010d58894 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiControllerFacts.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 Microsoft.AspNetCore.Mvc.Analyzers; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + internal static class ApiControllerFacts + { + public static bool IsApiControllerAction(ApiControllerSymbolCache symbolCache, IMethodSymbol method) + { + if (method == null) + { + return false; + } + + if (method.ReturnsVoid || method.ReturnType.TypeKind == TypeKind.Error) + { + return false; + } + + if (!MvcFacts.IsController(method.ContainingType, symbolCache.ControllerAttribute, symbolCache.NonControllerAttribute)) + { + return false; + } + + if (!method.ContainingType.HasAttribute(symbolCache.IApiBehaviorMetadata, inherit: true) && + !method.ContainingAssembly.HasAttribute(symbolCache.IApiBehaviorMetadata)) + { + return false; + } + + if (!MvcFacts.IsControllerAction(method, symbolCache.NonActionAttribute, symbolCache.IDisposableDispose)) + { + return false; + } + + return true; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiControllerSymbolCache.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiControllerSymbolCache.cs new file mode 100644 index 0000000000..37f6b26291 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiControllerSymbolCache.cs @@ -0,0 +1,71 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + internal readonly struct ApiControllerSymbolCache + { + public ApiControllerSymbolCache(Compilation compilation) + { + ApiConventionMethodAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.ApiConventionMethodAttribute); + ApiConventionNameMatchAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.ApiConventionNameMatchAttribute); + ApiConventionTypeAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.ApiConventionTypeAttribute); + ApiConventionTypeMatchAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.ApiConventionTypeMatchAttribute); + ControllerAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.ControllerAttribute); + DefaultStatusCodeAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.DefaultStatusCodeAttribute); + IActionResult = compilation.GetTypeByMetadataName(ApiSymbolNames.IActionResult); + IApiBehaviorMetadata = compilation.GetTypeByMetadataName(ApiSymbolNames.IApiBehaviorMetadata); + ModelStateDictionary = compilation.GetTypeByMetadataName(ApiSymbolNames.ModelStateDictionary); + NonActionAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.NonActionAttribute); + NonControllerAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.NonControllerAttribute); + ProblemDetails = compilation.GetTypeByMetadataName(ApiSymbolNames.ProblemDetails); + ProducesDefaultResponseTypeAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.ProducesDefaultResponseTypeAttribute); + ProducesErrorResponseTypeAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.ProducesErrorResponseTypeAttribute); + ProducesResponseTypeAttribute = compilation.GetTypeByMetadataName(ApiSymbolNames.ProducesResponseTypeAttribute); + + var statusCodeActionResult = compilation.GetTypeByMetadataName(ApiSymbolNames.IStatusCodeActionResult); + StatusCodeActionResultStatusProperty = (IPropertySymbol)statusCodeActionResult?.GetMembers("StatusCode")[0]; + + var disposable = compilation.GetSpecialType(SpecialType.System_IDisposable); + var members = disposable.GetMembers(nameof(IDisposable.Dispose)); + IDisposableDispose = members.Length == 1 ? (IMethodSymbol)members[0] : null; + } + + public INamedTypeSymbol ApiConventionMethodAttribute { get; } + + public INamedTypeSymbol ApiConventionNameMatchAttribute { get; } + + public INamedTypeSymbol ApiConventionTypeAttribute { get; } + + public INamedTypeSymbol ApiConventionTypeMatchAttribute { get; } + + public INamedTypeSymbol ControllerAttribute { get; } + + public INamedTypeSymbol DefaultStatusCodeAttribute { get; } + + public INamedTypeSymbol IActionResult { get; } + + public INamedTypeSymbol IApiBehaviorMetadata { get; } + + public IMethodSymbol IDisposableDispose { get; } + + public IPropertySymbol StatusCodeActionResultStatusProperty { get; } + + public ITypeSymbol ModelStateDictionary { get; } + + public INamedTypeSymbol NonActionAttribute { get; } + + public INamedTypeSymbol NonControllerAttribute { get; } + + public INamedTypeSymbol ProblemDetails { get; } + + public INamedTypeSymbol ProducesDefaultResponseTypeAttribute { get; } + + public INamedTypeSymbol ProducesResponseTypeAttribute { get; } + + public INamedTypeSymbol ProducesErrorResponseTypeAttribute { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiConventionAnalyzer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiConventionAnalyzer.cs new file mode 100644 index 0000000000..6b92ee4c8f --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiConventionAnalyzer.cs @@ -0,0 +1,115 @@ +// 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.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ApiConventionAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( + ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, + ApiDiagnosticDescriptors.API1001_ActionReturnsUndocumentedSuccessResult, + ApiDiagnosticDescriptors.API1002_ActionDoesNotReturnDocumentedStatusCode); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStartAnalysisContext => + { + var symbolCache = new ApiControllerSymbolCache(compilationStartAnalysisContext.Compilation); + if (symbolCache.ApiConventionTypeAttribute == null || symbolCache.ApiConventionTypeAttribute.TypeKind == TypeKind.Error) + { + // No-op if we can't find types we care about. + return; + } + + InitializeWorker(compilationStartAnalysisContext, symbolCache); + }); + } + + private void InitializeWorker(CompilationStartAnalysisContext compilationStartAnalysisContext, ApiControllerSymbolCache symbolCache) + { + compilationStartAnalysisContext.RegisterSyntaxNodeAction(syntaxNodeContext => + { + var cancellationToken = syntaxNodeContext.CancellationToken; + var methodSyntax = (MethodDeclarationSyntax)syntaxNodeContext.Node; + var semanticModel = syntaxNodeContext.SemanticModel; + var method = semanticModel.GetDeclaredSymbol(methodSyntax, syntaxNodeContext.CancellationToken); + + if (!ApiControllerFacts.IsApiControllerAction(symbolCache, method)) + { + return; + } + + var declaredResponseMetadata = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + var hasUnreadableStatusCodes = !ActualApiResponseMetadataFactory.TryGetActualResponseMetadata(symbolCache, semanticModel, methodSyntax, cancellationToken, out var actualResponseMetadata); + + var hasUndocumentedStatusCodes = false; + foreach (var actualMetadata in actualResponseMetadata) + { + var location = actualMetadata.ReturnStatement.GetLocation(); + + if (!DeclaredApiResponseMetadata.Contains(declaredResponseMetadata, actualMetadata)) + { + hasUndocumentedStatusCodes = true; + if (actualMetadata.IsDefaultResponse) + { + syntaxNodeContext.ReportDiagnostic(Diagnostic.Create( + ApiDiagnosticDescriptors.API1001_ActionReturnsUndocumentedSuccessResult, + location)); + } + else + { + syntaxNodeContext.ReportDiagnostic(Diagnostic.Create( + ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, + location, + actualMetadata.StatusCode)); + } + } + } + + if (hasUndocumentedStatusCodes || hasUnreadableStatusCodes) + { + // If we produced analyzer warnings about undocumented status codes, don't attempt to determine + // if there are documented status codes that are missing from the method body. + return; + } + + for (var i = 0; i < declaredResponseMetadata.Count; i++) + { + var declaredMetadata = declaredResponseMetadata[i]; + if (!Contains(actualResponseMetadata, declaredMetadata)) + { + syntaxNodeContext.ReportDiagnostic(Diagnostic.Create( + ApiDiagnosticDescriptors.API1002_ActionDoesNotReturnDocumentedStatusCode, + methodSyntax.Identifier.GetLocation(), + declaredMetadata.StatusCode)); + } + } + + }, SyntaxKind.MethodDeclaration); + } + + internal static bool Contains(IList actualResponseMetadata, DeclaredApiResponseMetadata declaredMetadata) + { + for (var i = 0; i < actualResponseMetadata.Count; i++) + { + if (declaredMetadata.Matches(actualResponseMetadata[i])) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiDiagnosticDescriptors.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiDiagnosticDescriptors.cs new file mode 100644 index 0000000000..ff56f64f1c --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiDiagnosticDescriptors.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 Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + internal static class ApiDiagnosticDescriptors + { + public static readonly DiagnosticDescriptor API1000_ActionReturnsUndocumentedStatusCode = + new DiagnosticDescriptor( + "API1000", + "Action returns undeclared status code.", + "Action method returns undeclared status code '{0}'.", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor API1001_ActionReturnsUndocumentedSuccessResult = + new DiagnosticDescriptor( + "API1001", + "Action returns undeclared success result.", + "Action method returns a success result without a corresponding ProducesResponseType.", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor API1002_ActionDoesNotReturnDocumentedStatusCode = + new DiagnosticDescriptor( + "API1002", + "Action documents status code that is not returned.", + "Action method documents status code '{0}' without a corresponding return type.", + "Usage", + DiagnosticSeverity.Info, + isEnabledByDefault: false); + + public static readonly DiagnosticDescriptor API1003_ApiActionsDoNotRequireExplicitModelValidationCheck = + new DiagnosticDescriptor( + "API1003", + "Action methods on ApiController instances do not require explicit model validation check.", + "Action methods on ApiController instances do not require explicit model validation check.", + "Usage", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + customTags: new[] { WellKnownDiagnosticTags.Unnecessary }); + + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiSymbolNames.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiSymbolNames.cs new file mode 100644 index 0000000000..849d07fa0b --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/ApiSymbolNames.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. + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + internal static class ApiSymbolNames + { + public const string ActionResultStatusCodeAttribute = "ActionResultStatusCodeAttribute"; + + public const string ActionResultObjectValueAttribute = "ActionResultObjectValueAttribute"; + + public const string AllowAnonymousAttribute = "Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute"; + + public const string ApiConventionMethodAttribute = "Microsoft.AspNetCore.Mvc.ApiConventionMethodAttribute"; + + public const string ApiConventionNameMatchAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorer.ApiConventionNameMatchAttribute"; + + public const string ApiConventionTypeMatchAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorer.ApiConventionTypeMatchAttribute"; + + public const string ApiConventionTypeAttribute = "Microsoft.AspNetCore.Mvc.ApiConventionTypeAttribute"; + + public const string ControllerAttribute = "Microsoft.AspNetCore.Mvc.ControllerAttribute"; + + public const string DefaultStatusCodeAttribute = "Microsoft.AspNetCore.Mvc.Infrastructure.DefaultStatusCodeAttribute"; + + public const string IApiBehaviorMetadata = "Microsoft.AspNetCore.Mvc.IApiBehaviorMetadata"; + + public const string IActionResult = "Microsoft.AspNetCore.Mvc.IActionResult"; + + public const string IStatusCodeActionResult = "Microsoft.AspNetCore.Mvc.Infrastructure.IStatusCodeActionResult"; + + public const string ModelStateDictionary = "Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary"; + + public const string NonActionAttribute = "Microsoft.AspNetCore.Mvc.NonActionAttribute"; + + public const string NonControllerAttribute = "Microsoft.AspNetCore.Mvc.NonControllerAttribute"; + + public const string ProblemDetails = "Microsoft.AspNetCore.Mvc.ProblemDetails"; + + public const string ProducesDefaultResponseTypeAttribute = "Microsoft.AspNetCore.Mvc.ProducesDefaultResponseTypeAttribute"; + + public const string ProducesErrorResponseTypeAttribute = "Microsoft.AspNetCore.Mvc.ProducesErrorResponseTypeAttribute"; + + public const string ProducesResponseTypeAttribute = "Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute"; + + public const string HttpStatusCodes = "Microsoft.AspNetCore.Http.StatusCodes"; + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/DeclaredApiResponseMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/DeclaredApiResponseMetadata.cs new file mode 100644 index 0000000000..30819931d0 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/DeclaredApiResponseMetadata.cs @@ -0,0 +1,100 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + internal readonly struct DeclaredApiResponseMetadata + { + public static DeclaredApiResponseMetadata ImplicitResponse { get; } = + new DeclaredApiResponseMetadata(statusCode: 200, attributeData: null, attributeSource: null, @implicit: true, @default: false); + + public static DeclaredApiResponseMetadata ForProducesResponseType(int statusCode, AttributeData attributeData, IMethodSymbol attributeSource) + { + return new DeclaredApiResponseMetadata(statusCode, attributeData, attributeSource, @implicit: false, @default: false); + } + + public static DeclaredApiResponseMetadata ForProducesDefaultResponse(AttributeData attributeData, IMethodSymbol attributeSource) + { + return new DeclaredApiResponseMetadata(statusCode: 0, attributeData, attributeSource, @implicit: false, @default: true); + } + + private DeclaredApiResponseMetadata( + int statusCode, + AttributeData attributeData, + IMethodSymbol attributeSource, + bool @implicit, + bool @default) + { + StatusCode = statusCode; + Attribute = attributeData; + AttributeSource = attributeSource; + IsImplicit = @implicit; + IsDefault = @default; + } + + public int StatusCode { get; } + + public AttributeData Attribute { get; } + + public IMethodSymbol AttributeSource { get; } + + /// + /// True if this is the implicit 200 associated with an + /// action specifying no metadata. + /// + public bool IsImplicit { get; } + + /// + /// True if this is from a ProducesDefaultResponseTypeAttribute. + /// Matches all failure (400 and above) status codes. + /// + public bool IsDefault { get; } + + internal static bool Contains(IList declaredApiResponseMetadata, ActualApiResponseMetadata actualMetadata) + { + return TryGetDeclaredMetadata(declaredApiResponseMetadata, actualMetadata, out _); + } + + internal static bool TryGetDeclaredMetadata( + IList declaredApiResponseMetadata, + ActualApiResponseMetadata actualMetadata, + out DeclaredApiResponseMetadata result) + { + for (var i = 0; i < declaredApiResponseMetadata.Count; i++) + { + var declaredMetadata = declaredApiResponseMetadata[i]; + + if (declaredMetadata.Matches(actualMetadata)) + { + result = declaredMetadata; + return true; + } + } + + result = default; + return false; + } + + internal bool Matches(ActualApiResponseMetadata actualMetadata) + { + if (actualMetadata.IsDefaultResponse) + { + return IsImplicit || StatusCode == 200 || StatusCode == 201; + } + else if (actualMetadata.StatusCode == StatusCode) + { + return true; + } + else if (actualMetadata.StatusCode >= 400 && IsDefault) + { + // ProducesDefaultResponse matches any failure code + return true; + } + + return false; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Microsoft.AspNetCore.Mvc.Api.Analyzers.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Microsoft.AspNetCore.Mvc.Api.Analyzers.csproj new file mode 100644 index 0000000000..97ec69ad41 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Microsoft.AspNetCore.Mvc.Api.Analyzers.csproj @@ -0,0 +1,51 @@ + + + CSharp Analyzers for ASP.NET Core MVC. + aspnetcore;aspnetcoremvc + + netstandard1.3 + false + false + false + $(MSBuildProjectName).nuspec + + + + + + + + + + + + + + + + + + + + true + + + id=$(PackageId); + version=$(PackageVersion); + authors=$(Authors); + description=$(Description); + tags=$(PackageTags.Replace(';', ' ')); + licenseUrl=$(PackageLicenseUrl); + projectUrl=$(PackageProjectUrl); + iconUrl=$(PackageIconUrl); + repositoryUrl=$(RepositoryUrl); + repositoryCommit=$(RepositoryCommit); + copyright=$(Copyright); + + OutputBinary=$(OutputPath)$(AssemblyName).dll; + OutputSymbol=$(OutputPath)$(AssemblyName).pdb; + + + + + diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Microsoft.AspNetCore.Mvc.Api.Analyzers.nuspec b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Microsoft.AspNetCore.Mvc.Api.Analyzers.nuspec new file mode 100644 index 0000000000..5f9d436f73 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Microsoft.AspNetCore.Mvc.Api.Analyzers.nuspec @@ -0,0 +1,24 @@ + + + + $id$ + $version$ + $authors$ + true + $licenseUrl$ + $projectUrl$ + $iconUrl$ + $description$ + $copyright$ + $tags$ + + + + + + + + + + + diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..1ed7ae5ed2 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Mvc.Api.Analyzers.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/SymbolApiConventionMatcher.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/SymbolApiConventionMatcher.cs new file mode 100644 index 0000000000..78078cfcd0 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/SymbolApiConventionMatcher.cs @@ -0,0 +1,197 @@ +// 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 Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + internal static class SymbolApiConventionMatcher + { + internal static bool IsMatch(ApiControllerSymbolCache symbolCache, IMethodSymbol method, IMethodSymbol conventionMethod) + { + return MethodMatches() && ParametersMatch(); + + bool MethodMatches() + { + var methodNameMatchBehavior = GetNameMatchBehavior(symbolCache, conventionMethod); + if (!IsNameMatch(method.Name, conventionMethod.Name, methodNameMatchBehavior)) + { + return false; + } + + return true; + } + + bool ParametersMatch() + { + var methodParameters = method.Parameters; + var conventionMethodParameters = conventionMethod.Parameters; + + for (var i = 0; i < conventionMethodParameters.Length; i++) + { + var conventionParameter = conventionMethodParameters[i]; + if (conventionParameter.IsParams) + { + return true; + } + + if (methodParameters.Length <= i) + { + return false; + } + + var nameMatchBehavior = GetNameMatchBehavior(symbolCache, conventionParameter); + var typeMatchBehavior = GetTypeMatchBehavior(symbolCache, conventionParameter); + + if (!IsTypeMatch(methodParameters[i].Type, conventionParameter.Type, typeMatchBehavior) || + !IsNameMatch(methodParameters[i].Name, conventionParameter.Name, nameMatchBehavior)) + { + return false; + } + } + + // Ensure convention has at least as many parameters as the method. params convention argument are handled + // inside the for loop. + return methodParameters.Length == conventionMethodParameters.Length; + } + } + + internal static SymbolApiConventionNameMatchBehavior GetNameMatchBehavior(ApiControllerSymbolCache symbolCache, ISymbol symbol) + { + var attribute = symbol.GetAttributes(symbolCache.ApiConventionNameMatchAttribute).FirstOrDefault(); + if (attribute == null || + attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Kind != TypedConstantKind.Enum) + { + return SymbolApiConventionNameMatchBehavior.Exact; + } + + var intValue = (int)attribute.ConstructorArguments[0].Value; + return (SymbolApiConventionNameMatchBehavior)intValue; + } + + internal static SymbolApiConventionTypeMatchBehavior GetTypeMatchBehavior(ApiControllerSymbolCache symbolCache, ISymbol symbol) + { + var attribute = symbol.GetAttributes(symbolCache.ApiConventionTypeMatchAttribute).FirstOrDefault(); + if (attribute == null || + attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Kind != TypedConstantKind.Enum) + { + return SymbolApiConventionTypeMatchBehavior.AssignableFrom; + } + + var intValue = (int)attribute.ConstructorArguments[0].Value; + return (SymbolApiConventionTypeMatchBehavior)intValue; + } + + internal static bool IsNameMatch(string name, string conventionName, SymbolApiConventionNameMatchBehavior nameMatchBehavior) + { + switch (nameMatchBehavior) + { + case SymbolApiConventionNameMatchBehavior.Any: + return true; + + case SymbolApiConventionNameMatchBehavior.Exact: + return string.Equals(name, conventionName, StringComparison.Ordinal); + + case SymbolApiConventionNameMatchBehavior.Prefix: + return IsNameMatchPrefix(); + + case SymbolApiConventionNameMatchBehavior.Suffix: + return IsNameMatchSuffix(); + + default: + return false; + } + + bool IsNameMatchPrefix() + { + if (name.Length < conventionName.Length) + { + return false; + } + + if (name.Length == conventionName.Length) + { + // name = "Post", conventionName = "Post" + return string.Equals(name, conventionName, StringComparison.Ordinal); + } + + if (!name.StartsWith(conventionName, StringComparison.Ordinal)) + { + // name = "GetPerson", conventionName = "Post" + return false; + } + + // Check for name = "PostPerson", conventionName = "Post" + // Verify the first letter after the convention name is upper case. In this case 'P' from "Person" + return char.IsUpper(name[conventionName.Length]); + } + + bool IsNameMatchSuffix() + { + if (name.Length < conventionName.Length) + { + // name = "person", conventionName = "personName" + return false; + } + + if (name.Length == conventionName.Length) + { + // name = id, conventionName = id + return string.Equals(name, conventionName, StringComparison.Ordinal); + } + + // Check for name = personName, conventionName = name + var index = name.Length - conventionName.Length - 1; + if (!char.IsLower(name[index])) + { + // Verify letter before "name" is lowercase. In this case the letter 'n' at the end of "person" + return false; + } + + index++; + if (name[index] != char.ToUpper(conventionName[0])) + { + // Verify the first letter from convention is upper case. In this case 'n' from "name" + return false; + } + + // Match the remaining letters with exact case. i.e. match "ame" from "personName", "name" + index++; + return string.Compare(name, index, conventionName, 1, conventionName.Length - 1, StringComparison.Ordinal) == 0; + } + } + + internal static bool IsTypeMatch(ITypeSymbol type, ITypeSymbol conventionType, SymbolApiConventionTypeMatchBehavior typeMatchBehavior) + { + switch (typeMatchBehavior) + { + case SymbolApiConventionTypeMatchBehavior.Any: + return true; + + case SymbolApiConventionTypeMatchBehavior.AssignableFrom: + return conventionType.IsAssignableFrom(type); + + default: + return false; + } + } + + internal enum SymbolApiConventionTypeMatchBehavior + { + Any, + AssignableFrom + } + + internal enum SymbolApiConventionNameMatchBehavior + { + Any, + Exact, + Prefix, + Suffix, + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/SymbolApiResponseMetadataProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/SymbolApiResponseMetadataProvider.cs new file mode 100644 index 0000000000..2be586958d --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/SymbolApiResponseMetadataProvider.cs @@ -0,0 +1,230 @@ +// 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.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + internal static class SymbolApiResponseMetadataProvider + { + private const string StatusCodeProperty = "StatusCode"; + private const string StatusCodeConstructorParameter = "statusCode"; + private static readonly IList DefaultResponseMetadatas = new[] + { + DeclaredApiResponseMetadata.ImplicitResponse, + }; + + public static IList GetDeclaredResponseMetadata( + in ApiControllerSymbolCache symbolCache, + IMethodSymbol method) + { + var metadataItems = GetResponseMetadataFromMethodAttributes(symbolCache, method); + if (metadataItems.Count != 0) + { + return metadataItems; + } + + var conventionTypeAttributes = GetConventionTypes(symbolCache, method); + metadataItems = GetResponseMetadataFromConventions(symbolCache, method, conventionTypeAttributes); + + if (metadataItems.Count == 0) + { + // If no metadata can be gleaned either through explicit attributes on the method or via a convention, + // declare an implicit 200 status code. + metadataItems = DefaultResponseMetadatas; + } + + return metadataItems; + } + + public static ITypeSymbol GetErrorResponseType( + in ApiControllerSymbolCache symbolCache, + IMethodSymbol method) + { + var errorTypeAttribute = + method.GetAttributes(symbolCache.ProducesErrorResponseTypeAttribute).FirstOrDefault() ?? + method.ContainingType.GetAttributes(symbolCache.ProducesErrorResponseTypeAttribute).FirstOrDefault() ?? + method.ContainingAssembly.GetAttributes(symbolCache.ProducesErrorResponseTypeAttribute).FirstOrDefault(); + + ITypeSymbol errorType = symbolCache.ProblemDetails; + if (errorTypeAttribute != null && + errorTypeAttribute.ConstructorArguments.Length == 1 && + errorTypeAttribute.ConstructorArguments[0].Kind == TypedConstantKind.Type && + errorTypeAttribute.ConstructorArguments[0].Value is ITypeSymbol typeSymbol) + { + errorType = typeSymbol; + } + + return errorType; + } + + private static IList GetResponseMetadataFromConventions( + in ApiControllerSymbolCache symbolCache, + IMethodSymbol method, + IReadOnlyList conventionTypes) + { + var conventionMethod = GetMethodFromConventionMethodAttribute(symbolCache, method); + if (conventionMethod == null) + { + conventionMethod = MatchConventionMethod(symbolCache, method, conventionTypes); + } + + if (conventionMethod != null) + { + return GetResponseMetadataFromMethodAttributes(symbolCache, conventionMethod); + } + + return Array.Empty(); + } + + private static IMethodSymbol GetMethodFromConventionMethodAttribute(in ApiControllerSymbolCache symbolCache, IMethodSymbol method) + { + var attribute = method.GetAttributes(symbolCache.ApiConventionMethodAttribute, inherit: true) + .FirstOrDefault(); + + if (attribute == null) + { + return null; + } + + if (attribute.ConstructorArguments.Length != 2) + { + return null; + } + + if (attribute.ConstructorArguments[0].Kind != TypedConstantKind.Type || + !(attribute.ConstructorArguments[0].Value is ITypeSymbol conventionType)) + { + return null; + } + + if (attribute.ConstructorArguments[1].Kind != TypedConstantKind.Primitive || + !(attribute.ConstructorArguments[1].Value is string conventionMethodName)) + { + return null; + } + + var conventionMethod = conventionType.GetMembers(conventionMethodName) + .FirstOrDefault(m => m.Kind == SymbolKind.Method && m.IsStatic && m.DeclaredAccessibility == Accessibility.Public); + + return (IMethodSymbol)conventionMethod; + } + + private static IMethodSymbol MatchConventionMethod( + in ApiControllerSymbolCache symbolCache, + IMethodSymbol method, + IReadOnlyList conventionTypes) + { + foreach (var conventionType in conventionTypes) + { + foreach (var conventionMethod in conventionType.GetMembers().OfType()) + { + if (!conventionMethod.IsStatic || conventionMethod.DeclaredAccessibility != Accessibility.Public) + { + continue; + } + + if (SymbolApiConventionMatcher.IsMatch(symbolCache, method, conventionMethod)) + { + return conventionMethod; + } + } + } + + return null; + } + + private static IList GetResponseMetadataFromMethodAttributes(in ApiControllerSymbolCache symbolCache, IMethodSymbol methodSymbol) + { + var metadataItems = new List(); + var responseMetadataAttributes = methodSymbol.GetAttributes(symbolCache.ProducesResponseTypeAttribute, inherit: true); + foreach (var attribute in responseMetadataAttributes) + { + var statusCode = GetStatusCode(attribute); + var metadata = DeclaredApiResponseMetadata.ForProducesResponseType(statusCode, attribute, attributeSource: methodSymbol); + + metadataItems.Add(metadata); + } + + var producesDefaultResponse = methodSymbol.GetAttributes(symbolCache.ProducesDefaultResponseTypeAttribute, inherit: true).FirstOrDefault(); + if (producesDefaultResponse != null) + { + metadataItems.Add(DeclaredApiResponseMetadata.ForProducesDefaultResponse(producesDefaultResponse, methodSymbol)); + } + + return metadataItems; + } + + internal static IReadOnlyList GetConventionTypes(in ApiControllerSymbolCache symbolCache, IMethodSymbol method) + { + var attributes = method.ContainingType.GetAttributes(symbolCache.ApiConventionTypeAttribute).ToArray(); + if (attributes.Length == 0) + { + attributes = method.ContainingAssembly.GetAttributes(symbolCache.ApiConventionTypeAttribute).ToArray(); + } + + var conventionTypes = new List(); + for (var i = 0; i < attributes.Length; i++) + { + var attribute = attributes[i]; + if (attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Kind != TypedConstantKind.Type || + !(attribute.ConstructorArguments[0].Value is ITypeSymbol conventionType)) + { + continue; + } + + conventionTypes.Add(conventionType); + } + + return conventionTypes; + } + + internal static int GetStatusCode(AttributeData attribute) + { + const int DefaultStatusCode = 200; + for (var i = 0; i < attribute.NamedArguments.Length; i++) + { + var namedArgument = attribute.NamedArguments[i]; + var namedArgumentValue = namedArgument.Value; + if (string.Equals(namedArgument.Key, StatusCodeProperty, StringComparison.Ordinal) && + namedArgumentValue.Kind == TypedConstantKind.Primitive && + (namedArgumentValue.Type.SpecialType & SpecialType.System_Int32) == SpecialType.System_Int32 && + namedArgumentValue.Value is int statusCode) + { + return statusCode; + } + } + + if (attribute.AttributeConstructor == null) + { + return DefaultStatusCode; + } + + var constructorParameters = attribute.AttributeConstructor.Parameters; + for (var i = 0; i < constructorParameters.Length; i++) + { + var parameter = constructorParameters[i]; + if (string.Equals(parameter.Name, StatusCodeConstructorParameter, StringComparison.Ordinal) && + (parameter.Type.SpecialType & SpecialType.System_Int32) == SpecialType.System_Int32) + { + if (attribute.ConstructorArguments.Length < i) + { + return DefaultStatusCode; + } + + var argument = attribute.ConstructorArguments[i]; + if (argument.Kind == TypedConstantKind.Primitive && argument.Value is int statusCode) + { + return statusCode; + } + } + } + + return DefaultStatusCode; + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiParameterContext.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiParameterContext.cs new file mode 100644 index 0000000000..d88281f490 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiParameterContext.cs @@ -0,0 +1,33 @@ +// 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.Controllers; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing.Template; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + internal class ApiParameterContext + { + public ApiParameterContext( + IModelMetadataProvider metadataProvider, + ControllerActionDescriptor actionDescriptor, + IReadOnlyList routeParameters) + { + MetadataProvider = metadataProvider; + ActionDescriptor = actionDescriptor; + RouteParameters = routeParameters; + + Results = new List(); + } + + public ControllerActionDescriptor ActionDescriptor { get; } + + public IModelMetadataProvider MetadataProvider { get; } + + public IList Results { get; } + + public IReadOnlyList RouteParameters { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs new file mode 100644 index 0000000000..0761a7950a --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs @@ -0,0 +1,283 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + internal class ApiResponseTypeProvider + { + private readonly IModelMetadataProvider _modelMetadataProvider; + private readonly IActionResultTypeMapper _mapper; + private readonly MvcOptions _mvcOptions; + + public ApiResponseTypeProvider( + IModelMetadataProvider modelMetadataProvider, + IActionResultTypeMapper mapper, + MvcOptions mvcOptions) + { + _modelMetadataProvider = modelMetadataProvider; + _mapper = mapper; + _mvcOptions = mvcOptions; + } + + public ICollection GetApiResponseTypes(ControllerActionDescriptor action) + { + // We only provide response info if we can figure out a type that is a user-data type. + // Void /Task object/IActionResult will result in no data. + var declaredReturnType = GetDeclaredReturnType(action); + + var runtimeReturnType = GetRuntimeReturnType(declaredReturnType); + + var responseMetadataAttributes = GetResponseMetadataAttributes(action); + if (!HasSignificantMetadataProvider(responseMetadataAttributes) && + action.Properties.TryGetValue(typeof(ApiConventionResult), out var result)) + { + // Action does not have any conventions. Use conventions on it if present. + var apiConventionResult = (ApiConventionResult)result; + responseMetadataAttributes.AddRange(apiConventionResult.ResponseMetadataProviders); + } + + var defaultErrorType = typeof(void); + if (action.Properties.TryGetValue(typeof(ProducesErrorResponseTypeAttribute), out result)) + { + defaultErrorType = ((ProducesErrorResponseTypeAttribute)result).Type; + } + + var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, runtimeReturnType, defaultErrorType); + return apiResponseTypes; + } + + private static List GetResponseMetadataAttributes(ControllerActionDescriptor action) + { + if (action.FilterDescriptors == null) + { + return new List(); + } + + // This technique for enumerating filters will intentionally ignore any filter that is an IFilterFactory + // while searching for a filter that implements IApiResponseMetadataProvider. + // + // The workaround for that is to implement the metadata interface on the IFilterFactory. + return action.FilterDescriptors + .Select(fd => fd.Filter) + .OfType() + .ToList(); + } + + private ICollection GetApiResponseTypes( + IReadOnlyList responseMetadataAttributes, + Type type, + Type defaultErrorType) + { + var results = new Dictionary(); + + // Get the content type that the action explicitly set to support. + // Walk through all 'filter' attributes in order, and allow each one to see or override + // the results of the previous ones. This is similar to the execution path for content-negotiation. + var contentTypes = new MediaTypeCollection(); + if (responseMetadataAttributes != null) + { + foreach (var metadataAttribute in responseMetadataAttributes) + { + metadataAttribute.SetContentTypes(contentTypes); + + var statusCode = metadataAttribute.StatusCode; + + var apiResponseType = new ApiResponseType + { + Type = metadataAttribute.Type, + StatusCode = statusCode, + IsDefaultResponse = metadataAttribute is IApiDefaultResponseMetadataProvider, + }; + + if (apiResponseType.Type == typeof(void)) + { + if (type != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) + { + // ProducesResponseTypeAttribute's constructor defaults to setting "Type" to void when no value is specified. + // In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a + // [ProducesResponseType(201)] instead of [ProducesResponseType(201, typeof(Person)] when typeof(Person) can be inferred + // from the return type. + apiResponseType.Type = type; + } + else if (IsClientError(statusCode) || apiResponseType.IsDefaultResponse) + { + // Use the default error type for "default" responses or 4xx client errors if no response type is specified. + apiResponseType.Type = defaultErrorType; + } + } + + if (apiResponseType.Type != null) + { + results[apiResponseType.StatusCode] = apiResponseType; + } + } + } + + // Set the default status only when no status has already been set explicitly + if (results.Count == 0 && type != null) + { + results[StatusCodes.Status200OK] = new ApiResponseType + { + StatusCode = StatusCodes.Status200OK, + Type = type, + }; + } + + if (contentTypes.Count == 0) + { + // None of the IApiResponseMetadataProvider specified a content type. This is common for actions that + // specify one or more ProducesResponseType but no ProducesAttribute. In this case, formatters will participate in conneg + // and respond to the incoming request. + // Querying IApiResponseTypeMetadataProvider.GetSupportedContentTypes with "null" should retrieve all supported + // content types that each formatter may respond in. + contentTypes.Add((string)null); + } + + var responseTypes = results.Values; + CalculateResponseFormats(responseTypes, contentTypes); + return responseTypes; + } + + private void CalculateResponseFormats(ICollection responseTypes, MediaTypeCollection declaredContentTypes) + { + var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); + + // Given the content-types that were declared for this action, determine the formatters that support the content-type for the given + // response type. + // 1. Responses that do not specify an type do not have any associated content-type. This usually is meant for status-code only responses such + // as return NotFound(); + // 2. When a type is specified, use GetSupportedContentTypes to expand wildcards and get the range of content-types formatters support. + // 3. When no formatter supports the specified content-type, use the user specified value as is. This is useful in actions where the user + // dictates the content-type. + // e.g. [Produces("application/pdf")] Action() => FileStream("somefile.pdf", "applicaiton/pdf"); + + foreach (var apiResponse in responseTypes) + { + var responseType = apiResponse.Type; + if (responseType == null || responseType == typeof(void)) + { + continue; + } + + apiResponse.ModelMetadata = _modelMetadataProvider.GetMetadataForType(responseType); + + foreach (var contentType in declaredContentTypes) + { + var isSupportedContentType = false; + + foreach (var responseTypeMetadataProvider in responseTypeMetadataProviders) + { + var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes( + contentType, + responseType); + + if (formatterSupportedContentTypes == null) + { + continue; + } + + isSupportedContentType = true; + + foreach (var formatterSupportedContentType in formatterSupportedContentTypes) + { + apiResponse.ApiResponseFormats.Add(new ApiResponseFormat + { + Formatter = (IOutputFormatter)responseTypeMetadataProvider, + MediaType = formatterSupportedContentType, + }); + } + } + + if (!isSupportedContentType && contentType != null) + { + // No output formatter was found that supports this content type. Add the user specified content type as-is to the result. + apiResponse.ApiResponseFormats.Add(new ApiResponseFormat + { + MediaType = contentType, + }); + } + } + } + } + + private Type GetDeclaredReturnType(ControllerActionDescriptor action) + { + var declaredReturnType = action.MethodInfo.ReturnType; + if (declaredReturnType == typeof(void) || + declaredReturnType == typeof(Task)) + { + return typeof(void); + } + + // Unwrap the type if it's a Task. The Task (non-generic) case was already handled. + var unwrappedType = declaredReturnType; + if (declaredReturnType.IsGenericType && + declaredReturnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + unwrappedType = declaredReturnType.GetGenericArguments()[0]; + } + + // If the method is declared to return IActionResult or a derived class, that information + // isn't valuable to the formatter. + if (typeof(IActionResult).IsAssignableFrom(unwrappedType)) + { + return null; + } + + // If we get here, the type should be a user-defined data type or an envelope type + // like ActionResult. The mapper service will unwrap envelopes. + unwrappedType = _mapper.GetResultDataType(unwrappedType); + return unwrappedType; + } + + private Type GetRuntimeReturnType(Type declaredReturnType) + { + // If we get here, then a filter didn't give us an answer, so we need to figure out if we + // want to use the declared return type. + // + // We've already excluded Task, void, and IActionResult at this point. + // + // If the action might return any object, then assume we don't know anything about it. + if (declaredReturnType == typeof(object)) + { + return null; + } + + return declaredReturnType; + } + + private static bool IsClientError(int statusCode) + { + return statusCode >= 400 && statusCode < 500; + } + + private static bool HasSignificantMetadataProvider(IReadOnlyList providers) + { + for (var i = 0; i < providers.Count; i++) + { + var provider = providers[i]; + + if (provider is ProducesAttribute producesAttribute && producesAttribute.Type is null) + { + // ProducesAttribute that does not specify type is considered not significant. + continue; + } + + // Any other IApiResponseMetadataProvider is considered significant + return true; + } + + return false; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs index 2ed34e730a..6fd8c7e65f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs @@ -4,8 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Formatters; @@ -27,6 +25,8 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { private readonly MvcOptions _mvcOptions; private readonly IActionResultTypeMapper _mapper; + private readonly ApiResponseTypeProvider _responseTypeProvider; + private readonly RouteOptions _routeOptions; private readonly IInlineConstraintResolver _constraintResolver; private readonly IModelMetadataProvider _modelMetadataProvider; @@ -42,10 +42,8 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer IOptions optionsAccessor, IInlineConstraintResolver constraintResolver, IModelMetadataProvider modelMetadataProvider) + : this(optionsAccessor, constraintResolver, modelMetadataProvider, null) { - _mvcOptions = optionsAccessor.Value; - _constraintResolver = constraintResolver; - _modelMetadataProvider = modelMetadataProvider; } /// @@ -56,6 +54,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer /// constraints. /// The . /// The . + [Obsolete("This constructor is obsolete and will be removed in a future release.")] public DefaultApiDescriptionProvider( IOptions optionsAccessor, IInlineConstraintResolver constraintResolver, @@ -66,6 +65,31 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer _constraintResolver = constraintResolver; _modelMetadataProvider = modelMetadataProvider; _mapper = mapper; + _responseTypeProvider = new ApiResponseTypeProvider(modelMetadataProvider, mapper, _mvcOptions); + } + + /// + /// Creates a new instance of . + /// + /// The accessor for . + /// The used for resolving inline + /// constraints. + /// The . + /// The . + /// The accessor for . + public DefaultApiDescriptionProvider( + IOptions optionsAccessor, + IInlineConstraintResolver constraintResolver, + IModelMetadataProvider modelMetadataProvider, + IActionResultTypeMapper mapper, + IOptions routeOptions) + { + _mvcOptions = optionsAccessor.Value; + _constraintResolver = constraintResolver; + _modelMetadataProvider = modelMetadataProvider; + _mapper = mapper; + _responseTypeProvider = new ApiResponseTypeProvider(modelMetadataProvider, mapper, _mvcOptions); + _routeOptions = routeOptions.Value; } /// @@ -127,15 +151,8 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer } var requestMetadataAttributes = GetRequestMetadataAttributes(action); - var responseMetadataAttributes = GetResponseMetadataAttributes(action); - // We only provide response info if we can figure out a type that is a user-data type. - // Void /Task object/IActionResult will result in no data. - var declaredReturnType = GetDeclaredReturnType(action); - - var runtimeReturnType = GetRuntimeReturnType(declaredReturnType); - - var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, runtimeReturnType); + var apiResponseTypes = _responseTypeProvider.GetApiResponseTypes(action); foreach (var apiResponseType in apiResponseTypes) { apiDescription.SupportedResponseTypes.Add(apiResponseType); @@ -184,7 +201,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { var visitor = new PseudoModelBindingVisitor(context, actionParameter); - ModelMetadata metadata = null; + ModelMetadata metadata; if (_mvcOptions.AllowValidatingTopLevelNodes && actionParameter is ControllerParameterDescriptor controllerParameterDescriptor && _modelMetadataProvider is ModelMetadataProvider provider) @@ -239,6 +256,21 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer } // Next, we want to join up any route parameters with those discovered from the action's parameters. + // This will result us in creating a parameter representation for each route parameter that does not + // have a mapping parameter or bound property. + ProcessRouteParameters(context); + + // Set IsRequired=true + ProcessIsRequired(context); + + // Set DefaultValue + ProcessParameterDefaultValue(context); + + return context.Results; + } + + private void ProcessRouteParameters(ApiParameterContext context) + { var routeParameters = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var routeParameter in context.RouteParameters) { @@ -278,8 +310,46 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer Source = BindingSource.Path, }); } + } - return context.Results; + internal static void ProcessIsRequired(ApiParameterContext context) + { + foreach (var parameter in context.Results) + { + if (parameter.Source == BindingSource.Body) + { + parameter.IsRequired = true; + } + + if (parameter.ModelMetadata != null && parameter.ModelMetadata.IsBindingRequired) + { + parameter.IsRequired = true; + } + + if (parameter.Source == BindingSource.Path && parameter.RouteInfo != null && !parameter.RouteInfo.IsOptional) + { + parameter.IsRequired = true; + } + } + } + + internal static void ProcessParameterDefaultValue(ApiParameterContext context) + { + foreach (var parameter in context.Results) + { + if (parameter.Source == BindingSource.Path) + { + parameter.DefaultValue = parameter.RouteInfo?.DefaultValue; + } + else + { + if (parameter.ParameterDescriptor is ControllerParameterDescriptor controllerParameter && + ParameterDefaultValues.TryGetDeclaredParameterDefaultValue(controllerParameter.ParameterInfo, out var defaultValue)) + { + parameter.DefaultValue = defaultValue; + } + } + } } private ApiParameterRouteInfo CreateRouteInfo(TemplatePart routeParameter) @@ -339,7 +409,9 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { if (part.IsLiteral) { - currentSegment += part.Text; + currentSegment += _routeOptions.LowercaseUrls ? + part.Text.ToLowerInvariant() : + part.Text; } else if (part.IsParameter) { @@ -406,151 +478,6 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer return contentTypes; } - private IReadOnlyList GetApiResponseTypes( - IApiResponseMetadataProvider[] responseMetadataAttributes, - Type type) - { - var results = new List(); - - // Build list of all possible return types (and status codes) for an action. - var objectTypes = new Dictionary(); - - // Get the content type that the action explicitly set to support. - // Walk through all 'filter' attributes in order, and allow each one to see or override - // the results of the previous ones. This is similar to the execution path for content-negotiation. - var contentTypes = new MediaTypeCollection(); - if (responseMetadataAttributes != null) - { - foreach (var metadataAttribute in responseMetadataAttributes) - { - metadataAttribute.SetContentTypes(contentTypes); - if (metadataAttribute.Type == typeof(void) && - type != null && - (metadataAttribute.StatusCode == StatusCodes.Status200OK || metadataAttribute.StatusCode == StatusCodes.Status201Created)) - { - // ProducesResponseTypeAttribute's constructor defaults to setting "Type" to void when no value is specified. - // In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a - // [ProducesResponseType(201)] instead of [ProducesResponseType(201, typeof(Person)] when typeof(Person) can be inferred - // from the return type. - objectTypes[metadataAttribute.StatusCode] = type; - } - else if (metadataAttribute.Type != null) - { - objectTypes[metadataAttribute.StatusCode] = metadataAttribute.Type; - } - } - } - - // Set the default status only when no status has already been set explicitly - if (objectTypes.Count == 0 - && type != null) - { - objectTypes[StatusCodes.Status200OK] = type; - } - - if (contentTypes.Count == 0) - { - contentTypes.Add((string)null); - } - - var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); - - foreach (var objectType in objectTypes) - { - if (objectType.Value == typeof(void)) - { - results.Add(new ApiResponseType() - { - StatusCode = objectType.Key, - Type = objectType.Value - }); - - continue; - } - - var apiResponseType = new ApiResponseType() - { - Type = objectType.Value, - StatusCode = objectType.Key, - ModelMetadata = _modelMetadataProvider.GetMetadataForType(objectType.Value) - }; - - foreach (var contentType in contentTypes) - { - foreach (var responseTypeMetadataProvider in responseTypeMetadataProviders) - { - var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes( - contentType, - objectType.Value); - - if (formatterSupportedContentTypes == null) - { - continue; - } - - foreach (var formatterSupportedContentType in formatterSupportedContentTypes) - { - apiResponseType.ApiResponseFormats.Add(new ApiResponseFormat() - { - Formatter = (IOutputFormatter)responseTypeMetadataProvider, - MediaType = formatterSupportedContentType, - }); - } - } - } - - results.Add(apiResponseType); - } - - return results; - } - - private Type GetDeclaredReturnType(ControllerActionDescriptor action) - { - var declaredReturnType = action.MethodInfo.ReturnType; - if (declaredReturnType == typeof(void) || - declaredReturnType == typeof(Task)) - { - return typeof(void); - } - - // Unwrap the type if it's a Task. The Task (non-generic) case was already handled. - Type unwrappedType = declaredReturnType; - if (declaredReturnType.IsGenericType && - declaredReturnType.GetGenericTypeDefinition() == typeof(Task<>)) - { - unwrappedType = declaredReturnType.GetGenericArguments()[0]; - } - - // If the method is declared to return IActionResult or a derived class, that information - // isn't valuable to the formatter. - if (typeof(IActionResult).IsAssignableFrom(unwrappedType)) - { - return null; - } - - // If we get here, the type should be a user-defined data type or an envelope type - // like ActionResult. The mapper service will unwrap envelopes. - unwrappedType = _mapper.GetResultDataType(unwrappedType); - return unwrappedType; - } - - private Type GetRuntimeReturnType(Type declaredReturnType) - { - // If we get here, then a filter didn't give us an answer, so we need to figure out if we - // want to use the declared return type. - // - // We've already excluded Task, void, and IActionResult at this point. - // - // If the action might return any object, then assume we don't know anything about it. - if (declaredReturnType == typeof(object)) - { - return null; - } - - return declaredReturnType; - } - private IApiRequestMetadataProvider[] GetRequestMetadataAttributes(ControllerActionDescriptor action) { if (action.FilterDescriptors == null) @@ -568,46 +495,6 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer .ToArray(); } - private IApiResponseMetadataProvider[] GetResponseMetadataAttributes(ControllerActionDescriptor action) - { - if (action.FilterDescriptors == null) - { - return null; - } - - // This technique for enumerating filters will intentionally ignore any filter that is an IFilterFactory - // while searching for a filter that implements IApiResponseMetadataProvider. - // - // The workaround for that is to implement the metadata interface on the IFilterFactory. - return action.FilterDescriptors - .Select(fd => fd.Filter) - .OfType() - .ToArray(); - } - - private class ApiParameterContext - { - public ApiParameterContext( - IModelMetadataProvider metadataProvider, - ControllerActionDescriptor actionDescriptor, - IReadOnlyList routeParameters) - { - MetadataProvider = metadataProvider; - ActionDescriptor = actionDescriptor; - RouteParameters = routeParameters; - - Results = new List(); - } - - public ControllerActionDescriptor ActionDescriptor { get; } - - public IModelMetadataProvider MetadataProvider { get; } - - public IList Results { get; } - - public IReadOnlyList RouteParameters { get; } - } - private class ApiParameterDescriptionContext { public ModelMetadata ModelMetadata { get; set; } @@ -772,7 +659,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer } } - private struct PropertyKey + private readonly struct PropertyKey { public readonly Type ContainerType; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj index 9ea5be90f4..308a2f225b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ApiExplorer/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC API explorer functionality for discovering metadata such as the list of controllers and actions, and their URLs and allowed HTTP methods. @@ -10,10 +10,6 @@ - - - - diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedAtActionResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedAtActionResult.cs index dd0480c39e..3c392d3abe 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedAtActionResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedAtActionResult.cs @@ -4,6 +4,7 @@ using System; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -14,8 +15,11 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that returns a Accepted (202) response with a Location header. /// + [DefaultStatusCode(DefaultStatusCode)] public class AcceptedAtActionResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status202Accepted; + /// /// Initializes a new instance of the with the values /// provided. @@ -28,13 +32,13 @@ namespace Microsoft.AspNetCore.Mvc string actionName, string controllerName, object routeValues, - object value) + [ActionResultObjectValue] object value) : base(value) { ActionName = actionName; ControllerName = controllerName; RouteValues = routeValues == null ? null : new RouteValueDictionary(routeValues); - StatusCode = StatusCodes.Status202Accepted; + StatusCode = DefaultStatusCode; } /// @@ -91,4 +95,4 @@ namespace Microsoft.AspNetCore.Mvc context.HttpContext.Response.Headers[HeaderNames.Location] = url; } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedAtRouteResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedAtRouteResult.cs index bfde81d3ae..e571afe475 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedAtRouteResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedAtRouteResult.cs @@ -8,21 +8,25 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { /// /// An that returns a Accepted (202) response with a Location header. /// + [DefaultStatusCode(DefaultStatusCode)] public class AcceptedAtRouteResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status202Accepted; + /// /// Initializes a new instance of the class with the values /// provided. /// /// The route data to use for generating the URL. /// The value to format in the entity body. - public AcceptedAtRouteResult(object routeValues, object value) + public AcceptedAtRouteResult(object routeValues, [ActionResultObjectValue] object value) : this(routeName: null, routeValues: routeValues, value: value) { } @@ -37,12 +41,12 @@ namespace Microsoft.AspNetCore.Mvc public AcceptedAtRouteResult( string routeName, object routeValues, - object value) + [ActionResultObjectValue] object value) : base(value) { RouteName = routeName; RouteValues = routeValues == null ? null : new RouteValueDictionary(routeValues); - StatusCode = StatusCodes.Status202Accepted; + StatusCode = DefaultStatusCode; } /// @@ -87,4 +91,4 @@ namespace Microsoft.AspNetCore.Mvc context.HttpContext.Response.Headers[HeaderNames.Location] = url; } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedResult.cs index 30b33a4fc5..0279285695 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AcceptedResult.cs @@ -3,6 +3,7 @@ using System; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc @@ -10,8 +11,11 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that returns an Accepted (202) response with a Location header. /// + [DefaultStatusCode(DefaultStatusCode)] public class AcceptedResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status202Accepted; + /// /// Initializes a new instance of the class with the values /// provided. @@ -19,7 +23,7 @@ namespace Microsoft.AspNetCore.Mvc public AcceptedResult() : base(value: null) { - StatusCode = StatusCodes.Status202Accepted; + StatusCode = DefaultStatusCode; } /// @@ -28,11 +32,11 @@ namespace Microsoft.AspNetCore.Mvc /// /// The location at which the status of requested content can be monitored. /// The value to format in the entity body. - public AcceptedResult(string location, object value) + public AcceptedResult(string location, [ActionResultObjectValue] object value) : base(value) { Location = location; - StatusCode = StatusCodes.Status202Accepted; + StatusCode = DefaultStatusCode; } /// @@ -42,7 +46,7 @@ namespace Microsoft.AspNetCore.Mvc /// The location at which the status of requested content can be monitored /// It is an optional parameter and may be null /// The value to format in the entity body. - public AcceptedResult(Uri locationUri, object value) + public AcceptedResult(Uri locationUri, [ActionResultObjectValue] object value) : base(value) { if (locationUri == null) @@ -59,7 +63,7 @@ namespace Microsoft.AspNetCore.Mvc Location = locationUri.GetComponents(UriComponents.SerializationInfoString, UriFormat.UriEscaped); } - StatusCode = StatusCodes.Status202Accepted; + StatusCode = DefaultStatusCode; } /// @@ -83,4 +87,4 @@ namespace Microsoft.AspNetCore.Mvc } } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ActionResultOfT.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ActionResultOfT.cs index f443d78805..048f1be54a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ActionResultOfT.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ActionResultOfT.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc @@ -18,15 +19,27 @@ namespace Microsoft.AspNetCore.Mvc /// The value. public ActionResult(TValue value) { + if (typeof(IActionResult).IsAssignableFrom(typeof(TValue))) + { + var error = Resources.FormatInvalidTypeTForActionResultOfT(typeof(TValue), "ActionResult"); + throw new ArgumentException(error); + } + Value = value; } /// - /// Intializes a new instance of using the specified . + /// Initializes a new instance of using the specified . /// /// The . public ActionResult(ActionResult result) { + if (typeof(IActionResult).IsAssignableFrom(typeof(TValue))) + { + var error = Resources.FormatInvalidTypeTForActionResultOfT(typeof(TValue), "ActionResult"); + throw new ArgumentException(error); + } + Result = result ?? throw new ArgumentNullException(nameof(result)); } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AntiforgeryValidationFailedResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AntiforgeryValidationFailedResult.cs new file mode 100644 index 0000000000..95e0643127 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/AntiforgeryValidationFailedResult.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 Microsoft.AspNetCore.Mvc.Core.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// A used for antiforgery validation + /// failures. Use to + /// match for validation failures inside MVC result filters. + /// + public class AntiforgeryValidationFailedResult : BadRequestResult, IAntiforgeryValidationFailedResult + { } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs index e955f25768..26a796130c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiBehaviorOptions.cs @@ -2,7 +2,10 @@ // 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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -10,17 +13,34 @@ namespace Microsoft.AspNetCore.Mvc /// /// Options used to configure behavior for types annotated with . /// - public class ApiBehaviorOptions + public class ApiBehaviorOptions : IEnumerable { + private readonly CompatibilitySwitch _suppressMapClientErrors; + private readonly CompatibilitySwitch _suppressUseValidationProblemDetailsForInvalidModelStateResponses; + private readonly CompatibilitySwitch _allowInferringBindingSourceForCollectionTypesAsFromQuery; + private readonly ICompatibilitySwitch[] _switches; + private Func _invalidModelStateResponseFactory; + /// + /// Creates a new instance of . + /// + public ApiBehaviorOptions() + { + _suppressMapClientErrors = new CompatibilitySwitch(nameof(SuppressMapClientErrors)); + _suppressUseValidationProblemDetailsForInvalidModelStateResponses = new CompatibilitySwitch(nameof(SuppressUseValidationProblemDetailsForInvalidModelStateResponses)); + _allowInferringBindingSourceForCollectionTypesAsFromQuery = new CompatibilitySwitch(nameof(AllowInferringBindingSourceForCollectionTypesAsFromQuery)); + _switches = new[] + { + _suppressMapClientErrors, + _suppressUseValidationProblemDetailsForInvalidModelStateResponses, + _allowInferringBindingSourceForCollectionTypesAsFromQuery + }; + } + /// /// Delegate invoked on actions annotated with to convert invalid /// into an - /// - /// By default, the delegate produces a that wraps a serialized form - /// of . - /// /// public Func InvalidModelStateResponseFactory { @@ -52,5 +72,141 @@ namespace Microsoft.AspNetCore.Mvc /// that are bound from form data. /// public bool SuppressConsumesConstraintForFormFileParameters { get; set; } + + /// + /// Gets or sets a value that determines if controllers with + /// transform certain certain client errors. + /// + /// When false, a result filter is added to API controller actions that transforms . + /// By default, is used to map to a + /// instance (returned as the value for ). + /// + /// + /// To customize the output of the filter (for e.g. to return a different error type), register a custom + /// implementation of of in the service collection. + /// + /// + /// + /// 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 SuppressMapClientErrors + { + // Note: When compatibility switches are removed in 3.0, this property should be retained as a regular boolean property. + get => _suppressMapClientErrors.Value; + set => _suppressMapClientErrors.Value = value; + } + + /// + /// Gets or sets a value that determines if controllers annotated with respond using + /// in . + /// + /// When , returns errors in + /// as a . Otherwise, returns the errors + /// in the format determined by . + /// + /// + /// + /// 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 SuppressUseValidationProblemDetailsForInvalidModelStateResponses + { + get => _suppressUseValidationProblemDetailsForInvalidModelStateResponses.Value; + set => _suppressUseValidationProblemDetailsForInvalidModelStateResponses.Value = value; + } + + /// + /// Gets or sets a value that determines if for collection types + /// (). + /// + /// When , the binding source for collection types is inferred as . + /// Otherwise is inferred. + /// + /// + /// + /// 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 takes + /// 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 AllowInferringBindingSourceForCollectionTypesAsFromQuery + { + get => _allowInferringBindingSourceForCollectionTypesAsFromQuery.Value; + set => _allowInferringBindingSourceForCollectionTypesAsFromQuery.Value = value; + } + + /// + /// Gets a map of HTTP status codes to . Configured values + /// are used to transform to an + /// instance where the is . + /// + /// Use of this feature can be disabled by resetting . + /// + /// + public IDictionary ClientErrorMapping { get; } = + new Dictionary(); + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)_switches).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiControllerAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiControllerAttribute.cs index 0f1f1627bf..0be60296de 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiControllerAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiControllerAttribute.cs @@ -2,16 +2,20 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using Microsoft.AspNetCore.Mvc.Internal; namespace Microsoft.AspNetCore.Mvc { /// - /// Indicates that a type and all derived types are used to serve HTTP API responses. The presence of - /// this attribute can be used to target conventions, filters and other behaviors based on the purpose - /// of the controller. + /// Indicates that a type and all derived types are used to serve HTTP API responses. + /// + /// Controllers decorated with this attribute are configured with features and behavior targeted at improving the + /// developer experience for building APIs. + /// + /// + /// When decorated on an assembly, all controllers in the assembly will be treated as controllers with API behavior. + /// /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class, AllowMultiple = false, Inherited = true)] public class ApiControllerAttribute : ControllerAttribute, IApiBehaviorMetadata { } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionMethodAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionMethodAttribute.cs new file mode 100644 index 0000000000..8efb21d871 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionMethodAttribute.cs @@ -0,0 +1,76 @@ +// 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 System.Reflection; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Core; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// API conventions to be applied to a controller action. + /// + /// API conventions are used to influence the output of ApiExplorer. + /// can be used to specify an exact convention method that applies + /// to an action. for details about applying conventions at + /// the assembly or controller level. + /// + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class ApiConventionMethodAttribute : Attribute + { + /// + /// Initializes an instance using and + /// the specified . + /// + /// + /// The of the convention. + /// + /// Conventions must be static types. Methods in a convention are + /// matched to an action method using rules specified by + /// that may be applied to a method name or it's parameters and + /// that are applied to parameters. + /// + /// + /// The method name. + public ApiConventionMethodAttribute(Type conventionType, string methodName) + { + ConventionType = conventionType ?? throw new ArgumentNullException(nameof(conventionType)); + ApiConventionTypeAttribute.EnsureValid(conventionType); + + if (string.IsNullOrEmpty(methodName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(methodName)); + } + + Method = GetConventionMethod(conventionType, methodName); + } + + private static MethodInfo GetConventionMethod(Type conventionType, string methodName) + { + var methods = conventionType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(method => method.Name == methodName) + .ToArray(); + + if (methods.Length == 0) + { + throw new ArgumentException(Resources.FormatApiConventionMethod_NoMethodFound(methodName, conventionType), nameof(methodName)); + } + else if (methods.Length > 1) + { + throw new ArgumentException(Resources.FormatApiConventionMethod_AmbiguousMethodName(methodName, conventionType), nameof(methodName)); + } + + return methods[0]; + } + + /// + /// Gets the convention type. + /// + public Type ConventionType { get; } + + internal MethodInfo Method { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs new file mode 100644 index 0000000000..e546a258ea --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs @@ -0,0 +1,90 @@ +// 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 System.Reflection; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// API conventions to be applied to an assembly containing MVC controllers or a single controller. + /// + /// API conventions are used to influence the output of ApiExplorer. + /// Conventions must be static types. Methods in a convention are + /// matched to an action method using rules specified by + /// that may be applied to a method name or it's parameters and + /// that are applied to parameters. + /// + /// + /// When no attributes are found specifying the behavior, MVC matches method names and parameter names are matched + /// using and parameter types are matched + /// using . + /// + /// + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + public sealed class ApiConventionTypeAttribute : Attribute + { + /// + /// Initializes an instance using . + /// + /// + /// The of the convention. + /// + /// Conventions must be static types. Methods in a convention are + /// matched to an action method using rules specified by + /// that may be applied to a method name or it's parameters and + /// that are applied to parameters. + /// + /// + public ApiConventionTypeAttribute(Type conventionType) + { + ConventionType = conventionType ?? throw new ArgumentNullException(nameof(conventionType)); + EnsureValid(conventionType); + } + + /// + /// Gets the convention type. + /// + public Type ConventionType { get; } + + internal static void EnsureValid(Type conventionType) + { + if (!conventionType.IsSealed || !conventionType.IsAbstract) + { + // Conventions must be static viz abstract + sealed. + throw new ArgumentException(Resources.FormatApiConventionMustBeStatic(conventionType), nameof(conventionType)); + } + + foreach (var method in conventionType.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + var unsupportedAttributes = method.GetCustomAttributes(inherit: true) + .Where(attribute => !IsAllowedAttribute(attribute)) + .ToArray(); + + if (unsupportedAttributes.Length == 0) + { + continue; + } + + var methodDisplayName = TypeNameHelper.GetTypeDisplayName(method.DeclaringType) + "." + method.Name; + var errorMessage = Resources.FormatApiConvention_UnsupportedAttributesOnConvention( + methodDisplayName, + Environment.NewLine + string.Join(Environment.NewLine, unsupportedAttributes) + Environment.NewLine, + $"{nameof(ProducesResponseTypeAttribute)}, {nameof(ProducesDefaultResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"); + + throw new ArgumentException(errorMessage, nameof(conventionType)); + } + } + + private static bool IsAllowedAttribute(object attribute) + { + return attribute is ProducesResponseTypeAttribute || + attribute is ProducesDefaultResponseTypeAttribute || + attribute is ApiConventionNameMatchAttribute; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionMatcher.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionMatcher.cs new file mode 100644 index 0000000000..e080ce4ef4 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionMatcher.cs @@ -0,0 +1,175 @@ +// 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.Reflection; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + internal static class ApiConventionMatcher + { + internal static bool IsMatch(MethodInfo methodInfo, MethodInfo conventionMethod) + { + return MethodMatches() && ParametersMatch(); + + bool MethodMatches() + { + var methodNameMatchBehavior = GetNameMatchBehavior(conventionMethod); + return IsNameMatch(methodInfo.Name, conventionMethod.Name, methodNameMatchBehavior); + } + + bool ParametersMatch() + { + var methodParameters = methodInfo.GetParameters(); + var conventionMethodParameters = conventionMethod.GetParameters(); + + for (var i = 0; i < conventionMethodParameters.Length; i++) + { + var conventionParameter = conventionMethodParameters[i]; + if (conventionParameter.IsDefined(typeof(ParamArrayAttribute))) + { + return true; + } + + if (methodParameters.Length <= i) + { + return false; + } + + var nameMatchBehavior = GetNameMatchBehavior(conventionParameter); + var typeMatchBehavior = GetTypeMatchBehavior(conventionParameter); + + if (!IsTypeMatch(methodParameters[i].ParameterType, conventionParameter.ParameterType, typeMatchBehavior) || + !IsNameMatch(methodParameters[i].Name, conventionParameter.Name, nameMatchBehavior)) + { + return false; + } + } + + // Ensure convention has at least as many parameters as the method. params convention argument are handled + // inside the for loop. + return methodParameters.Length == conventionMethodParameters.Length; + } + } + + internal static ApiConventionNameMatchBehavior GetNameMatchBehavior(ICustomAttributeProvider attributeProvider) + { + var attribute = GetCustomAttribute(attributeProvider); + return attribute?.MatchBehavior ?? ApiConventionNameMatchBehavior.Exact; + } + + internal static ApiConventionTypeMatchBehavior GetTypeMatchBehavior(ICustomAttributeProvider attributeProvider) + { + var attribute = GetCustomAttribute(attributeProvider); + return attribute?.MatchBehavior ?? ApiConventionTypeMatchBehavior.AssignableFrom; + } + + private static TAttribute GetCustomAttribute(ICustomAttributeProvider attributeProvider) + { + var attributes = attributeProvider.GetCustomAttributes(inherit: false); + for (var i = 0; i < attributes.Length; i++) + { + if (attributes[i] is TAttribute attribute) + { + return attribute; + } + } + + return default; + } + + internal static bool IsNameMatch(string name, string conventionName, ApiConventionNameMatchBehavior nameMatchBehavior) + { + switch (nameMatchBehavior) + { + case ApiConventionNameMatchBehavior.Any: + return true; + + case ApiConventionNameMatchBehavior.Exact: + return string.Equals(name, conventionName, StringComparison.Ordinal); + + case ApiConventionNameMatchBehavior.Prefix: + return IsNameMatchPrefix(); + + case ApiConventionNameMatchBehavior.Suffix: + return IsNameMatchSuffix(); + + default: + return false; + } + + bool IsNameMatchPrefix() + { + if (name.Length < conventionName.Length) + { + return false; + } + + if (name.Length == conventionName.Length) + { + // name = "Post", conventionName = "Post" + return string.Equals(name, conventionName, StringComparison.Ordinal); + } + + if (!name.StartsWith(conventionName, StringComparison.Ordinal)) + { + // name = "GetPerson", conventionName = "Post" + return false; + } + + // Check for name = "PostPerson", conventionName = "Post" + // Verify the first letter after the convention name is upper case. In this case 'P' from "Person" + return char.IsUpper(name[conventionName.Length]); + } + + bool IsNameMatchSuffix() + { + if (name.Length < conventionName.Length) + { + // name = "person", conventionName = "personName" + return false; + } + + if (name.Length == conventionName.Length) + { + // name = id, conventionName = id + return string.Equals(name, conventionName, StringComparison.Ordinal); + } + + // Check for name = personName, conventionName = name + var index = name.Length - conventionName.Length - 1; + if (!char.IsLower(name[index])) + { + // Verify letter before "name" is lowercase. In this case the letter 'n' at the end of "person" + return false; + } + + index++; + if (name[index] != char.ToUpper(conventionName[0])) + { + // Verify the first letter from convention is upper case. In this case 'n' from "name" + return false; + } + + // Match the remaining letters with exact case. i.e. match "ame" from "personName", "name" + index++; + return string.Compare(name, index, conventionName, 1, conventionName.Length - 1, StringComparison.Ordinal) == 0; + } + } + + internal static bool IsTypeMatch(Type type, Type conventionType, ApiConventionTypeMatchBehavior typeMatchBehavior) + { + switch (typeMatchBehavior) + { + case ApiConventionTypeMatchBehavior.Any: + return true; + + case ApiConventionTypeMatchBehavior.AssignableFrom: + return conventionType.IsAssignableFrom(type); + + default: + return false; + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchAttribute.cs new file mode 100644 index 0000000000..2da8950dec --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchAttribute.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 System; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + /// + /// Determines the matching behavior an API convention method or parameter by name. + /// for supported options. + /// . + /// + /// + /// is used if no value for this + /// attribute is specified on a convention method or parameter. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public sealed class ApiConventionNameMatchAttribute : Attribute + { + /// + /// Initializes a new instance of . + /// + /// The . + public ApiConventionNameMatchAttribute(ApiConventionNameMatchBehavior matchBehavior) + { + MatchBehavior = matchBehavior; + } + + /// + /// Gets the . + /// + public ApiConventionNameMatchBehavior MatchBehavior { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchBehavior.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchBehavior.cs new file mode 100644 index 0000000000..b4775fbf05 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchBehavior.cs @@ -0,0 +1,39 @@ +// 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.AspNetCore.Mvc.ApiExplorer +{ + /// + /// The behavior for matching the name of a convention parameter or method. + /// + public enum ApiConventionNameMatchBehavior + { + /// + /// Matches any name. Use this if the parameter or method name does not need to be matched. + /// + Any, + + /// + /// The parameter or method name must exactly match the convention. + /// + Exact, + + /// + /// The parameter or method name in the convention is a proper prefix. + /// + /// Casing is used to delineate words in a given name. For instance, with this behavior + /// the convention name "Get" will match "Get", "GetPerson" or "GetById", but not "getById", "Getaway". + /// + /// + Prefix, + + /// + /// The parameter or method name in the convention is a proper suffix. + /// + /// Casing is used to delineate words in a given name. For instance, with this behavior + /// the convention name "id" will match "id", or "personId" but not "grid" or "personid". + /// + /// + Suffix, + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs new file mode 100644 index 0000000000..aaef7d2d24 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs @@ -0,0 +1,67 @@ +// 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 System.Reflection; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + /// + /// Metadata associated with an action method via API convention. + /// + public sealed class ApiConventionResult + { + public ApiConventionResult(IReadOnlyList responseMetadataProviders) + { + ResponseMetadataProviders = responseMetadataProviders ?? + throw new ArgumentNullException(nameof(responseMetadataProviders)); + } + + public IReadOnlyList ResponseMetadataProviders { get; } + + internal static bool TryGetApiConvention( + MethodInfo method, + ApiConventionTypeAttribute[] apiConventionAttributes, + out ApiConventionResult result) + { + var apiConventionMethodAttribute = method.GetCustomAttribute(inherit: true); + var conventionMethod = apiConventionMethodAttribute?.Method; + if (conventionMethod == null) + { + conventionMethod = GetConventionMethod(method, apiConventionAttributes); + } + + if (conventionMethod != null) + { + var metadataProviders = conventionMethod.GetCustomAttributes(inherit: false) + .OfType() + .ToArray(); + + result = new ApiConventionResult(metadataProviders); + return true; + } + + result = null; + return false; + } + + private static MethodInfo GetConventionMethod(MethodInfo method, ApiConventionTypeAttribute[] apiConventionAttributes) + { + foreach (var attribute in apiConventionAttributes) + { + var conventionMethods = attribute.ConventionType.GetMethods(BindingFlags.Public | BindingFlags.Static); + foreach (var conventionMethod in conventionMethods) + { + if (ApiConventionMatcher.IsMatch(method, conventionMethod)) + { + return conventionMethod; + } + } + } + + return null; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchAttribute.cs new file mode 100644 index 0000000000..657da6ef3d --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchAttribute.cs @@ -0,0 +1,27 @@ +// 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.ApiExplorer +{ + /// + /// Determines the matching behavior an API convention parameter by type. + /// for supported options. + /// . + /// + /// + /// is used if no value for this + /// attribute is specified on a convention parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] + public sealed class ApiConventionTypeMatchAttribute : Attribute + { + public ApiConventionTypeMatchAttribute(ApiConventionTypeMatchBehavior matchBehavior) + { + MatchBehavior = matchBehavior; + } + + public ApiConventionTypeMatchBehavior MatchBehavior { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchBehavior.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchBehavior.cs new file mode 100644 index 0000000000..300c7255f9 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchBehavior.cs @@ -0,0 +1,22 @@ +// 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.AspNetCore.Mvc.ApiExplorer +{ + /// + /// The behavior for matching the name of a convention parameter. + /// + public enum ApiConventionTypeMatchBehavior + { + /// + /// Matches any type. Use this if the parameter does not need to be matched. + /// + Any, + + /// + /// The parameter in the convention is the exact type or a subclass of the type + /// specified in the convention. + /// + AssignableFrom, + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiDefaultResponseMetadataProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiDefaultResponseMetadataProvider.cs new file mode 100644 index 0000000000..a841fc9f0a --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiDefaultResponseMetadataProvider.cs @@ -0,0 +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. + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + /// + /// Provides a return type for all HTTP status codes that are not covered by other instances. + /// + public interface IApiDefaultResponseMetadataProvider : IApiResponseMetadataProvider + { + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs index 6114bc227f..59cc05b4e4 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs @@ -84,6 +84,9 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels public IReadOnlyList Attributes { get; } + /// + /// Gets or sets the . + /// public ControllerModel Controller { get; set; } public IList Filters { get; } @@ -123,6 +126,9 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels string ICommonModel.Name => ActionName; + /// + /// Gets the instances. + /// public IList Selectors { get; } public string DisplayName diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiBehaviorApplicationModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiBehaviorApplicationModelProvider.cs new file mode 100644 index 0000000000..35a415297b --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiBehaviorApplicationModelProvider.cs @@ -0,0 +1,134 @@ +// 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 System.Reflection; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + internal class ApiBehaviorApplicationModelProvider : IApplicationModelProvider + { + public ApiBehaviorApplicationModelProvider( + IOptions apiBehaviorOptions, + IModelMetadataProvider modelMetadataProvider, + IClientErrorFactory clientErrorFactory, + ILoggerFactory loggerFactory) + { + var options = apiBehaviorOptions.Value; + + ActionModelConventions = new List() + { + new ApiVisibilityConvention(), + }; + + if (!options.SuppressMapClientErrors) + { + ActionModelConventions.Add(new ClientErrorResultFilterConvention()); + } + + if (!options.SuppressModelStateInvalidFilter) + { + ActionModelConventions.Add(new InvalidModelStateFilterConvention()); + } + + if (!options.SuppressConsumesConstraintForFormFileParameters) + { + ActionModelConventions.Add(new ConsumesConstraintForFormFileParameterConvention()); + } + + var defaultErrorType = options.SuppressMapClientErrors ? typeof(void) : typeof(ProblemDetails); + var defaultErrorTypeAttribute = new ProducesErrorResponseTypeAttribute(defaultErrorType); + ActionModelConventions.Add(new ApiConventionApplicationModelConvention(defaultErrorTypeAttribute)); + + if (!options.SuppressInferBindingSourcesForParameters) + { + var convention = new InferParameterBindingInfoConvention(modelMetadataProvider) + { + AllowInferringBindingSourceForCollectionTypesAsFromQuery = options.AllowInferringBindingSourceForCollectionTypesAsFromQuery, + }; + + ActionModelConventions.Add(convention); + } + } + + /// + /// Order is set to execute after the and allow any other user + /// that configure routing to execute. + /// + public int Order => -1000 + 100; + + public List ActionModelConventions { get; } + + public void OnProvidersExecuted(ApplicationModelProviderContext context) + { + } + + public void OnProvidersExecuting(ApplicationModelProviderContext context) + { + foreach (var controller in context.Result.Controllers) + { + if (!IsApiController(controller)) + { + continue; + } + + foreach (var action in controller.Actions) + { + // Ensure ApiController is set up correctly + EnsureActionIsAttributeRouted(action); + + foreach (var convention in ActionModelConventions) + { + convention.Apply(action); + } + } + } + } + + private static void EnsureActionIsAttributeRouted(ActionModel actionModel) + { + if (!IsAttributeRouted(actionModel.Controller.Selectors) && + !IsAttributeRouted(actionModel.Selectors)) + { + // Require attribute routing with controllers annotated with ApiControllerAttribute + var message = Resources.FormatApiController_AttributeRouteRequired( + actionModel.DisplayName, + nameof(ApiControllerAttribute)); + throw new InvalidOperationException(message); + } + + bool IsAttributeRouted(IList selectorModel) + { + for (var i = 0; i < selectorModel.Count; i++) + { + if (selectorModel[i].AttributeRouteModel != null) + { + return true; + } + } + + return false; + } + } + + private static bool IsApiController(ControllerModel controller) + { + if (controller.Attributes.OfType().Any()) + { + return true; + } + + var controllerAssembly = controller.ControllerType.Assembly; + var assemblyAttributes = controllerAssembly.GetCustomAttributes(); + return assemblyAttributes.OfType().Any(); + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiConventionApplicationModelConvention.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiConventionApplicationModelConvention.cs new file mode 100644 index 0000000000..2ab2023a09 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiConventionApplicationModelConvention.cs @@ -0,0 +1,82 @@ +// 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 System.Reflection; +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + /// + /// An that discovers + /// + /// from applied or . + /// that applies to the action. + /// + /// + public class ApiConventionApplicationModelConvention : IActionModelConvention + { + /// + /// Initializes a new instance of . + /// + /// The error type to be used. Use + /// when no default error type is to be inferred. + /// + public ApiConventionApplicationModelConvention(ProducesErrorResponseTypeAttribute defaultErrorResponseType) + { + DefaultErrorResponseType = defaultErrorResponseType ?? throw new ArgumentNullException(nameof(defaultErrorResponseType)); + } + + /// + /// Gets the default that is associated with an action + /// when no attribute is discovered. + /// + public ProducesErrorResponseTypeAttribute DefaultErrorResponseType { get; } + + public void Apply(ActionModel action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (!ShouldApply(action)) + { + return; + } + + DiscoverApiConvention(action); + DiscoverErrorResponseType(action); + } + + protected virtual bool ShouldApply(ActionModel action) => true; + + private static void DiscoverApiConvention(ActionModel action) + { + var controller = action.Controller; + var apiConventionAttributes = controller.Attributes.OfType().ToArray(); + if (apiConventionAttributes.Length == 0) + { + var controllerAssembly = controller.ControllerType.Assembly; + apiConventionAttributes = controllerAssembly.GetCustomAttributes().ToArray(); + } + + if (ApiConventionResult.TryGetApiConvention(action.ActionMethod, apiConventionAttributes, out var result)) + { + action.Properties[typeof(ApiConventionResult)] = result; + } + } + + private void DiscoverErrorResponseType(ActionModel action) + { + var errorTypeAttribute = + action.Attributes.OfType().FirstOrDefault() ?? + action.Controller.Attributes.OfType().FirstOrDefault() ?? + action.Controller.ControllerType.Assembly.GetCustomAttribute() ?? + DefaultErrorResponseType; + + action.Properties[typeof(ProducesErrorResponseTypeAttribute)] = errorTypeAttribute; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiVisibilityConvention.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiVisibilityConvention.cs new file mode 100644 index 0000000000..fb269048cf --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ApiVisibilityConvention.cs @@ -0,0 +1,27 @@ +// 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.AspNetCore.Mvc.ApplicationModels +{ + /// + /// A that sets Api Explorer visibility. + /// + public class ApiVisibilityConvention : IActionModelConvention + { + public void Apply(ActionModel action) + { + if (!ShouldApply(action)) + { + return; + } + + if (action.Controller.ApiExplorer.IsVisible == null && action.ApiExplorer.IsVisible == null) + { + // Enable ApiExplorer for the action if it wasn't already explicitly configured. + action.ApiExplorer.IsVisible = true; + } + } + + protected virtual bool ShouldApply(ActionModel action) => true; + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/AttributeRouteModel.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/AttributeRouteModel.cs index 07e6a20c48..4623d67090 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/AttributeRouteModel.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/AttributeRouteModel.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Mvc.ApplicationModels { @@ -220,6 +221,11 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels } public static string ReplaceTokens(string template, IDictionary values) + { + return ReplaceTokens(template, values, routeTokenTransformer: null); + } + + public static string ReplaceTokens(string template, IDictionary values, IOutboundParameterTransformer routeTokenTransformer) { var builder = new StringBuilder(); var state = TemplateParserState.Plaintext; @@ -371,6 +377,11 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels throw new InvalidOperationException(message); } + if (routeTokenTransformer != null) + { + value = routeTokenTransformer.TransformOutbound(value); + } + builder.Append(value); if (c == '[') diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ClientErrorResultFilterConvention.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ClientErrorResultFilterConvention.cs new file mode 100644 index 0000000000..0fff758d4c --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ClientErrorResultFilterConvention.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; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + /// + /// An that adds a + /// to that transforms . + /// + public class ClientErrorResultFilterConvention : IActionModelConvention + { + private readonly ClientErrorResultFilterFactory _filterFactory = new ClientErrorResultFilterFactory(); + + public void Apply(ActionModel action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (!ShouldApply(action)) + { + return; + } + + + action.Filters.Add(_filterFactory); + } + + protected virtual bool ShouldApply(ActionModel action) => true; + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ConsumesConstraintForFormFileParameterConvention.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ConsumesConstraintForFormFileParameterConvention.cs new file mode 100644 index 0000000000..714856e11d --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ConsumesConstraintForFormFileParameterConvention.cs @@ -0,0 +1,55 @@ +// 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 Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + /// + /// An that adds a with multipart/form-data + /// to controllers containing form file () parameters. + /// + public class ConsumesConstraintForFormFileParameterConvention : IActionModelConvention + { + public void Apply(ActionModel action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (!ShouldApply(action)) + { + return; + } + + AddMultipartFormDataConsumesAttribute(action); + } + + protected virtual bool ShouldApply(ActionModel action) => true; + + // Internal for unit testing + internal void AddMultipartFormDataConsumesAttribute(ActionModel action) + { + // Add a ConsumesAttribute if the request does not explicitly specify one. + if (action.Filters.OfType().Any()) + { + return; + } + + foreach (var parameter in action.Parameters) + { + var bindingSource = parameter.BindingInfo?.BindingSource; + if (bindingSource == BindingSource.FormFile) + { + // If an controller accepts files, it must accept multipart/form-data. + action.Filters.Add(new ConsumesAttribute("multipart/form-data")); + return; + } + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InferParameterBindingInfoConvention.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InferParameterBindingInfoConvention.cs new file mode 100644 index 0000000000..9a3839308a --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InferParameterBindingInfoConvention.cs @@ -0,0 +1,133 @@ +// 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 Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing.Template; +using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + /// + /// An that infers for parameters. + /// + /// + /// The goal of this covention is to make intuitive and easy to document inferences. The rules are: + /// + /// A previously specified is never overwritten. + /// A complex type parameter () is assigned . + /// Parameter with a name that appears as a route value in ANY route template is assigned . + /// All other parameters are . + /// + /// + public class InferParameterBindingInfoConvention : IActionModelConvention + { + private readonly IModelMetadataProvider _modelMetadataProvider; + + public InferParameterBindingInfoConvention( + IModelMetadataProvider modelMetadataProvider) + { + _modelMetadataProvider = modelMetadataProvider ?? throw new ArgumentNullException(nameof(modelMetadataProvider)); + } + + internal bool AllowInferringBindingSourceForCollectionTypesAsFromQuery { get; set; } + + protected virtual bool ShouldApply(ActionModel action) => true; + + public void Apply(ActionModel action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (!ShouldApply(action)) + { + return; + } + + InferParameterBindingSources(action); + } + + internal void InferParameterBindingSources(ActionModel action) + { + for (var i = 0; i < action.Parameters.Count; i++) + { + var parameter = action.Parameters[i]; + var bindingSource = parameter.BindingInfo?.BindingSource; + if (bindingSource == null) + { + bindingSource = InferBindingSourceForParameter(parameter); + + parameter.BindingInfo = parameter.BindingInfo ?? new BindingInfo(); + parameter.BindingInfo.BindingSource = bindingSource; + } + } + + var fromBodyParameters = action.Parameters.Where(p => p.BindingInfo.BindingSource == BindingSource.Body).ToList(); + if (fromBodyParameters.Count > 1) + { + var parameters = string.Join(Environment.NewLine, fromBodyParameters.Select(p => p.DisplayName)); + var message = Resources.FormatApiController_MultipleBodyParametersFound( + action.DisplayName, + nameof(FromQueryAttribute), + nameof(FromRouteAttribute), + nameof(FromBodyAttribute)); + + message += Environment.NewLine + parameters; + throw new InvalidOperationException(message); + } + } + + // Internal for unit testing. + internal BindingSource InferBindingSourceForParameter(ParameterModel parameter) + { + if (IsComplexTypeParameter(parameter)) + { + return BindingSource.Body; + } + + if (ParameterExistsInAnyRoute(parameter.Action, parameter.ParameterName)) + { + return BindingSource.Path; + } + + return BindingSource.Query; + } + + private bool ParameterExistsInAnyRoute(ActionModel action, string parameterName) + { + foreach (var (route, _, _) in ActionAttributeRouteModel.GetAttributeRoutes(action)) + { + if (route == null) + { + continue; + } + + var parsedTemplate = TemplateParser.Parse(route.Template); + if (parsedTemplate.GetParameter(parameterName) != null) + { + return true; + } + } + + return false; + } + + private bool IsComplexTypeParameter(ParameterModel parameter) + { + // No need for information from attributes on the parameter. Just use its type. + var metadata = _modelMetadataProvider + .GetMetadataForType(parameter.ParameterInfo.ParameterType); + + if (AllowInferringBindingSourceForCollectionTypesAsFromQuery && metadata.IsCollectionType) + { + return false; + } + + return metadata.IsComplexType; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InvalidModelStateFilterConvention.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InvalidModelStateFilterConvention.cs new file mode 100644 index 0000000000..6d15ae2d58 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/InvalidModelStateFilterConvention.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + /// + /// An that adds a + /// to that responds to invalid + /// + public class InvalidModelStateFilterConvention : IActionModelConvention + { + private readonly ModelStateInvalidFilterFactory _filterFactory = new ModelStateInvalidFilterFactory(); + + public void Apply(ActionModel action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (!ShouldApply(action)) + { + return; + } + + action.Filters.Add(_filterFactory); + } + + protected virtual bool ShouldApply(ActionModel action) => true; + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/RouteTokenTransformerConvention.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/RouteTokenTransformerConvention.cs new file mode 100644 index 0000000000..004e4a22c3 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/RouteTokenTransformerConvention.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.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + /// + /// An that sets attribute routing token replacement + /// to use the specified on . + /// This convention does not effect Razor page routes. + /// + public class RouteTokenTransformerConvention : IActionModelConvention + { + private readonly IOutboundParameterTransformer _parameterTransformer; + + /// + /// Creates a new instance of with the specified . + /// + /// The to use with attribute routing token replacement. + public RouteTokenTransformerConvention(IOutboundParameterTransformer parameterTransformer) + { + if (parameterTransformer == null) + { + throw new ArgumentNullException(nameof(parameterTransformer)); + } + + _parameterTransformer = parameterTransformer; + } + + public void Apply(ActionModel action) + { + if (ShouldApply(action)) + { + action.Properties[typeof(IOutboundParameterTransformer)] = _parameterTransformer; + } + } + + protected virtual bool ShouldApply(ActionModel action) => true; + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/SelectorModel.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/SelectorModel.cs index e838e17c04..d376f6c0d2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/SelectorModel.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/SelectorModel.cs @@ -12,6 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels public SelectorModel() { ActionConstraints = new List(); + EndpointMetadata = new List(); } public SelectorModel(SelectorModel other) @@ -22,6 +23,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels } ActionConstraints = new List(other.ActionConstraints); + EndpointMetadata = new List(other.EndpointMetadata); if (other.AttributeRouteModel != null) { @@ -32,5 +34,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels public AttributeRouteModel AttributeRouteModel { get; set; } public IList ActionConstraints { get; } + + public IList EndpointMetadata { get; } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationParts/ApplicationPartManager.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationParts/ApplicationPartManager.cs index 1636c61df7..99e1c608e1 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationParts/ApplicationPartManager.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationParts/ApplicationPartManager.cs @@ -23,7 +23,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationParts /// Gets the list of instances. /// /// Instances in this collection are stored in precedence order. An that appears - /// earlier in the list has a higher precendence. + /// earlier in the list has a higher precedence. /// An may choose to use this an interface as a way to resolve conflicts when /// multiple instances resolve equivalent feature values. /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationParts/RelatedAssemblyAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationParts/RelatedAssemblyAttribute.cs index b9f6cdb2d7..294d7b9ac8 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationParts/RelatedAssemblyAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ApplicationParts/RelatedAssemblyAttribute.cs @@ -115,7 +115,8 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationParts internal static string GetAssemblyLocation(Assembly assembly) { - if (Uri.TryCreate(assembly.CodeBase, UriKind.Absolute, out var result) && result.IsFile) + if (Uri.TryCreate(assembly.CodeBase, UriKind.Absolute, out var result) && + result.IsFile && string.IsNullOrWhiteSpace(result.Fragment)) { return result.LocalPath; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Authorization/AuthorizeFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Authorization/AuthorizeFilter.cs index abf76ea191..29cf7cc085 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Authorization/AuthorizeFilter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Authorization/AuthorizeFilter.cs @@ -108,6 +108,24 @@ namespace Microsoft.AspNetCore.Mvc.Authorization bool IFilterFactory.IsReusable => true; + // Computes the actual policy for this filter using either Policy or PolicyProvider + AuthorizeData + private Task ComputePolicyAsync() + { + if (Policy != null) + { + return Task.FromResult(Policy); + } + if (PolicyProvider == null) + { + throw new InvalidOperationException( + Resources.FormatAuthorizeFilter_AuthorizationPolicyCannotBeCreated( + nameof(AuthorizationPolicy), + nameof(IAuthorizationPolicyProvider))); + } + + return AuthorizationPolicy.CombineAsync(PolicyProvider, AuthorizeData); + } + private async Task GetEffectivePolicyAsync(AuthorizationFilterContext context) { if (_effectivePolicy != null) @@ -115,7 +133,8 @@ namespace Microsoft.AspNetCore.Mvc.Authorization return _effectivePolicy; } - var effectivePolicy = Policy; + var effectivePolicy = await ComputePolicyAsync(); + var canCache = PolicyProvider == null; if (_mvcOptions == null) { @@ -124,13 +143,13 @@ namespace Microsoft.AspNetCore.Mvc.Authorization if (_mvcOptions.AllowCombiningAuthorizeFilters) { - if (!context.IsEffectivePolicy(this)) + if (!context.IsEffectivePolicy(this)) { return null; } // Combine all authorize filters into single effective policy that's only run on the closest filter - AuthorizationPolicyBuilder builder = null; + var builder = new AuthorizationPolicyBuilder(effectivePolicy); for (var i = 0; i < context.Filters.Count; i++) { if (ReferenceEquals(this, context.Filters[i])) @@ -140,29 +159,17 @@ namespace Microsoft.AspNetCore.Mvc.Authorization if (context.Filters[i] is AuthorizeFilter authorizeFilter) { - builder = builder ?? new AuthorizationPolicyBuilder(effectivePolicy); - builder.Combine(authorizeFilter.Policy); + // Combine using the explicit policy, or the dynamic policy provider + builder.Combine(await authorizeFilter.ComputePolicyAsync()); + canCache = canCache && authorizeFilter.PolicyProvider == null; } } effectivePolicy = builder?.Build() ?? effectivePolicy; } - if (effectivePolicy == null) - { - if (PolicyProvider == null) - { - throw new InvalidOperationException( - Resources.FormatAuthorizeFilter_AuthorizationPolicyCannotBeCreated( - nameof(AuthorizationPolicy), - nameof(IAuthorizationPolicyProvider))); - } - - effectivePolicy = await AuthorizationPolicy.CombineAsync(PolicyProvider, AuthorizeData); - } - - // We can cache the effective policy when there is no custom policy provider - if (PolicyProvider == null) + // We can cache the effective policy when there is no custom policy provider + if (canCache) { _effectivePolicy = effectivePolicy; } @@ -189,7 +196,7 @@ namespace Microsoft.AspNetCore.Mvc.Authorization var authenticateResult = await policyEvaluator.AuthenticateAsync(effectivePolicy, context.HttpContext); // Allow Anonymous skips all authorization - if (context.Filters.Any(item => item is IAllowAnonymousFilter)) + if (HasAllowAnonymous(context.Filters)) { return; } @@ -218,5 +225,18 @@ namespace Microsoft.AspNetCore.Mvc.Authorization var policyProvider = serviceProvider.GetRequiredService(); return AuthorizationApplicationModelProvider.GetFilter(policyProvider, AuthorizeData); } + + private static bool HasAllowAnonymous(IList filters) + { + for (var i = 0; i < filters.Count; i++) + { + if (filters[i] is IAllowAnonymousFilter) + { + return true; + } + } + + return false; + } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/BadRequestObjectResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/BadRequestObjectResult.cs index f31552a3a6..cf3627fb31 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/BadRequestObjectResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/BadRequestObjectResult.cs @@ -3,6 +3,7 @@ using System; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -10,23 +11,26 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that when executed will produce a Bad Request (400) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class BadRequestObjectResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status400BadRequest; + /// /// Creates a new instance. /// /// Contains the errors to be returned to the client. - public BadRequestObjectResult(object error) + public BadRequestObjectResult([ActionResultObjectValue] object error) : base(error) { - StatusCode = StatusCodes.Status400BadRequest; + StatusCode = DefaultStatusCode; } /// /// Creates a new instance. /// /// containing the validation errors. - public BadRequestObjectResult(ModelStateDictionary modelState) + public BadRequestObjectResult([ActionResultObjectValue] ModelStateDictionary modelState) : base(new SerializableError(modelState)) { if (modelState == null) @@ -34,7 +38,7 @@ namespace Microsoft.AspNetCore.Mvc throw new ArgumentNullException(nameof(modelState)); } - StatusCode = StatusCodes.Status400BadRequest; + StatusCode = DefaultStatusCode; } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/BadRequestResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/BadRequestResult.cs index 69025ee49d..5a034b181a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/BadRequestResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/BadRequestResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { @@ -9,13 +10,16 @@ namespace Microsoft.AspNetCore.Mvc /// A that when /// executed will produce a Bad Request (400) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class BadRequestResult : StatusCodeResult { + private const int DefaultStatusCode = StatusCodes.Status400BadRequest; + /// /// Creates a new instance. /// public BadRequestResult() - : base(StatusCodes.Status400BadRequest) + : base(DefaultStatusCode) { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs index 2dcbcd8fe4..06e26ed407 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs @@ -2,10 +2,15 @@ // 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.Internal; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Builder { @@ -14,6 +19,9 @@ namespace Microsoft.AspNetCore.Builder /// public static class MvcApplicationBuilderExtensions { + // Property key set in routing package by UseEndpointRouting to indicate middleware is registered + private const string EndpointRoutingRegisteredKey = "__EndpointRoutingMiddlewareRegistered"; + /// /// Adds MVC to the request execution pipeline. /// @@ -75,6 +83,97 @@ namespace Microsoft.AspNetCore.Builder throw new ArgumentNullException(nameof(configureRoutes)); } + VerifyMvcIsRegistered(app); + + var options = app.ApplicationServices.GetRequiredService>(); + + if (options.Value.EnableEndpointRouting) + { + var mvcEndpointDataSource = app.ApplicationServices + .GetRequiredService>() + .OfType() + .First(); + var parameterPolicyFactory = app.ApplicationServices + .GetRequiredService(); + + var endpointRouteBuilder = new EndpointRouteBuilder(app); + + configureRoutes(endpointRouteBuilder); + + foreach (var router in endpointRouteBuilder.Routes) + { + // Only accept Microsoft.AspNetCore.Routing.Route when converting to endpoint + // Sub-types could have additional customization that we can't knowingly convert + if (router is Route route && router.GetType() == typeof(Route)) + { + var endpointInfo = new MvcEndpointInfo( + route.Name, + route.RouteTemplate, + route.Defaults, + route.Constraints.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value), + route.DataTokens, + parameterPolicyFactory); + + mvcEndpointDataSource.ConventionalEndpointInfos.Add(endpointInfo); + } + else + { + throw new InvalidOperationException($"Cannot use '{router.GetType().FullName}' with Endpoint Routing."); + } + } + + if (!app.Properties.TryGetValue(EndpointRoutingRegisteredKey, out _)) + { + // Matching middleware has not been registered yet + // For back-compat register middleware so an endpoint is matched and then immediately used + app.UseEndpointRouting(); + } + + return app.UseEndpoint(); + } + else + { + var routes = new RouteBuilder(app) + { + DefaultHandler = app.ApplicationServices.GetRequiredService(), + }; + + configureRoutes(routes); + + routes.Routes.Insert(0, AttributeRouting.CreateAttributeMegaRoute(app.ApplicationServices)); + + return app.UseRouter(routes.Build()); + } + } + + private class EndpointRouteBuilder : IRouteBuilder + { + public EndpointRouteBuilder(IApplicationBuilder applicationBuilder) + { + ApplicationBuilder = applicationBuilder; + Routes = new List(); + DefaultHandler = NullRouter.Instance; + } + + public IApplicationBuilder ApplicationBuilder { get; } + + public IRouter DefaultHandler { get; set; } + + public IServiceProvider ServiceProvider + { + get { return ApplicationBuilder.ApplicationServices; } + } + + public IList Routes { get; } + + public IRouter Build() + { + throw new NotSupportedException(); + } + } + + private static void VerifyMvcIsRegistered(IApplicationBuilder app) + { // Verify if AddMvc was done before calling UseMvc // We use the MvcMarkerService to make sure if all the services were added. if (app.ApplicationServices.GetService(typeof(MvcMarkerService)) == null) @@ -84,20 +183,6 @@ namespace Microsoft.AspNetCore.Builder "AddMvc", "ConfigureServices(...)")); } - - var middlewarePipelineBuilder = app.ApplicationServices.GetRequiredService(); - middlewarePipelineBuilder.ApplicationBuilder = app.New(); - - var routes = new RouteBuilder(app) - { - DefaultHandler = app.ApplicationServices.GetRequiredService(), - }; - - configureRoutes(routes); - - routes.Routes.Insert(0, AttributeRouting.CreateAttributeMegaRoute(app.ApplicationServices)); - - return app.UseRouter(routes.Build()); } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs new file mode 100644 index 0000000000..161189b871 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Microsoft.AspNetCore.Builder +{ + internal class MvcEndpointInfo + { + public MvcEndpointInfo( + string name, + string pattern, + RouteValueDictionary defaults, + IDictionary constraints, + RouteValueDictionary dataTokens, + ParameterPolicyFactory parameterPolicyFactory) + { + Name = name; + Pattern = pattern ?? string.Empty; + DataTokens = dataTokens; + + try + { + // Data we parse from the pattern will be used to fill in the rest of the constraints or + // defaults. The parser will throw for invalid routes. + ParsedPattern = RoutePatternFactory.Parse(pattern, defaults, constraints); + ParameterPolicies = BuildParameterPolicies(ParsedPattern.Parameters, parameterPolicyFactory); + + Defaults = defaults; + // Merge defaults outside of RoutePattern because the defaults will already have values from pattern + MergedDefaults = new RouteValueDictionary(ParsedPattern.Defaults); + } + catch (Exception exception) + { + throw new RouteCreationException( + string.Format(CultureInfo.CurrentCulture, "An error occurred while creating the route with name '{0}' and pattern '{1}'.", name, pattern), exception); + } + } + + public string Name { get; } + public string Pattern { get; } + + // Non-inline defaults + public RouteValueDictionary Defaults { get; } + + // Inline and non-inline defaults merged into one + public RouteValueDictionary MergedDefaults { get; } + + public IDictionary> ParameterPolicies { get; } + public RouteValueDictionary DataTokens { get; } + public RoutePattern ParsedPattern { get; private set; } + + internal static Dictionary> BuildParameterPolicies(IReadOnlyList parameters, ParameterPolicyFactory parameterPolicyFactory) + { + var policies = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var parameter in parameters) + { + foreach (var parameterPolicy in parameter.ParameterPolicies) + { + var createdPolicy = parameterPolicyFactory.Create(parameter, parameterPolicy); + if (!policies.TryGetValue(parameter.Name, out var policyList)) + { + policyList = new List(); + policies.Add(parameter.Name, policyList); + } + + policyList.Add(createdPolicy); + } + } + + return policies; + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ClientErrorData.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ClientErrorData.cs new file mode 100644 index 0000000000..9b292ad53d --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ClientErrorData.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// Information for producing client errors. This type is used to configure client errors + /// produced by consumers of . + /// + public class ClientErrorData + { + /// + /// Gets or sets a link (URI) that describes the client error. + /// + /// + /// By default, this maps to . + /// + public string Link { get; set; } + + /// + /// Gets or sets the summary of the client error. + /// + /// + /// By default, this maps to and should not change + /// between multiple occurrences of the same error. + /// + public string Title { get; set; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CompatibilityVersion.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CompatibilityVersion.cs index 91372a0d19..d35cdd6110 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CompatibilityVersion.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CompatibilityVersion.cs @@ -6,11 +6,11 @@ using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc { /// - /// Specifies the version compatibility of runtime behaviors configured by . + /// Specifies the version compatibility of runtime behaviors configured by . /// /// /// - /// The best way to set a compatibility version is by using + /// The best way to set a compatibility version is by using /// or /// in your application's /// ConfigureServices method. @@ -20,45 +20,69 @@ namespace Microsoft.AspNetCore.Mvc /// public class Startup /// { /// ... - /// + /// /// public void ConfigureServices(IServiceCollection services) /// { /// services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); /// } - /// + /// /// ... /// } /// /// /// /// - /// Setting compatiblity version to a specific version will change the default values of various - /// settings to match a particular minor release of ASP.NET Core MVC. + /// Setting compatibility version to a specific version will change the default values of various + /// settings to match a particular minor release of ASP.NET Core MVC. /// /// public enum CompatibilityVersion { /// - /// Sets the default value of settings on to match the behavior of + /// Sets the default value of settings on to match the behavior of /// ASP.NET Core MVC 2.0. /// Version_2_0, /// - /// Sets the default value of settings on to match the behavior of + /// Sets the default value of settings on to match the behavior of /// ASP.NET Core MVC 2.1. /// /// /// ASP.NET Core MVC 2.1 introduces compatibility switches for the following: /// + /// + /// /// /// /// MvcJsonOptions.AllowInputFormatterExceptionMessages /// RazorPagesOptions.AllowAreas + /// RazorPagesOptions.AllowMappingHeadRequestsToGetHandler /// /// Version_2_1, + /// + /// Sets the default value of settings on to match the behavior of + /// ASP.NET Core MVC 2.2. + /// + /// + /// ASP.NET Core MVC 2.2 introduces compatibility switches for the following: + /// + /// ApiBehaviorOptions.SuppressMapClientErrors + /// ApiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses + /// MvcDataAnnotationsLocalizationOptions.AllowDataAnnotationsLocalizationForEnumDisplayAttributes + /// + /// + /// + /// RazorPagesOptions.AllowDefaultHandlingForOptionsRequests + /// RazorViewEngineOptions.AllowRecompilingViewsOnFileChange + /// MvcViewOptions.AllowRenderingMaxLengthAttribute + /// MvcXmlOptions.AllowRfc7807CompliantProblemDetailsFormat + /// + /// + Version_2_2, + /// /// Sets the default value of settings on to match the latest release. Use this /// value with care, upgrading minor versions will cause breaking changes when using . diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConflictObjectResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConflictObjectResult.cs index 29e72e816b..696357783d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConflictObjectResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConflictObjectResult.cs @@ -3,6 +3,7 @@ using System; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -10,23 +11,26 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that when executed will produce a Conflict (409) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class ConflictObjectResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status409Conflict; + /// /// Creates a new instance. /// /// Contains the errors to be returned to the client. - public ConflictObjectResult(object error) + public ConflictObjectResult([ActionResultObjectValue] object error) : base(error) { - StatusCode = StatusCodes.Status409Conflict; + StatusCode = DefaultStatusCode; } /// /// Creates a new instance. /// /// containing the validation errors. - public ConflictObjectResult(ModelStateDictionary modelState) + public ConflictObjectResult([ActionResultObjectValue] ModelStateDictionary modelState) : base(new SerializableError(modelState)) { if (modelState == null) @@ -34,7 +38,7 @@ namespace Microsoft.AspNetCore.Mvc throw new ArgumentNullException(nameof(modelState)); } - StatusCode = StatusCodes.Status409Conflict; + StatusCode = DefaultStatusCode; } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConflictResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConflictResult.cs index 92eb10385a..a97acb8c31 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConflictResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConflictResult.cs @@ -2,19 +2,23 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { /// /// A that when executed will produce a Conflict (409) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class ConflictResult : StatusCodeResult { + private const int DefaultStatusCode = StatusCodes.Status409Conflict; + /// /// Creates a new instance. /// public ConflictResult() - : base(StatusCodes.Status409Conflict) + : base(DefaultStatusCode) { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConsumesAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConsumesAttribute.cs index c3c240e63e..30a103a531 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConsumesAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ConsumesAttribute.cs @@ -7,11 +7,11 @@ using System.Linq; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApiExplorer; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Net.Http.Headers; +using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; namespace Microsoft.AspNetCore.Mvc { @@ -77,7 +77,7 @@ namespace Microsoft.AspNetCore.Mvc // Confirm the request's content type is more specific than a media type this action supports e.g. OK // if client sent "text/plain" data and this action supports "text/*". - if (requestContentType != null && !IsSubsetOfAnyContentType(requestContentType)) + if (requestContentType == null || !IsSubsetOfAnyContentType(requestContentType)) { context.Result = new UnsupportedMediaTypeResult(); } @@ -189,11 +189,10 @@ namespace Microsoft.AspNetCore.Mvc // If there are multiple IConsumeActionConstraints which are defined at the class and // at the action level, the one closest to the action overrides the others. To ensure this // we take advantage of the fact that ConsumesAttribute is both an IActionFilter and an - // IConsumeActionConstraint. Since filterdescriptor collection is ordered (the last filter is the one + // IConsumeActionConstraint. Since FilterDescriptor collection is ordered (the last filter is the one // closest to the action), we apply this constraint only if there is no IConsumeActionConstraint after this. return actionDescriptor.FilterDescriptors.Last( filter => filter.Filter is IConsumesActionConstraint).Filter == this; - } private MediaTypeCollection GetContentTypes(string firstArg, string[] args) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ContentResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ContentResult.cs index 65ad59bf47..29c9ddab2d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ContentResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ContentResult.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc { - public class ContentResult : ActionResult + public class ContentResult : ActionResult, IStatusCodeActionResult { /// /// Gets or set the content representing the body of the response. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs index 5626f1ccd2..3cc780746a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; @@ -200,7 +201,7 @@ namespace Microsoft.AspNetCore.Mvc /// The status code to set on the response. /// The created object for the response. [NonAction] - public virtual StatusCodeResult StatusCode(int statusCode) + public virtual StatusCodeResult StatusCode([ActionResultStatusCode] int statusCode) => new StatusCodeResult(statusCode); /// @@ -210,10 +211,12 @@ namespace Microsoft.AspNetCore.Mvc /// The value to set on the . /// The created object for the response. [NonAction] - public virtual ObjectResult StatusCode(int statusCode, object value) + public virtual ObjectResult StatusCode([ActionResultStatusCode] int statusCode, [ActionResultObjectValue] object value) { - var result = new ObjectResult(value); - result.StatusCode = statusCode; + var result = new ObjectResult(value) + { + StatusCode = statusCode + }; return result; } @@ -301,9 +304,10 @@ namespace Microsoft.AspNetCore.Mvc /// The content value to format in the entity body. /// The created for the response. [NonAction] - public virtual OkObjectResult Ok(object value) + public virtual OkObjectResult Ok([ActionResultObjectValue] object value) => new OkObjectResult(value); + #region RedirectResult variants /// /// Creates a object that redirects () /// to the specified . @@ -1074,7 +1078,9 @@ namespace Microsoft.AspNetCore.Mvc preserveMethod: true, fragment: fragment); } + #endregion + #region FileResult variants /// /// Returns a file with the specified as content (), /// and the specified as the Content-Type. @@ -1695,6 +1701,7 @@ namespace Microsoft.AspNetCore.Mvc EnableRangeProcessing = enableRangeProcessing, }; } + #endregion /// /// Creates an that produces an response. @@ -1704,6 +1711,14 @@ namespace Microsoft.AspNetCore.Mvc public virtual UnauthorizedResult Unauthorized() => new UnauthorizedResult(); + /// + /// Creates an that produces a response. + /// + /// The created for the response. + [NonAction] + public virtual UnauthorizedObjectResult Unauthorized([ActionResultObjectValue] object value) + => new UnauthorizedObjectResult(value); + /// /// Creates an that produces a response. /// @@ -1717,7 +1732,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// The created for the response. [NonAction] - public virtual NotFoundObjectResult NotFound(object value) + public virtual NotFoundObjectResult NotFound([ActionResultObjectValue] object value) => new NotFoundObjectResult(value); /// @@ -1734,7 +1749,7 @@ namespace Microsoft.AspNetCore.Mvc /// An error object to be returned to the client. /// The created for the response. [NonAction] - public virtual BadRequestObjectResult BadRequest(object error) + public virtual BadRequestObjectResult BadRequest([ActionResultObjectValue] object error) => new BadRequestObjectResult(error); /// @@ -1743,7 +1758,7 @@ namespace Microsoft.AspNetCore.Mvc /// The containing errors to be returned to the client. /// The created for the response. [NonAction] - public virtual BadRequestObjectResult BadRequest(ModelStateDictionary modelState) + public virtual BadRequestObjectResult BadRequest([ActionResultObjectValue] ModelStateDictionary modelState) { if (modelState == null) { @@ -1769,7 +1784,7 @@ namespace Microsoft.AspNetCore.Mvc /// An error object to be returned to the client. /// The created for the response. [NonAction] - public virtual UnprocessableEntityObjectResult UnprocessableEntity(object error) + public virtual UnprocessableEntityObjectResult UnprocessableEntity([ActionResultObjectValue] object error) { return new UnprocessableEntityObjectResult(error); } @@ -1780,7 +1795,7 @@ namespace Microsoft.AspNetCore.Mvc /// The containing errors to be returned to the client. /// The created for the response. [NonAction] - public virtual UnprocessableEntityObjectResult UnprocessableEntity(ModelStateDictionary modelState) + public virtual UnprocessableEntityObjectResult UnprocessableEntity([ActionResultObjectValue] ModelStateDictionary modelState) { if (modelState == null) { @@ -1804,7 +1819,7 @@ namespace Microsoft.AspNetCore.Mvc /// Contains errors to be returned to the client. /// The created for the response. [NonAction] - public virtual ConflictObjectResult Conflict(object error) + public virtual ConflictObjectResult Conflict([ActionResultObjectValue] object error) => new ConflictObjectResult(error); /// @@ -1813,7 +1828,7 @@ namespace Microsoft.AspNetCore.Mvc /// The containing errors to be returned to the client. /// The created for the response. [NonAction] - public virtual ConflictObjectResult Conflict(ModelStateDictionary modelState) + public virtual ConflictObjectResult Conflict([ActionResultObjectValue] ModelStateDictionary modelState) => new ConflictObjectResult(modelState); /// @@ -1821,7 +1836,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// The created for the response. [NonAction] - public virtual ActionResult ValidationProblem(ValidationProblemDetails descriptor) + public virtual ActionResult ValidationProblem([ActionResultObjectValue] ValidationProblemDetails descriptor) { if (descriptor == null) { @@ -1836,7 +1851,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// The created for the response. [NonAction] - public virtual ActionResult ValidationProblem(ModelStateDictionary modelStateDictionary) + public virtual ActionResult ValidationProblem([ActionResultObjectValue] ModelStateDictionary modelStateDictionary) { if (modelStateDictionary == null) { @@ -1866,7 +1881,7 @@ namespace Microsoft.AspNetCore.Mvc /// The content value to format in the entity body. /// The created for the response. [NonAction] - public virtual CreatedResult Created(string uri, object value) + public virtual CreatedResult Created(string uri, [ActionResultObjectValue] object value) { if (uri == null) { @@ -1883,7 +1898,7 @@ namespace Microsoft.AspNetCore.Mvc /// The content value to format in the entity body. /// The created for the response. [NonAction] - public virtual CreatedResult Created(Uri uri, object value) + public virtual CreatedResult Created(Uri uri, [ActionResultObjectValue] object value) { if (uri == null) { @@ -1900,7 +1915,7 @@ namespace Microsoft.AspNetCore.Mvc /// The content value to format in the entity body. /// The created for the response. [NonAction] - public virtual CreatedAtActionResult CreatedAtAction(string actionName, object value) + public virtual CreatedAtActionResult CreatedAtAction(string actionName, [ActionResultObjectValue] object value) => CreatedAtAction(actionName, routeValues: null, value: value); /// @@ -1911,7 +1926,7 @@ namespace Microsoft.AspNetCore.Mvc /// The content value to format in the entity body. /// The created for the response. [NonAction] - public virtual CreatedAtActionResult CreatedAtAction(string actionName, object routeValues, object value) + public virtual CreatedAtActionResult CreatedAtAction(string actionName, object routeValues, [ActionResultObjectValue] object value) => CreatedAtAction(actionName, controllerName: null, routeValues: routeValues, value: value); /// @@ -1927,7 +1942,7 @@ namespace Microsoft.AspNetCore.Mvc string actionName, string controllerName, object routeValues, - object value) + [ActionResultObjectValue] object value) => new CreatedAtActionResult(actionName, controllerName, routeValues, value); /// @@ -1937,7 +1952,7 @@ namespace Microsoft.AspNetCore.Mvc /// The content value to format in the entity body. /// The created for the response. [NonAction] - public virtual CreatedAtRouteResult CreatedAtRoute(string routeName, object value) + public virtual CreatedAtRouteResult CreatedAtRoute(string routeName, [ActionResultObjectValue] object value) => CreatedAtRoute(routeName, routeValues: null, value: value); /// @@ -1947,7 +1962,7 @@ namespace Microsoft.AspNetCore.Mvc /// The content value to format in the entity body. /// The created for the response. [NonAction] - public virtual CreatedAtRouteResult CreatedAtRoute(object routeValues, object value) + public virtual CreatedAtRouteResult CreatedAtRoute(object routeValues, [ActionResultObjectValue] object value) => CreatedAtRoute(routeName: null, routeValues: routeValues, value: value); /// @@ -1958,7 +1973,7 @@ namespace Microsoft.AspNetCore.Mvc /// The content value to format in the entity body. /// The created for the response. [NonAction] - public virtual CreatedAtRouteResult CreatedAtRoute(string routeName, object routeValues, object value) + public virtual CreatedAtRouteResult CreatedAtRoute(string routeName, object routeValues, [ActionResultObjectValue] object value) => new CreatedAtRouteResult(routeName, routeValues, value); /// @@ -1975,7 +1990,7 @@ namespace Microsoft.AspNetCore.Mvc /// The optional content value to format in the entity body; may be null. /// The created for the response. [NonAction] - public virtual AcceptedResult Accepted(object value) + public virtual AcceptedResult Accepted([ActionResultObjectValue] object value) => new AcceptedResult(location: null, value: value); /// @@ -2012,7 +2027,7 @@ namespace Microsoft.AspNetCore.Mvc /// The optional content value to format in the entity body; may be null. /// The created for the response. [NonAction] - public virtual AcceptedResult Accepted(string uri, object value) + public virtual AcceptedResult Accepted(string uri, [ActionResultObjectValue] object value) => new AcceptedResult(uri, value); /// @@ -2022,7 +2037,7 @@ namespace Microsoft.AspNetCore.Mvc /// The optional content value to format in the entity body; may be null. /// The created for the response. [NonAction] - public virtual AcceptedResult Accepted(Uri uri, object value) + public virtual AcceptedResult Accepted(Uri uri, [ActionResultObjectValue] object value) { if (uri == null) { @@ -2058,7 +2073,7 @@ namespace Microsoft.AspNetCore.Mvc /// The optional content value to format in the entity body; may be null. /// The created for the response. [NonAction] - public virtual AcceptedAtActionResult AcceptedAtAction(string actionName, object value) + public virtual AcceptedAtActionResult AcceptedAtAction(string actionName, [ActionResultObjectValue] object value) => AcceptedAtAction(actionName, routeValues: null, value: value); /// @@ -2069,7 +2084,7 @@ namespace Microsoft.AspNetCore.Mvc /// The route data to use for generating the URL. /// The created for the response. [NonAction] - public virtual AcceptedAtActionResult AcceptedAtAction(string actionName, string controllerName, object routeValues) + public virtual AcceptedAtActionResult AcceptedAtAction(string actionName, string controllerName, [ActionResultObjectValue] object routeValues) => AcceptedAtAction(actionName, controllerName, routeValues, value: null); /// @@ -2080,7 +2095,7 @@ namespace Microsoft.AspNetCore.Mvc /// The optional content value to format in the entity body; may be null. /// The created for the response. [NonAction] - public virtual AcceptedAtActionResult AcceptedAtAction(string actionName, object routeValues, object value) + public virtual AcceptedAtActionResult AcceptedAtAction(string actionName, object routeValues, [ActionResultObjectValue] object value) => AcceptedAtAction(actionName, controllerName: null, routeValues: routeValues, value: value); /// @@ -2096,7 +2111,7 @@ namespace Microsoft.AspNetCore.Mvc string actionName, string controllerName, object routeValues, - object value) + [ActionResultObjectValue] object value) => new AcceptedAtActionResult(actionName, controllerName, routeValues, value); /// @@ -2105,7 +2120,7 @@ namespace Microsoft.AspNetCore.Mvc /// The route data to use for generating the URL. /// The created for the response. [NonAction] - public virtual AcceptedAtRouteResult AcceptedAtRoute(object routeValues) + public virtual AcceptedAtRouteResult AcceptedAtRoute([ActionResultObjectValue] object routeValues) => AcceptedAtRoute(routeName: null, routeValues: routeValues, value: null); /// @@ -2134,7 +2149,7 @@ namespace Microsoft.AspNetCore.Mvc /// The optional content value to format in the entity body; may be null. /// The created for the response. [NonAction] - public virtual AcceptedAtRouteResult AcceptedAtRoute(object routeValues, object value) + public virtual AcceptedAtRouteResult AcceptedAtRoute(object routeValues, [ActionResultObjectValue] object value) => AcceptedAtRoute(routeName: null, routeValues: routeValues, value: value); /// @@ -2145,7 +2160,7 @@ namespace Microsoft.AspNetCore.Mvc /// The optional content value to format in the entity body; may be null. /// The created for the response. [NonAction] - public virtual AcceptedAtRouteResult AcceptedAtRoute(string routeName, object routeValues, object value) + public virtual AcceptedAtRouteResult AcceptedAtRoute(string routeName, object routeValues, [ActionResultObjectValue] object value) => new AcceptedAtRouteResult(routeName, routeValues, value); /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerBoundPropertyDescriptor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerBoundPropertyDescriptor.cs index f150169fd3..709dffcf5c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerBoundPropertyDescriptor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerBoundPropertyDescriptor.cs @@ -3,7 +3,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc.Controllers { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerFactoryProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerFactoryProvider.cs index e11ad85096..8b70db1f79 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerFactoryProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerFactoryProvider.cs @@ -19,7 +19,10 @@ namespace Microsoft.AspNetCore.Mvc.Controllers public ControllerFactoryProvider( IControllerActivatorProvider activatorProvider, IControllerFactory controllerFactory, - IEnumerable propertyActivators) +#pragma warning disable PUB0001 // Pubternal type in public API + IEnumerable propertyActivators +#pragma warning restore PUB0001 + ) { if (activatorProvider == null) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerParameterDescriptor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerParameterDescriptor.cs index e52cf741fb..593a40d0fc 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerParameterDescriptor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/ControllerParameterDescriptor.cs @@ -3,7 +3,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc.Controllers { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/DefaultControllerActivator.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/DefaultControllerActivator.cs index 8d53e715e3..9de77f768c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/DefaultControllerActivator.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/DefaultControllerActivator.cs @@ -18,7 +18,9 @@ namespace Microsoft.AspNetCore.Mvc.Controllers /// Creates a new . /// /// The . +#pragma warning disable PUB0001 // Pubternal type in public API public DefaultControllerActivator(ITypeActivatorCache typeActivatorCache) +#pragma warning restore PUB0001 { if (typeActivatorCache == null) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/DefaultControllerFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/DefaultControllerFactory.cs index 3a2627d1ec..8c10083810 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/DefaultControllerFactory.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Controllers/DefaultControllerFactory.cs @@ -29,7 +29,10 @@ namespace Microsoft.AspNetCore.Mvc.Controllers /// public DefaultControllerFactory( IControllerActivator controllerActivator, - IEnumerable propertyActivators) +#pragma warning disable PUB0001 // Pubternal type in public API + IEnumerable propertyActivators +#pragma warning restore PUB0001 + ) { if (controllerActivator == null) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedAtActionResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedAtActionResult.cs index 6ed312a374..a43516bde6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedAtActionResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedAtActionResult.cs @@ -8,14 +8,18 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { /// /// An that returns a Created (201) response with a Location header. /// + [DefaultStatusCode(DefaultStatusCode)] public class CreatedAtActionResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status201Created; + /// /// Initializes a new instance of the with the values /// provided. @@ -28,13 +32,13 @@ namespace Microsoft.AspNetCore.Mvc string actionName, string controllerName, object routeValues, - object value) + [ActionResultObjectValue] object value) : base(value) { ActionName = actionName; ControllerName = controllerName; RouteValues = routeValues == null ? null : new RouteValueDictionary(routeValues); - StatusCode = StatusCodes.Status201Created; + StatusCode = DefaultStatusCode; } /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedAtRouteResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedAtRouteResult.cs index 6e26789563..324f872692 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedAtRouteResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedAtRouteResult.cs @@ -8,21 +8,25 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { /// /// An that returns a Created (201) response with a Location header. /// + [DefaultStatusCode(DefaultStatusCode)] public class CreatedAtRouteResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status201Created; + /// /// Initializes a new instance of the class with the values /// provided. /// /// The route data to use for generating the URL. /// The value to format in the entity body. - public CreatedAtRouteResult(object routeValues, object value) + public CreatedAtRouteResult(object routeValues, [ActionResultObjectValue] object value) : this(routeName: null, routeValues: routeValues, value: value) { } @@ -37,12 +41,12 @@ namespace Microsoft.AspNetCore.Mvc public CreatedAtRouteResult( string routeName, object routeValues, - object value) + [ActionResultObjectValue] object value) : base(value) { RouteName = routeName; RouteValues = routeValues == null ? null : new RouteValueDictionary(routeValues); - StatusCode = StatusCodes.Status201Created; + StatusCode = DefaultStatusCode; } /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedResult.cs index 5e1d23a628..1ead848ca2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/CreatedResult.cs @@ -3,6 +3,7 @@ using System; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc @@ -10,8 +11,11 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that returns a Created (201) response with a Location header. /// + [DefaultStatusCode(DefaultStatusCode)] public class CreatedResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status201Created; + private string _location; /// @@ -29,7 +33,7 @@ namespace Microsoft.AspNetCore.Mvc } Location = location; - StatusCode = StatusCodes.Status201Created; + StatusCode = DefaultStatusCode; } /// @@ -55,7 +59,7 @@ namespace Microsoft.AspNetCore.Mvc Location = location.GetComponents(UriComponents.SerializationInfoString, UriFormat.UriEscaped); } - StatusCode = StatusCodes.Status201Created; + StatusCode = DefaultStatusCode; } /// @@ -88,4 +92,4 @@ namespace Microsoft.AspNetCore.Mvc context.HttpContext.Response.Headers[HeaderNames.Location] = Location; } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs new file mode 100644 index 0000000000..281e78461f --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs @@ -0,0 +1,111 @@ +// 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.Http; +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Microsoft.AspNetCore.Mvc +{ + public static class DefaultApiConventions + { + #region GET + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Get( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id) { } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Find( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id) + { } + #endregion + + #region POST + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Post( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) { } + + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Create( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) + { } + #endregion + + #region PUT + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Put( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id, + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) { } + + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Edit( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id, + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) + { } + + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Update( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id, + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) + { } + #endregion + + #region DELETE + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Delete( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id) { } + #endregion + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultObjectValidator.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DefaultObjectValidator.cs similarity index 66% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultObjectValidator.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DefaultObjectValidator.cs index 0afcdb0f5e..ff175dc5cf 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultObjectValidator.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DefaultObjectValidator.cs @@ -2,26 +2,32 @@ // 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.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc { /// /// The default implementation of . /// - public class DefaultObjectValidator : ObjectModelValidator + internal class DefaultObjectValidator : ObjectModelValidator { + private readonly MvcOptions _mvcOptions; + /// /// Initializes a new instance of . /// /// The . /// The list of . + /// Accessor to . public DefaultObjectValidator( IModelMetadataProvider modelMetadataProvider, - IList validatorProviders) + IList validatorProviders, + MvcOptions mvcOptions) : base(modelMetadataProvider, validatorProviders) { + _mvcOptions = mvcOptions; } public override ValidationVisitor GetValidationVisitor( @@ -31,12 +37,17 @@ namespace Microsoft.AspNetCore.Mvc.Internal IModelMetadataProvider metadataProvider, ValidationStateDictionary validationState) { - return new ValidationVisitor( + var visitor = new ValidationVisitor( actionContext, validatorProvider, validatorCache, metadataProvider, validationState); + + visitor.MaxValidationDepth = _mvcOptions.MaxValidationDepth; + visitor.AllowShortCircuitingValidationWhenNoValidatorsArePresent = _mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent; + + return visitor; } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreMvcBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreMvcBuilderExtensions.cs index 6e1f9f9a1b..4830e87058 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreMvcBuilderExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreMvcBuilderExtensions.cs @@ -151,5 +151,30 @@ namespace Microsoft.Extensions.DependencyInjection builder.Services.Configure(o => o.CompatibilityVersion = version); return builder; } + + /// + /// Configures . + /// + /// The . + /// The configure action. + /// The . + public static IMvcBuilder ConfigureApiBehaviorOptions( + 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; + } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreMvcCoreBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreMvcCoreBuilderExtensions.cs index 76ebe6c261..57499c930a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreMvcCoreBuilderExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreMvcCoreBuilderExtensions.cs @@ -169,6 +169,31 @@ namespace Microsoft.Extensions.DependencyInjection return builder; } + /// + /// Configures . + /// + /// The . + /// The configure action. + /// The . + public static IMvcCoreBuilder ConfigureApiBehaviorOptions( + 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; + } + /// /// Sets the for ASP.NET Core MVC for the application. /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs index c0bfe2039e..95a4afb21b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs @@ -20,7 +20,6 @@ using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.Extensions.DependencyInjection @@ -147,8 +146,12 @@ namespace Microsoft.Extensions.DependencyInjection ServiceDescriptor.Transient, MvcCoreMvcOptionsSetup>()); services.TryAddEnumerable( ServiceDescriptor.Transient, MvcOptionsConfigureCompatibilityOptions>()); + services.TryAddEnumerable( + ServiceDescriptor.Transient, MvcCoreMvcOptionsSetup>()); services.TryAddEnumerable( ServiceDescriptor.Transient, ApiBehaviorOptionsSetup>()); + services.TryAddEnumerable( + ServiceDescriptor.Transient, ApiBehaviorOptionsSetup>()); services.TryAddEnumerable( ServiceDescriptor.Transient, MvcCoreRouteOptionsSetup>()); @@ -164,7 +167,7 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddEnumerable( ServiceDescriptor.Transient()); - services.TryAddSingleton(); + services.TryAddSingleton(); // // Action Selection @@ -173,8 +176,11 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton(); // Will be cached by the DefaultActionSelector - services.TryAddEnumerable( - ServiceDescriptor.Transient()); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + + // Policies for Endpoints + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); // // Controller Factory @@ -226,7 +232,7 @@ namespace Microsoft.Extensions.DependencyInjection { var options = s.GetRequiredService>().Value; var metadataProvider = s.GetRequiredService(); - return new DefaultObjectValidator(metadataProvider, options.ModelValidatorProviders); + return new DefaultObjectValidator(metadataProvider, options.ModelValidatorProviders, options); }); services.TryAddSingleton(); services.TryAddSingleton(); @@ -253,6 +259,7 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton, RedirectToRouteResultExecutor>(); services.TryAddSingleton, RedirectToPageResultExecutor>(); services.TryAddSingleton, ContentResultExecutor>(); + services.TryAddSingleton(); // // Route Handlers @@ -260,12 +267,21 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton(); // Only one per app services.TryAddTransient(); // Many per app + // + // Endpoint Routing / Endpoints + // + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + // // Middleware pipeline filter related // services.TryAddSingleton(); // This maintains a cache of middleware pipelines, so it needs to be a singleton services.TryAddSingleton(); + // Sets ApplicationBuilder on MiddlewareFilterBuilder + services.TryAddEnumerable(ServiceDescriptor.Singleton()); } private static void ConfigureDefaultServices(IServiceCollection services) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Formatters/FormatFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Formatters/FormatFilter.cs index dd7b568fce..e70f0130c9 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Formatters/FormatFilter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Formatters/FormatFilter.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Globalization; using System.Linq; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Filters; @@ -59,7 +60,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters if (context.RouteData.Values.TryGetValue("format", out var obj)) { // null and string.Empty are equivalent for route values. - var routeValue = obj?.ToString(); + var routeValue = Convert.ToString(obj, CultureInfo.InvariantCulture); return string.IsNullOrEmpty(routeValue) ? null : routeValue; } @@ -166,8 +167,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters return; } - var objectResult = context.Result as ObjectResult; - if (objectResult == null) + if (!(context.Result is ObjectResult objectResult)) { return; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Formatters/MediaType.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Formatters/MediaType.cs index e72fccac65..7c557ed4cc 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Formatters/MediaType.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Formatters/MediaType.cs @@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// /// A media type value. /// - public struct MediaType + public readonly struct MediaType { private static readonly StringSegment QualityParameter = new StringSegment("q"); @@ -67,7 +67,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters throw new ArgumentException(Resources.FormatArgument_InvalidOffsetLength(nameof(offset), nameof(length))); } } - + _parameterParser = default(MediaTypeParameterParser); var typeLength = GetTypeLength(mediaType, offset, out var type); @@ -393,7 +393,9 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// The media type to parse. /// The position at which the parsing starts. /// The parsed media type with its associated quality. +#pragma warning disable PUB0001 // Pubternal type in public API public static MediaTypeSegmentWithQuality CreateMediaTypeSegmentWithQuality(string mediaType, int start) +#pragma warning restore PUB0001 { var parsedMediaType = new MediaType(mediaType, start, length: null); @@ -486,9 +488,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } else { - // The set has no suffix, so we're just looking for an exact match (which means that if 'this' - // has a suffix, it won't match). - return set.SubType.Equals(SubType, StringComparison.OrdinalIgnoreCase); + // If this subtype or suffix matches the subtype of the set, + // it is considered a subtype. + // Ex: application/json > application/val+json + return MatchesEitherSubtypeOrSuffix(set); } } @@ -505,6 +508,12 @@ namespace Microsoft.AspNetCore.Mvc.Formatters return set.SubTypeSuffix.Equals(SubTypeSuffix, StringComparison.OrdinalIgnoreCase); } + private bool MatchesEitherSubtypeOrSuffix(MediaType set) + { + return set.SubType.Equals(SubType, StringComparison.OrdinalIgnoreCase) || + set.SubType.Equals(SubTypeSuffix, StringComparison.OrdinalIgnoreCase); + } + private bool ContainsAllParameters(MediaTypeParameterParser setParameters) { var parameterFound = true; @@ -674,7 +683,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } } - private struct MediaTypeParameter : IEquatable + private readonly struct MediaTypeParameter : IEquatable { public MediaTypeParameter(StringSegment name, StringSegment value) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/FromServicesAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/FromServicesAttribute.cs index 271ae181f2..31e299de73 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/FromServicesAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/FromServicesAttribute.cs @@ -16,9 +16,9 @@ namespace Microsoft.AspNetCore.Mvc /// /// /// [HttpGet] - /// public ProductModel GetProduct([FromServices] IProductModelRequestService productModelReqest) + /// public ProductModel GetProduct([FromServices] IProductModelRequestService productModelRequest) /// { - /// return productModelReqest.Value; + /// return productModelRequest.Value; /// } /// /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpDeleteAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpDeleteAttribute.cs index b0bd31e3cc..5ad70affeb 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpDeleteAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpDeleteAttribute.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc.Routing; namespace Microsoft.AspNetCore.Mvc { /// - /// Identifies an action that only supports the HTTP DELETE method. + /// Identifies an action that supports the HTTP DELETE method. /// public class HttpDeleteAttribute : HttpMethodAttribute { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpGetAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpGetAttribute.cs index 1b8a9501ef..ada829e31b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpGetAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpGetAttribute.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc.Routing; namespace Microsoft.AspNetCore.Mvc { /// - /// Identifies an action that only supports the HTTP GET method. + /// Identifies an action that supports the HTTP GET method. /// public class HttpGetAttribute : HttpMethodAttribute { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpHeadAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpHeadAttribute.cs index 43e6c903e6..dc021177d5 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpHeadAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpHeadAttribute.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc.Routing; namespace Microsoft.AspNetCore.Mvc { /// - /// Identifies an action that only supports the HTTP HEAD method. + /// Identifies an action that supports the HTTP HEAD method. /// public class HttpHeadAttribute : HttpMethodAttribute { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpOptionsAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpOptionsAttribute.cs index da08f04497..f52ba3359c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpOptionsAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpOptionsAttribute.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc.Routing; namespace Microsoft.AspNetCore.Mvc { /// - /// Identifies an action that only supports the HTTP OPTIONS method. + /// Identifies an action that supports the HTTP OPTIONS method. /// public class HttpOptionsAttribute : HttpMethodAttribute { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPatchAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPatchAttribute.cs index 4d7e01d829..33639f2bec 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPatchAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPatchAttribute.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc.Routing; namespace Microsoft.AspNetCore.Mvc { /// - /// Identifies an action that only supports the HTTP PATCH method. + /// Identifies an action that supports the HTTP PATCH method. /// public class HttpPatchAttribute : HttpMethodAttribute { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPostAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPostAttribute.cs index cf9e12d5cc..992ea0f615 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPostAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPostAttribute.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc.Routing; namespace Microsoft.AspNetCore.Mvc { /// - /// Identifies an action that only supports the HTTP POST method. + /// Identifies an action that supports the HTTP POST method. /// public class HttpPostAttribute : HttpMethodAttribute { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPutAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPutAttribute.cs index 09e28237d8..eb018395bf 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPutAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/HttpPutAttribute.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc.Routing; namespace Microsoft.AspNetCore.Mvc { /// - /// Identifies an action that only supports the HTTP PUT method. + /// Identifies an action that supports the HTTP PUT method. /// public class HttpPutAttribute : HttpMethodAttribute { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IApiBehaviorMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/IApiBehaviorMetadata.cs similarity index 80% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IApiBehaviorMetadata.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/IApiBehaviorMetadata.cs index b73c1ba56e..41a053ebed 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IApiBehaviorMetadata.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/IApiBehaviorMetadata.cs @@ -3,13 +3,13 @@ using Microsoft.AspNetCore.Mvc.Filters; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc { /// /// An interface for . See /// for details. /// - public interface IApiBehaviorMetadata : IFilterMetadata + internal interface IApiBehaviorMetadata : IFilterMetadata { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionDescriptorCollectionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionDescriptorCollectionProvider.cs new file mode 100644 index 0000000000..633890e08f --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionDescriptorCollectionProvider.cs @@ -0,0 +1,33 @@ +// 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.Abstractions; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + /// + /// A base class for which also provides an + /// for reactive notifications of changes. + /// + /// + /// is used as a base class by the default implementation of + /// . To retrieve an instance of , + /// obtain the from the dependency injection provider and + /// downcast to . + /// + public abstract class ActionDescriptorCollectionProvider : IActionDescriptorCollectionProvider + { + /// + /// Returns the current cached + /// + public abstract ActionDescriptorCollection ActionDescriptors { get; } + + /// + /// Gets an that will be signaled after the + /// collection has changed. + /// + /// The . + public abstract IChangeToken GetChangeToken(); + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionResultObjectValueAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionResultObjectValueAttribute.cs new file mode 100644 index 0000000000..8a87c33b2b --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionResultObjectValueAttribute.cs @@ -0,0 +1,27 @@ +// 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.Infrastructure +{ + /// + /// Attribute annoted on ActionResult constructor, helper method parameters, and properties to indicate + /// that the parameter or property is used to set the "value" for ActionResult. + /// + /// Analyzers match this parameter by type name. This allows users to annotate custom results \ custom helpers + /// with a user defined attribute without having to expose this type. + /// + /// + /// This attribute is intentionally marked Inherited=false since the analyzer does not walk the inheritance graph. + /// + /// + /// + /// BadObjectResult([ActionResultObjectValueAttribute] object value) + /// ObjectResult { [ActionResultObjectValueAttribute] public object Value { get; set; } } + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + internal sealed class ActionResultObjectValueAttribute : Attribute + { + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionResultStatusCodeAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionResultStatusCodeAttribute.cs new file mode 100644 index 0000000000..2b4d1d4119 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ActionResultStatusCodeAttribute.cs @@ -0,0 +1,26 @@ +// 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.Infrastructure +{ + /// + /// Attribute annoted on ActionResult constructor and helper method parameters to indicate + /// that the parameter is used to set the "statusCode" for the ActionResult. + /// + /// Analyzers match this parameter by type name. This allows users to annotate custom results \ custom helpers + /// with a user defined attribute without having to expose this type. + /// + /// + /// This attribute is intentionally marked Inherited=false since the analyzer does not walk the inheritance graph. + /// + /// + /// + /// StatusCodeResult([ActionResultStatusCodeParameter] int statusCode) + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] + internal sealed class ActionResultStatusCodeAttribute : Attribute + { + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ClientErrorResultFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ClientErrorResultFilter.cs new file mode 100644 index 0000000000..1ef66b1a33 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ClientErrorResultFilter.cs @@ -0,0 +1,63 @@ +// 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.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal class ClientErrorResultFilter : IAlwaysRunResultFilter, IOrderedFilter + { + internal const int FilterOrder = -2000; + private readonly IClientErrorFactory _clientErrorFactory; + private readonly ILogger _logger; + + public ClientErrorResultFilter( + IClientErrorFactory clientErrorFactory, + ILogger logger) + { + _clientErrorFactory = clientErrorFactory ?? throw new ArgumentNullException(nameof(clientErrorFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets the filter order. Defaults to -2000 so that it runs early. + /// + public int Order => FilterOrder; + + public void OnResultExecuted(ResultExecutedContext context) + { + } + + public void OnResultExecuting(ResultExecutingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (!(context.Result is IClientErrorActionResult clientError)) + { + return; + } + + // We do not have an upper bound on the allowed status code. This allows this filter to be used + // for 5xx and later status codes. + if (clientError.StatusCode < 400) + { + return; + } + + var result = _clientErrorFactory.GetClientError(context, clientError); + if (result == null) + { + return; + } + + _logger.TransformingClientError(context.Result.GetType(), result?.GetType(), clientError.StatusCode); + context.Result = result; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ClientErrorResultFilterFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ClientErrorResultFilterFactory.cs new file mode 100644 index 0000000000..e4665fcc5d --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ClientErrorResultFilterFactory.cs @@ -0,0 +1,22 @@ +// 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.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal sealed class ClientErrorResultFilterFactory : IFilterFactory, IOrderedFilter + { + public int Order => ClientErrorResultFilter.FilterOrder; + + public bool IsReusable => true; + + public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) + { + var resultFilter = ActivatorUtilities.CreateInstance(serviceProvider); + return resultFilter; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ConfigureCompatibilityOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ConfigureCompatibilityOptions.cs index c46a3529d0..33009c95de 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ConfigureCompatibilityOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ConfigureCompatibilityOptions.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.Infrastructure { /// - /// A base class for infrastructure that implements ASP.NET Core MVC's support for + /// A base class for infrastructure that implements ASP.NET Core MVC's support for /// . This is framework infrastructure and should not be used /// by application code. /// @@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure throw new ArgumentNullException(nameof(options)); } - // Evaluate DefaultValues onces so subclasses don't have to cache. + // Evaluate DefaultValues once so subclasses don't have to cache. var defaultValues = DefaultValues; foreach (var @switch in options) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultActionDescriptorCollectionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultActionDescriptorCollectionProvider.cs new file mode 100644 index 0000000000..eb41cd8b7c --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultActionDescriptorCollectionProvider.cs @@ -0,0 +1,160 @@ +// 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.Collections.ObjectModel; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal class DefaultActionDescriptorCollectionProvider : ActionDescriptorCollectionProvider + { + private readonly IActionDescriptorProvider[] _actionDescriptorProviders; + private readonly IActionDescriptorChangeProvider[] _actionDescriptorChangeProviders; + + // The lock is used to protect WRITES to the following (do not need to protect reads once initialized). + private readonly object _lock; + private ActionDescriptorCollection _collection; + private IChangeToken _changeToken; + private CancellationTokenSource _cancellationTokenSource; + private int _version = 0; + + public DefaultActionDescriptorCollectionProvider( + IEnumerable actionDescriptorProviders, + IEnumerable actionDescriptorChangeProviders) + { + _actionDescriptorProviders = actionDescriptorProviders + .OrderBy(p => p.Order) + .ToArray(); + + _actionDescriptorChangeProviders = actionDescriptorChangeProviders.ToArray(); + + _lock = new object(); + + // IMPORTANT: this needs to be the last thing we do in the constructor. Change notifications can happen immediately! + ChangeToken.OnChange( + GetCompositeChangeToken, + UpdateCollection); + } + + /// + /// Returns a cached collection of . + /// + public override ActionDescriptorCollection ActionDescriptors + { + get + { + Initialize(); + Debug.Assert(_collection != null); + Debug.Assert(_changeToken != null); + + return _collection; + } + } + + /// + /// Gets an that will be signaled after the + /// collection has changed. + /// + /// The . + public override IChangeToken GetChangeToken() + { + Initialize(); + Debug.Assert(_collection != null); + Debug.Assert(_changeToken != null); + + return _changeToken; + } + + private IChangeToken GetCompositeChangeToken() + { + if (_actionDescriptorChangeProviders.Length == 1) + { + return _actionDescriptorChangeProviders[0].GetChangeToken(); + } + + var changeTokens = new IChangeToken[_actionDescriptorChangeProviders.Length]; + for (var i = 0; i < _actionDescriptorChangeProviders.Length; i++) + { + changeTokens[i] = _actionDescriptorChangeProviders[i].GetChangeToken(); + } + + return new CompositeChangeToken(changeTokens); + } + + private void Initialize() + { + // Using double-checked locking on initialization because we fire change token callbacks + // when the collection changes. We don't want to do that repeatedly for redundant changes. + // + // The main call path of this code on the first call is async initialization from Endpoint Routing + // which is done in a non-blocking way so in practice no caller will ever block here. + if (_collection == null) + { + lock (_lock) + { + if (_collection == null) + { + UpdateCollection(); + } + } + } + } + + private void UpdateCollection() + { + // Using the lock to initialize writes means that we serialize changes. This eliminates + // the potential for changes to be processed out of order - the risk is that newer data + // could be overwritten by older data. + lock (_lock) + { + var context = new ActionDescriptorProviderContext(); + + for (var i = 0; i < _actionDescriptorProviders.Length; i++) + { + _actionDescriptorProviders[i].OnProvidersExecuting(context); + } + + for (var i = _actionDescriptorProviders.Length - 1; i >= 0; i--) + { + _actionDescriptorProviders[i].OnProvidersExecuted(context); + } + + // The sequence for an update is important because we don't want anyone to obtain + // the new change token but the old action descriptor collection. + // 1. Obtain the old cancellation token source (don't trigger it yet) + // 2. Set the new action descriptor collection + // 3. Set the new change token + // 4. Trigger the old cancellation token source + // + // Consumers who poll will observe a new action descriptor collection at step 2 - they will see + // the new collection and ignore the change token. + // + // Consumers who listen to the change token will re-query at step 4 - they will see the new collection + // and new change token. + // + // Anyone who acquires the collection and change token between steps 2 and 3 will be notified of + // a no-op change at step 4. + + // Step 1. + var oldCancellationTokenSource = _cancellationTokenSource; + + // Step 2. + _collection = new ActionDescriptorCollection( + new ReadOnlyCollection(context.Results), + _version++); + + // Step 3. + _cancellationTokenSource = new CancellationTokenSource(); + _changeToken = new CancellationChangeToken(_cancellationTokenSource.Token); + + // Step 4 - might be null if it's the first time. + oldCancellationTokenSource?.Cancel(); + } + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultStatusCodeAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultStatusCodeAttribute.cs new file mode 100644 index 0000000000..f34ffc4d66 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/DefaultStatusCodeAttribute.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 System; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + /// + /// Specifies the default status code associated with an . + /// + /// + /// This attribute is informational only and does not have any runtime effects. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class DefaultStatusCodeAttribute : Attribute + { + /// + /// Initializes a new instance of . + /// + /// The default status code. + public DefaultStatusCodeAttribute(int statusCode) + { + StatusCode = statusCode; + } + + /// + /// Gets the default status code. + /// + public int StatusCode { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionDescriptorChangeProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionDescriptorChangeProvider.cs index d736512f1c..7b524dd10c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionDescriptorChangeProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionDescriptorChangeProvider.cs @@ -1,6 +1,7 @@ // 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.Abstractions; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.Infrastructure @@ -9,6 +10,11 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure /// Provides a way to signal invalidation of the cached collection of from an /// . /// + /// + /// The change token returned from is only for use inside the MVC infrastructure. + /// Use to be notified of + /// changes. + /// public interface IActionDescriptorChangeProvider { /// @@ -16,6 +22,11 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure /// instances. /// /// The . + /// + /// The change token returned from is only for use inside the MVC infrastructure. + /// Use to be notified of + /// changes. + /// IChangeToken GetChangeToken(); } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionDescriptorCollectionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionDescriptorCollectionProvider.cs index 3e64a66677..c109d78846 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionDescriptorCollectionProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionDescriptorCollectionProvider.cs @@ -1,18 +1,28 @@ // 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.Primitives; + namespace Microsoft.AspNetCore.Mvc.Infrastructure { /// /// Provides the currently cached collection of . /// /// + /// /// The default implementation internally caches the collection and uses /// to invalidate this cache, incrementing /// the collection is reconstructed. - /// + /// + /// + /// To be reactively notified of changes, downcast to and + /// subscribe to the change token returned from + /// using . + /// + /// /// Default consumers of this service, are aware of the version and will recache /// data as appropriate, but rely on the version being unique. + /// /// public interface IActionDescriptorCollectionProvider { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionResultExecutor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionResultExecutor.cs index 3a4e398c5e..99d01f81d2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionResultExecutor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IActionResultExecutor.cs @@ -12,14 +12,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure /// /// The type of . /// - /// Implementions of are typically called by the + /// Implementations of are typically called by the /// method of the corresponding action result type. /// Implementations should be registered as singleton services. /// public interface IActionResultExecutor where TResult : IActionResult { /// - /// Asynchronously excecutes the action result, by modifying the . + /// Asynchronously executes the action result, by modifying the . /// /// The associated with the current request."/> /// The action result to execute. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IAntiforgeryValidationFailedResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IAntiforgeryValidationFailedResult.cs new file mode 100644 index 0000000000..07befb4452 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IAntiforgeryValidationFailedResult.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.AspNetCore.Mvc.Core.Infrastructure +{ + /// + /// Represents an that is used when the + /// antiforgery validation failed. This can be matched inside MVC result + /// filters to process the validation failure. + /// + public interface IAntiforgeryValidationFailedResult : IActionResult + { } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IClientErrorActionResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IClientErrorActionResult.cs new file mode 100644 index 0000000000..6d9926b50c --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IClientErrorActionResult.cs @@ -0,0 +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. + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + /// + /// An that can be transformed to a more descriptive client error. + /// + public interface IClientErrorActionResult : IStatusCodeActionResult + { + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IClientErrorFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IClientErrorFactory.cs new file mode 100644 index 0000000000..b592c52a9b --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IClientErrorFactory.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.AspNetCore.Mvc.Infrastructure +{ + /// + /// A factory for producing client errors. This contract is used by controllers annotated + /// with to transform . + /// + public interface IClientErrorFactory + { + /// + /// Transforms for the specified . + /// + /// The . + /// The . + /// THe that would be returned to the client. + IActionResult GetClientError(ActionContext actionContext, IClientErrorActionResult clientError); + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IParameterInfoParameterDescriptor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IParameterInfoParameterDescriptor.cs similarity index 92% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IParameterInfoParameterDescriptor.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IParameterInfoParameterDescriptor.cs index 6f260b5750..93e6a09b28 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IParameterInfoParameterDescriptor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IParameterInfoParameterDescriptor.cs @@ -4,7 +4,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.Abstractions; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc.Infrastructure { /// /// A for action parameters. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IPropertyInfoParameterDescriptor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IPropertyInfoParameterDescriptor.cs similarity index 91% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IPropertyInfoParameterDescriptor.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IPropertyInfoParameterDescriptor.cs index ccbe5de73c..5a9a8682d1 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/IPropertyInfoParameterDescriptor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IPropertyInfoParameterDescriptor.cs @@ -4,7 +4,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.Abstractions; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc.Infrastructure { /// /// A for bound properties. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IStatusCodeActionResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IStatusCodeActionResult.cs new file mode 100644 index 0000000000..a8eabf652d --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/IStatusCodeActionResult.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. + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + /// + /// Represents an that when executed will + /// produce an HTTP response with the specified . + /// + public interface IStatusCodeActionResult : IActionResult + { + /// + /// Gets or sets the HTTP status code. + /// + int? StatusCode { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ModelStateInvalidFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ModelStateInvalidFilter.cs index 43aa96daec..c535bb118b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ModelStateInvalidFilter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ModelStateInvalidFilter.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Extensions.Logging; @@ -15,12 +16,22 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure /// public class ModelStateInvalidFilter : IActionFilter, IOrderedFilter { + internal const int FilterOrder = -2000; + private readonly ApiBehaviorOptions _apiBehaviorOptions; private readonly ILogger _logger; public ModelStateInvalidFilter(ApiBehaviorOptions apiBehaviorOptions, ILogger logger) { _apiBehaviorOptions = apiBehaviorOptions ?? throw new ArgumentNullException(nameof(apiBehaviorOptions)); + if (!_apiBehaviorOptions.SuppressModelStateInvalidFilter && _apiBehaviorOptions.InvalidModelStateResponseFactory == null) + { + throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull( + typeof(ApiBehaviorOptions), + nameof(ApiBehaviorOptions.InvalidModelStateResponseFactory))); + } + + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -39,7 +50,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure /// Look at for more detailed info. /// /// - public int Order => -2000; + public int Order => FilterOrder; /// public bool IsReusable => true; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ModelStateInvalidFilterFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ModelStateInvalidFilterFactory.cs new file mode 100644 index 0000000000..3f5c81dc98 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ModelStateInvalidFilterFactory.cs @@ -0,0 +1,26 @@ +// 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.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal sealed class ModelStateInvalidFilterFactory : IFilterFactory, IOrderedFilter + { + public int Order => ModelStateInvalidFilter.FilterOrder; + + public bool IsReusable => true; + + public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) + { + var options = serviceProvider.GetRequiredService>(); + var loggerFactory = serviceProvider.GetRequiredService(); + + return new ModelStateInvalidFilter(options.Value, loggerFactory.CreateLogger()); + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcCoreMvcOptionsSetup.cs similarity index 81% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcCoreMvcOptionsSetup.cs index f03e2ca661..7b89b69ee6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs +++ b/src/Mvc/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) { @@ -77,13 +78,22 @@ namespace Microsoft.AspNetCore.Mvc.Internal options.ValueProviderFactories.Add(new JQueryFormValueProviderFactory()); // Set up metadata providers - ConfigureAdditionalModelMetadataDetailsProvider(options.ModelMetadataDetailsProviders); + ConfigureAdditionalModelMetadataDetailsProviders(options.ModelMetadataDetailsProviders); // Set up validators options.ModelValidatorProviders.Add(new DefaultModelValidatorProvider()); } - internal static void ConfigureAdditionalModelMetadataDetailsProvider(IList modelMetadataDetailsProviders) + 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 // by altering the collection of providers. @@ -96,6 +106,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal modelMetadataDetailsProviders.Add(new BindingSourceMetadataProvider(typeof(IFormFile), BindingSource.FormFile)); modelMetadataDetailsProviders.Add(new BindingSourceMetadataProvider(typeof(IFormCollection), BindingSource.FormFile)); modelMetadataDetailsProviders.Add(new BindingSourceMetadataProvider(typeof(IFormFileCollection), BindingSource.FormFile)); + modelMetadataDetailsProviders.Add(new BindingSourceMetadataProvider(typeof(IEnumerable), BindingSource.FormFile)); // Add types to be excluded from Validation modelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(typeof(Type))); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcOptionsConfigureCompatibilityOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcOptionsConfigureCompatibilityOptions.cs index 71957b40cf..850d24e385 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcOptionsConfigureCompatibilityOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/MvcOptionsConfigureCompatibilityOptions.cs @@ -32,6 +32,17 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure values[nameof(MvcOptions.SuppressBindingUndefinedValueToEnumType)] = true; } + if (Version >= CompatibilityVersion.Version_2_2) + { + values[nameof(MvcOptions.EnableEndpointRouting)] = true; + + // Matches JsonSerializerSettingsProvider.DefaultMaxDepth + values[nameof(MvcOptions.MaxValidationDepth)] = 32; + + values[nameof(MvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent)] = true; + + } + return values; } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/NullableCompatibilitySwitch.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/NullableCompatibilitySwitch.cs new file mode 100644 index 0000000000..53563ba157 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/NullableCompatibilitySwitch.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal class NullableCompatibilitySwitch : ICompatibilitySwitch where TValue : struct + { + private TValue? _value; + + public NullableCompatibilitySwitch(string name) + { + Name = name; + } + + public bool IsValueSet { get; private set; } + + public string Name { get; } + + public TValue? Value + { + get => _value; + set + { + IsValueSet = true; + _value = value; + } + } + + object ICompatibilitySwitch.Value + { + get => Value; + set => Value = (TValue?)value; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ObjectResultExecutor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ObjectResultExecutor.cs index 5d5fd81b23..d48d213532 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ObjectResultExecutor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ObjectResultExecutor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Text; using System.Threading.Tasks; @@ -84,21 +85,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure throw new ArgumentNullException(nameof(result)); } - // If the user sets the content type both on the ObjectResult (example: by Produces) and Response object, - // then the one set on ObjectResult takes precedence over the Response object - if (result.ContentTypes == null || result.ContentTypes.Count == 0) - { - var responseContentType = context.HttpContext.Response.ContentType; - if (!string.IsNullOrEmpty(responseContentType)) - { - if (result.ContentTypes == null) - { - result.ContentTypes = new MediaTypeCollection(); - } - - result.ContentTypes.Add(responseContentType); - } - } + InferContentTypes(context, result); var objectType = result.DeclaredType; if (objectType == null || objectType == typeof(object)) @@ -113,8 +100,8 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure result.Value); var selectedFormatter = FormatterSelector.SelectFormatter( - formatterContext, - (IList)result.Formatters ?? Array.Empty(), + formatterContext, + (IList)result.Formatters ?? Array.Empty(), result.ContentTypes); if (selectedFormatter == null) { @@ -130,5 +117,27 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure result.OnFormatting(context); return selectedFormatter.WriteAsync(formatterContext); } + + private static void InferContentTypes(ActionContext context, ObjectResult result) + { + Debug.Assert(result.ContentTypes != null); + if (result.ContentTypes.Count != 0) + { + return; + } + + // If the user sets the content type both on the ObjectResult (example: by Produces) and Response object, + // then the one set on ObjectResult takes precedence over the Response object + var responseContentType = context.HttpContext.Response.ContentType; + if (!string.IsNullOrEmpty(responseContentType)) + { + result.ContentTypes.Add(responseContentType); + } + else if (result.Value is ProblemDetails) + { + result.ContentTypes.Add("application/problem+json"); + result.ContentTypes.Add("application/problem+xml"); + } + } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ProblemDetailsClientErrorFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ProblemDetailsClientErrorFactory.cs new file mode 100644 index 0000000000..ff47a18fa6 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/ProblemDetailsClientErrorFactory.cs @@ -0,0 +1,54 @@ +// 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 Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + internal class ProblemDetailsClientErrorFactory : IClientErrorFactory + { + private static readonly string TraceIdentifierKey = "traceId"; + private readonly ApiBehaviorOptions _options; + + public ProblemDetailsClientErrorFactory(IOptions options) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public IActionResult GetClientError(ActionContext actionContext, IClientErrorActionResult clientError) + { + var problemDetails = new ProblemDetails + { + Status = clientError.StatusCode, + Type = "about:blank", + }; + + if (clientError.StatusCode is int statusCode && + _options.ClientErrorMapping.TryGetValue(statusCode, out var errorData)) + { + problemDetails.Title = errorData.Title; + problemDetails.Type = errorData.Link; + + SetTraceId(actionContext, problemDetails); + } + + return new ObjectResult(problemDetails) + { + StatusCode = problemDetails.Status, + ContentTypes = + { + "application/problem+json", + "application/problem+xml", + }, + }; + } + + internal static void SetTraceId(ActionContext actionContext, ProblemDetails problemDetails) + { + var traceId = Activity.Current?.Id ?? actionContext.HttpContext.TraceIdentifier; + problemDetails.Extensions[TraceIdentifierKey] = traceId; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionConstraintCache.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionConstraintCache.cs index 3ba049a494..384d48bffa 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionConstraintCache.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionConstraintCache.cs @@ -27,7 +27,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal _actionConstraintProviders = actionConstraintProviders.OrderBy(item => item.Order).ToArray(); } - private InnerCache CurrentCache + internal InnerCache CurrentCache { get { @@ -36,7 +36,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal if (current == null || current.Version != actionDescriptors.Version) { - current = new InnerCache(actionDescriptors.Version); + current = new InnerCache(actionDescriptors); _currentCache = current; } @@ -150,10 +150,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal { return null; } - + var actionConstraints = new IActionConstraint[count]; var actionConstraintIndex = 0; - for (int i = 0; i < items.Count; i++) + for (var i = 0; i < items.Count; i++) { var actionConstraint = items[i].Constraint; if (actionConstraint != null) @@ -165,20 +165,22 @@ namespace Microsoft.AspNetCore.Mvc.Internal return actionConstraints; } - private class InnerCache + internal class InnerCache { - public InnerCache(int version) + private readonly ActionDescriptorCollection _actions; + + public InnerCache(ActionDescriptorCollection actions) { - Version = version; + _actions = actions; } public ConcurrentDictionary Entries { get; } = new ConcurrentDictionary(); - public int Version { get; } + public int Version => _actions.Version; } - private struct CacheEntry + internal readonly struct CacheEntry { public CacheEntry(IReadOnlyList actionConstraints) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionDescriptorCollectionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionDescriptorCollectionProvider.cs deleted file mode 100644 index 49cb219178..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionDescriptorCollectionProvider.cs +++ /dev/null @@ -1,95 +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.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.Extensions.Primitives; - -namespace Microsoft.AspNetCore.Mvc.Internal -{ - /// - /// Default implementation of . - /// - public class ActionDescriptorCollectionProvider : IActionDescriptorCollectionProvider - { - private readonly IActionDescriptorProvider[] _actionDescriptorProviders; - private readonly IActionDescriptorChangeProvider[] _actionDescriptorChangeProviders; - private ActionDescriptorCollection _collection; - private int _version = -1; - - /// - /// Initializes a new instance of the class. - /// - /// The sequence of . - /// The sequence of . - public ActionDescriptorCollectionProvider( - IEnumerable actionDescriptorProviders, - IEnumerable actionDescriptorChangeProviders) - { - _actionDescriptorProviders = actionDescriptorProviders - .OrderBy(p => p.Order) - .ToArray(); - - _actionDescriptorChangeProviders = actionDescriptorChangeProviders.ToArray(); - - ChangeToken.OnChange( - GetCompositeChangeToken, - UpdateCollection); - } - - private IChangeToken GetCompositeChangeToken() - { - if (_actionDescriptorChangeProviders.Length == 1) - { - return _actionDescriptorChangeProviders[0].GetChangeToken(); - } - - var changeTokens = new IChangeToken[_actionDescriptorChangeProviders.Length]; - for (var i = 0; i < _actionDescriptorChangeProviders.Length; i++) - { - changeTokens[i] = _actionDescriptorChangeProviders[i].GetChangeToken(); - } - - return new CompositeChangeToken(changeTokens); - } - - /// - /// Returns a cached collection of . - /// - public ActionDescriptorCollection ActionDescriptors - { - get - { - if (_collection == null) - { - UpdateCollection(); - } - - return _collection; - } - } - - private void UpdateCollection() - { - var context = new ActionDescriptorProviderContext(); - - for (var i = 0; i < _actionDescriptorProviders.Length; i++) - { - _actionDescriptorProviders[i].OnProvidersExecuting(context); - } - - for (var i = _actionDescriptorProviders.Length - 1; i >= 0; i--) - { - _actionDescriptorProviders[i].OnProvidersExecuted(context); - } - - _collection = new ActionDescriptorCollection( - new ReadOnlyCollection(context.Results), - Interlocked.Increment(ref _version)); - } - } -} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionMethodExecutor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionMethodExecutor.cs index 28541b3e55..806b97d1e5 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionMethodExecutor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionMethodExecutor.cs @@ -165,7 +165,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal => typeof(Task).IsAssignableFrom(executor.MethodReturnType); } - // Task DownloadFile(..) + // Task DownloadFile(..) // ValueTask GetViewsAsync(..) private class TaskOfActionResultExecutor : ActionMethodExecutor { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs index b3d14d1be8..5dba1a7654 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs @@ -4,15 +4,16 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Threading; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; +using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; namespace Microsoft.AspNetCore.Mvc.Internal { @@ -81,11 +82,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal var values = new string[keys.Length]; for (var i = 0; i < keys.Length; i++) { - context.RouteData.Values.TryGetValue(keys[i], out object value); - + context.RouteData.Values.TryGetValue(keys[i], out var value); if (value != null) { - values[i] = value as string ?? Convert.ToString(value); + values[i] = value as string ?? Convert.ToString(value, CultureInfo.InvariantCulture); } } @@ -220,9 +220,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal var actionsWithConstraint = new List(); var actionsWithoutConstraint = new List(); - var constraintContext = new ActionConstraintContext(); - constraintContext.Candidates = candidates; - constraintContext.RouteContext = context; + var constraintContext = new ActionConstraintContext + { + Candidates = candidates, + RouteContext = context + }; // Perf: Avoid allocations for (var i = 0; i < candidates.Count; i++) @@ -294,7 +296,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // canonical entries. When you don't hit a case-sensitive match it will try the case-insensitive dictionary // so you still get correct behaviors. // - // The difference here is because while MVC is case-insensitive, doing a case-sensitive comparison is much + // The difference here is because while MVC is case-insensitive, doing a case-sensitive comparison is much // faster. We also expect that most of the URLs we process are canonically-cased because they were generated // by Url.Action or another routing api. // @@ -316,7 +318,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal OrdinalEntries = new Dictionary>(StringArrayComparer.Ordinal); OrdinalIgnoreCaseEntries = new Dictionary>(StringArrayComparer.OrdinalIgnoreCase); - // We need to first identify of the keys that action selection will look at (in route data). + // We need to first identify of the keys that action selection will look at (in route data). // We want to only consider conventionally routed actions here. var routeKeys = new HashSet(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < actions.Items.Count; i++) @@ -365,7 +367,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // We also want to add the same (as in reference equality) list of actions to the ordinal entries. // We'll keep updating `entries` to include all of the actions in the same equivalence class - - // meaning, all conventionally routed actions for which the route values are equalignoring case. + // meaning, all conventionally routed actions for which the route values are equal ignoring case. // // `entries` will appear in `OrdinalIgnoreCaseEntries` exactly once and in `OrdinalEntries` once // for each variation of casing that we've seen. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs deleted file mode 100644 index 1661eefc03..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs +++ /dev/null @@ -1,262 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Diagnostics; -using System.Linq; -using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.Core; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Routing.Template; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Microsoft.AspNetCore.Mvc.Internal -{ - public class ApiBehaviorApplicationModelProvider : IApplicationModelProvider - { - private readonly ApiBehaviorOptions _apiBehaviorOptions; - private readonly IModelMetadataProvider _modelMetadataProvider; - private readonly ModelStateInvalidFilter _modelStateInvalidFilter; - private readonly ILogger _logger; - - public ApiBehaviorApplicationModelProvider( - IOptions apiBehaviorOptions, - IModelMetadataProvider modelMetadataProvider, - ILoggerFactory loggerFactory) - { - _apiBehaviorOptions = apiBehaviorOptions.Value; - _modelMetadataProvider = modelMetadataProvider; - _logger = loggerFactory.CreateLogger(); - - if (!_apiBehaviorOptions.SuppressModelStateInvalidFilter && _apiBehaviorOptions.InvalidModelStateResponseFactory == null) - { - throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull( - typeof(ApiBehaviorOptions), - nameof(ApiBehaviorOptions.InvalidModelStateResponseFactory))); - } - - _modelStateInvalidFilter = new ModelStateInvalidFilter( - apiBehaviorOptions.Value, - loggerFactory.CreateLogger()); - } - - /// - /// Order is set to execute after the and allow any other user - /// that configure routing to execute. - /// - public int Order => -1000 + 100; - - public void OnProvidersExecuted(ApplicationModelProviderContext context) - { - } - - public void OnProvidersExecuting(ApplicationModelProviderContext context) - { - foreach (var controllerModel in context.Result.Controllers) - { - var isApiController = controllerModel.Attributes.OfType().Any(); - if (isApiController && - controllerModel.ApiExplorer.IsVisible == null) - { - // Enable ApiExplorer for the controller if it wasn't already explicitly configured. - controllerModel.ApiExplorer.IsVisible = true; - } - - if (isApiController) - { - InferBoundPropertyModelPrefixes(controllerModel); - } - - var controllerHasSelectorModel = controllerModel.Selectors.Any(s => s.AttributeRouteModel != null); - - foreach (var actionModel in controllerModel.Actions) - { - if (!isApiController && !actionModel.Attributes.OfType().Any()) - { - continue; - } - - EnsureActionIsAttributeRouted(controllerHasSelectorModel, actionModel); - - AddInvalidModelStateFilter(actionModel); - - InferParameterBindingSources(actionModel); - - InferParameterModelPrefixes(actionModel); - - AddMultipartFormDataConsumesAttribute(actionModel); - } - } - } - - // Internal for unit testing - internal void AddMultipartFormDataConsumesAttribute(ActionModel actionModel) - { - if (_apiBehaviorOptions.SuppressConsumesConstraintForFormFileParameters) - { - return; - } - - // Add a ConsumesAttribute if the request does not explicitly specify one. - if (actionModel.Filters.OfType().Any()) - { - return; - } - - foreach (var parameter in actionModel.Parameters) - { - var bindingSource = parameter.BindingInfo?.BindingSource; - if (bindingSource == BindingSource.FormFile) - { - // If an action accepts files, it must accept multipart/form-data. - actionModel.Filters.Add(new ConsumesAttribute("multipart/form-data")); - } - } - } - - private static void EnsureActionIsAttributeRouted(bool controllerHasSelectorModel, ActionModel actionModel) - { - if (!controllerHasSelectorModel && !actionModel.Selectors.Any(s => s.AttributeRouteModel != null)) - { - // Require attribute routing with controllers annotated with ApiControllerAttribute - var message = Resources.FormatApiController_AttributeRouteRequired( - actionModel.DisplayName, - nameof(ApiControllerAttribute)); - throw new InvalidOperationException(message); - } - } - - private void AddInvalidModelStateFilter(ActionModel actionModel) - { - if (_apiBehaviorOptions.SuppressModelStateInvalidFilter) - { - return; - } - - Debug.Assert(_apiBehaviorOptions.InvalidModelStateResponseFactory != null); - actionModel.Filters.Add(_modelStateInvalidFilter); - } - - // Internal for unit testing - internal void InferParameterBindingSources(ActionModel actionModel) - { - if (_modelMetadataProvider == null || _apiBehaviorOptions.SuppressInferBindingSourcesForParameters) - { - return; - } - var inferredBindingSources = new BindingSource[actionModel.Parameters.Count]; - - for (var i = 0; i < actionModel.Parameters.Count; i++) - { - var parameter = actionModel.Parameters[i]; - var bindingSource = parameter.BindingInfo?.BindingSource; - if (bindingSource == null) - { - bindingSource = InferBindingSourceForParameter(parameter); - - parameter.BindingInfo = parameter.BindingInfo ?? new BindingInfo(); - parameter.BindingInfo.BindingSource = bindingSource; - } - } - - var fromBodyParameters = actionModel.Parameters.Where(p => p.BindingInfo.BindingSource == BindingSource.Body).ToList(); - if (fromBodyParameters.Count > 1) - { - var parameters = string.Join(Environment.NewLine, fromBodyParameters.Select(p => p.DisplayName)); - var message = Resources.FormatApiController_MultipleBodyParametersFound( - actionModel.DisplayName, - nameof(FromQueryAttribute), - nameof(FromRouteAttribute), - nameof(FromBodyAttribute)); - - message += Environment.NewLine + parameters; - throw new InvalidOperationException(message); - } - } - - // For any complex types that are bound from value providers, set the prefix - // to the empty prefix by default. This makes binding much more predictable - // and describable via ApiExplorer - - // internal for testing - internal void InferBoundPropertyModelPrefixes(ControllerModel controllerModel) - { - foreach (var property in controllerModel.ControllerProperties) - { - if (property.BindingInfo != null && - property.BindingInfo.BinderModelName == null && - property.BindingInfo.BindingSource != null && - !property.BindingInfo.BindingSource.IsGreedy) - { - var metadata = _modelMetadataProvider.GetMetadataForProperty( - controllerModel.ControllerType, - property.PropertyInfo.Name); - if (metadata.IsComplexType && !metadata.IsCollectionType) - { - property.BindingInfo.BinderModelName = string.Empty; - } - } - } - } - - // internal for testing - internal void InferParameterModelPrefixes(ActionModel actionModel) - { - foreach (var parameter in actionModel.Parameters) - { - var bindingInfo = parameter.BindingInfo; - if (bindingInfo?.BindingSource != null && - bindingInfo.BinderModelName == null && - !bindingInfo.BindingSource.IsGreedy && - IsComplexTypeParameter(parameter)) - { - parameter.BindingInfo.BinderModelName = string.Empty; - } - } - } - - // Internal for unit testing. - internal BindingSource InferBindingSourceForParameter(ParameterModel parameter) - { - if (ParameterExistsInAnyRoute(parameter.Action, parameter.ParameterName)) - { - return BindingSource.Path; - } - - var bindingSource = IsComplexTypeParameter(parameter) ? - BindingSource.Body : - BindingSource.Query; - - return bindingSource; - } - - private bool ParameterExistsInAnyRoute(ActionModel actionModel, string parameterName) - { - foreach (var (route, _, _) in ActionAttributeRouteModel.GetAttributeRoutes(actionModel)) - { - if (route == null) - { - continue; - } - - var parsedTemplate = TemplateParser.Parse(route.Template); - if (parsedTemplate.GetParameter(parameterName) != null) - { - return true; - } - } - - return false; - } - - private bool IsComplexTypeParameter(ParameterModel parameter) - { - // No need for information from attributes on the parameter. Just use its type. - var metadata = _modelMetadataProvider - .GetMetadataForType(parameter.ParameterInfo.ParameterType); - return metadata.IsComplexType && !metadata.IsCollectionType; - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorOptionsSetup.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorOptionsSetup.cs index 48f50f7980..02ae3bc30d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorOptionsSetup.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorOptionsSetup.cs @@ -2,17 +2,46 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.Internal { - public class ApiBehaviorOptionsSetup : IConfigureOptions + public class ApiBehaviorOptionsSetup : + ConfigureCompatibilityOptions, + IConfigureOptions { + internal static readonly Func DefaultFactory = DefaultInvalidModelStateResponse; + internal static readonly Func ProblemDetailsFactory = ProblemDetailsInvalidModelStateResponse; - public ApiBehaviorOptionsSetup() + public ApiBehaviorOptionsSetup( + ILoggerFactory loggerFactory, + IOptions compatibilityOptions) + : base(loggerFactory, compatibilityOptions) { } + protected override IReadOnlyDictionary DefaultValues + { + get + { + var dictionary = new Dictionary(); + + if (Version < CompatibilityVersion.Version_2_2) + { + dictionary[nameof(ApiBehaviorOptions.SuppressMapClientErrors)] = true; + dictionary[nameof(ApiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses)] = true; + dictionary[nameof(ApiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery)] = true; + } + + return dictionary; + } + } + public void Configure(ApiBehaviorOptions options) { if (options == null) @@ -20,17 +49,102 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(options)); } - options.InvalidModelStateResponseFactory = GetInvalidModelStateResponse; + options.InvalidModelStateResponseFactory = DefaultFactory; + ConfigureClientErrorMapping(options); + } - IActionResult GetInvalidModelStateResponse(ActionContext context) + public override void PostConfigure(string name, ApiBehaviorOptions options) + { + // Let compatibility switches do their thing. + base.PostConfigure(name, options); + + // We want to use problem details factory only if + // (a) it has not been opted out of (SuppressMapClientErrors = true) + // (b) a different factory was configured + if (!options.SuppressMapClientErrors && + object.ReferenceEquals(options.InvalidModelStateResponseFactory, DefaultFactory)) { - var result = new BadRequestObjectResult(context.ModelState); - - result.ContentTypes.Add("application/json"); - result.ContentTypes.Add("application/xml"); - - return result; + options.InvalidModelStateResponseFactory = ProblemDetailsFactory; } } + + // Internal for unit testing + internal static void ConfigureClientErrorMapping(ApiBehaviorOptions options) + { + options.ClientErrorMapping[400] = new ClientErrorData + { + Link = "https://tools.ietf.org/html/rfc7231#section-6.5.1", + Title = Resources.ApiConventions_Title_400, + }; + + options.ClientErrorMapping[401] = new ClientErrorData + { + Link = "https://tools.ietf.org/html/rfc7235#section-3.1", + Title = Resources.ApiConventions_Title_401, + }; + + options.ClientErrorMapping[403] = new ClientErrorData + { + Link = "https://tools.ietf.org/html/rfc7231#section-6.5.3", + Title = Resources.ApiConventions_Title_403, + }; + + options.ClientErrorMapping[404] = new ClientErrorData + { + Link = "https://tools.ietf.org/html/rfc7231#section-6.5.4", + Title = Resources.ApiConventions_Title_404, + }; + + options.ClientErrorMapping[406] = new ClientErrorData + { + Link = "https://tools.ietf.org/html/rfc7231#section-6.5.6", + Title = Resources.ApiConventions_Title_406, + }; + + options.ClientErrorMapping[409] = new ClientErrorData + { + Link = "https://tools.ietf.org/html/rfc7231#section-6.5.8", + Title = Resources.ApiConventions_Title_409, + }; + + options.ClientErrorMapping[415] = new ClientErrorData + { + Link = "https://tools.ietf.org/html/rfc7231#section-6.5.13", + Title = Resources.ApiConventions_Title_415, + }; + + options.ClientErrorMapping[422] = new ClientErrorData + { + Link = "https://tools.ietf.org/html/rfc4918#section-11.2", + Title = Resources.ApiConventions_Title_422, + }; + } + + private static IActionResult DefaultInvalidModelStateResponse(ActionContext context) + { + var result = new BadRequestObjectResult(context.ModelState); + + result.ContentTypes.Add("application/json"); + result.ContentTypes.Add("application/xml"); + + return result; + } + + internal static IActionResult ProblemDetailsInvalidModelStateResponse(ActionContext context) + { + var problemDetails = new ValidationProblemDetails(context.ModelState) + { + Status = StatusCodes.Status400BadRequest, + }; + + ProblemDetailsClientErrorFactory.SetTraceId(context, problemDetails); + + var result = new BadRequestObjectResult(problemDetails); + + result.ContentTypes.Add("application/problem+json"); + result.ContentTypes.Add("application/problem+xml"); + + return result; + } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/AttributeRoute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/AttributeRoute.cs index bf1f4d575d..9d307e7e23 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/AttributeRoute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/AttributeRoute.cs @@ -6,13 +6,13 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Template; using Microsoft.AspNetCore.Routing.Tree; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; +using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; namespace Microsoft.AspNetCore.Mvc.Internal { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ClientValidatorCache.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ClientValidatorCache.cs index 37adde40c2..0bcc97f67b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ClientValidatorCache.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ClientValidatorCache.cs @@ -121,7 +121,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal return validators; } - private struct CacheEntry + private readonly struct CacheEntry { public CacheEntry(IReadOnlyList validators) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerActionDescriptorBuilder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerActionDescriptorBuilder.cs index 18ae818293..21ec3aee6b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerActionDescriptorBuilder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerActionDescriptorBuilder.cs @@ -10,9 +10,10 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; namespace Microsoft.AspNetCore.Mvc.Internal { @@ -138,10 +139,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal ActionModel action) { var defaultControllerConstraints = Enumerable.Empty(); + var defaultControllerEndpointMetadata = Enumerable.Empty(); if (controller.Selectors.Count > 0) { defaultControllerConstraints = controller.Selectors[0].ActionConstraints .Where(constraint => !(constraint is IRouteTemplateProvider)); + defaultControllerEndpointMetadata = controller.Selectors[0].EndpointMetadata; } var actionDescriptors = new List(); @@ -163,6 +166,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal } AddActionConstraints(actionDescriptor, actionSelector, controllerConstraints); + + // Metadata for the action is more significant so order it before the controller metadata + var actionDescriptorMetadata = actionSelector.EndpointMetadata.ToList(); + actionDescriptorMetadata.AddRange(defaultControllerEndpointMetadata); + + actionDescriptor.EndpointMetadata = actionDescriptorMetadata; } return actionDescriptors; @@ -381,15 +390,20 @@ namespace Microsoft.AspNetCore.Mvc.Internal { try { + actionDescriptor.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out var transformer); + var routeTokenTransformer = transformer as IOutboundParameterTransformer; + actionDescriptor.AttributeRouteInfo.Template = AttributeRouteModel.ReplaceTokens( actionDescriptor.AttributeRouteInfo.Template, - actionDescriptor.RouteValues); + actionDescriptor.RouteValues, + routeTokenTransformer); if (actionDescriptor.AttributeRouteInfo.Name != null) { actionDescriptor.AttributeRouteInfo.Name = AttributeRouteModel.ReplaceTokens( actionDescriptor.AttributeRouteInfo.Name, - actionDescriptor.RouteValues); + actionDescriptor.RouteValues, + routeTokenTransformer); } } catch (InvalidOperationException ex) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerActionInvoker.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerActionInvoker.cs index f020c86bf3..767ed08505 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerActionInvoker.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerActionInvoker.cs @@ -7,11 +7,11 @@ using System.Diagnostics; using System.Runtime.ExceptionServices; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; +using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; namespace Microsoft.AspNetCore.Mvc.Internal { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerBinderDelegateProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerBinderDelegateProvider.cs index e29525ea11..20d600281e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerBinderDelegateProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ControllerBinderDelegateProvider.cs @@ -194,7 +194,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal return propertyBindingInfo; } - private struct BinderItem + private readonly struct BinderItem { public BinderItem(IModelBinder modelBinder, ModelMetadata modelMetadata) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs index fc380933c2..c79b2b209d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultApplicationModelProvider.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Options; @@ -641,6 +642,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } AddRange(selectorModel.ActionConstraints, attributes.OfType()); + AddRange(selectorModel.EndpointMetadata, attributes); // Simple case, all HTTP method attributes apply var httpMethods = attributes @@ -652,6 +654,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal if (httpMethods.Length > 0) { selectorModel.ActionConstraints.Add(new HttpMethodActionConstraint(httpMethods)); + selectorModel.EndpointMetadata.Add(new HttpMethodMetadata(httpMethods)); } return selectorModel; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelBindingContext.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelBindingContext.cs index 0ff9292a02..c57992ceed 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelBindingContext.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelBindingContext.cs @@ -237,6 +237,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding // Because this is the top-level context, FieldName and ModelName should be the same. FieldName = binderModelName ?? modelName, ModelName = binderModelName ?? modelName, + OriginalModelName = binderModelName ?? modelName, IsTopLevelObject = true, ModelMetadata = metadata, diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/FilterCursorItem.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/FilterCursorItem.cs index ccb42dc3f7..a62aa1b8cc 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/FilterCursorItem.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/FilterCursorItem.cs @@ -3,7 +3,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { - public struct FilterCursorItem + public readonly struct FilterCursorItem { public FilterCursorItem(TFilter filter, TFilterAsync filterAsync) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/FilterFactoryResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/FilterFactoryResult.cs index e532a08427..69e39cfea9 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/FilterFactoryResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/FilterFactoryResult.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Mvc.Filters; namespace Microsoft.AspNetCore.Mvc.Internal { - public struct FilterFactoryResult + public readonly struct FilterFactoryResult { public FilterFactoryResult( FilterItem[] cacheableFilters, diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MediaTypeSegmentWithQuality.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MediaTypeSegmentWithQuality.cs index c9a8f4502e..52d6281ed1 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MediaTypeSegmentWithQuality.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MediaTypeSegmentWithQuality.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Internal /// /// A media type with its associated quality. /// - public struct MediaTypeSegmentWithQuality + public readonly struct MediaTypeSegmentWithQuality { /// /// Initializes an instance of . diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MiddlewareFilterBuilder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MiddlewareFilterBuilder.cs index cbceab72b0..6257968f90 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MiddlewareFilterBuilder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MiddlewareFilterBuilder.cs @@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal public RequestDelegate GetPipeline(Type configurationType) { - // Build the pipeline only once. This is similar to how middlewares registered in Startup are constructed. + // Build the pipeline only once. This is similar to how middleware registered in Startup are constructed. var requestDelegate = _pipelinesCache.GetOrAdd( configurationType, @@ -77,7 +77,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } // Ideally we want the experience of a middleware pipeline to behave the same as if it was registered - // in Startup. In this scenario, an Exception thrown in a middelware later in the pipeline gets + // in Startup. In this scenario, an Exception thrown in a middleware later in the pipeline gets // propagated back to earlier middleware. So, check if a later resource filter threw an Exception and // propagate that back to the middleware pipeline. resourceExecutedContext.ExceptionDispatchInfo?.Throw(); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MiddlewareFilterBuilderStartupFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MiddlewareFilterBuilderStartupFilter.cs new file mode 100644 index 0000000000..d568da7b51 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MiddlewareFilterBuilderStartupFilter.cs @@ -0,0 +1,26 @@ +// 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.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + internal class MiddlewareFilterBuilderStartupFilter : IStartupFilter + { + public Action Configure(Action next) + { + return MiddlewareFilterBuilder; + + void MiddlewareFilterBuilder(IApplicationBuilder builder) + { + var middlewarePipelineBuilder = builder.ApplicationServices.GetRequiredService(); + middlewarePipelineBuilder.ApplicationBuilder = builder.New(); + + next(builder); + } + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcAttributeRouteHandler.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcAttributeRouteHandler.cs index 93f7277522..c40318e2d9 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcAttributeRouteHandler.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcAttributeRouteHandler.cs @@ -5,10 +5,10 @@ using System; using System.Diagnostics; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; +using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; namespace Microsoft.AspNetCore.Mvc.Internal { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreLoggerExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreLoggerExtensions.cs index c840f785b1..b4a223b8b2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreLoggerExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreLoggerExtensions.cs @@ -34,6 +34,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal private static readonly Action _actionExecuting; private static readonly Action _actionExecuted; + private static readonly Action _pageExecuting; + private static readonly Action _pageExecuted; + private static readonly Action _challengeResultExecuting; private static readonly Action _contentResultExecuting; @@ -106,6 +109,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal private static readonly Action _foundNoValueForPropertyInRequest; private static readonly Action _foundNoValueForParameterInRequest; private static readonly Action _foundNoValueInRequest; + private static readonly Action _parameterBinderRequestPredicateShortCircuitOfProperty; + private static readonly Action _parameterBinderRequestPredicateShortCircuitOfParameter; private static readonly Action _noPublicSettableProperties; private static readonly Action _cannotBindToComplexType; private static readonly Action _cannotBindToFilesCollectionDueToUnsupportedContentType; @@ -146,6 +151,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal private static readonly Action _notMostEffectiveFilter; private static readonly Action, Exception> _registeredOutputFormatters; + private static readonly Action _transformingClientError; + static MvcCoreLoggerExtensions() { _actionExecuting = LoggerMessage.Define( @@ -158,6 +165,16 @@ namespace Microsoft.AspNetCore.Mvc.Internal 2, "Executed action {ActionName} in {ElapsedMilliseconds}ms"); + _pageExecuting = LoggerMessage.Define( + LogLevel.Information, + 3, + "Route matched with {RouteData}. Executing page {PageName}"); + + _pageExecuted = LoggerMessage.Define( + LogLevel.Information, + 4, + "Executed page {PageName} in {ElapsedMilliseconds}ms"); + _challengeResultExecuting = LoggerMessage.Define( LogLevel.Information, 1, @@ -623,6 +640,21 @@ namespace Microsoft.AspNetCore.Mvc.Internal LogLevel.Debug, 46, "Could not find a value in the request with name '{ModelName}' of type '{ModelType}'."); + + _parameterBinderRequestPredicateShortCircuitOfProperty = LoggerMessage.Define( + LogLevel.Debug, + 47, + "Skipped binding property '{PropertyContainerType}.{PropertyName}' since its binding information disallowed it for the current request."); + + _parameterBinderRequestPredicateShortCircuitOfParameter = LoggerMessage.Define( + LogLevel.Debug, + 48, + "Skipped binding parameter '{ParameterName}' since its binding information disallowed it for the current request."); + + _transformingClientError = LoggerMessage.Define( + LogLevel.Trace, + new EventId(49, nameof(Infrastructure.ClientErrorResultFilter)), + "Replacing {InitialActionResultType} with status code {StatusCode} with {ReplacedActionResultType}."); } public static void RegisteredOutputFormatters(this ILogger logger, IEnumerable outputFormatters) @@ -682,8 +714,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal stringBuilder.Append($"{routeKeys[i]} = \"{routeValues[i]}\", "); } } - - _actionExecuting(logger, stringBuilder.ToString(), action.DisplayName, null); + if (action.RouteValues.TryGetValue("page", out var page) && page != null) + { + _pageExecuting(logger, stringBuilder.ToString(), action.DisplayName, null); + } + else + { + _actionExecuting(logger, stringBuilder.ToString(), action.DisplayName, null); + } } } @@ -765,7 +803,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Don't log if logging wasn't enabled at start of request as time will be wildly wrong. if (logger.IsEnabled(LogLevel.Information)) { - _actionExecuted(logger, action.DisplayName, timeSpan.TotalMilliseconds, null); + if (action.RouteValues.TryGetValue("page", out var page) && page != null) + { + _pageExecuted(logger, action.DisplayName, timeSpan.TotalMilliseconds, null); + } + else + { + _actionExecuted(logger, action.DisplayName, timeSpan.TotalMilliseconds, null); + } } } @@ -1286,14 +1331,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal public static void AttemptingToBindParameterOrProperty( this ILogger logger, ParameterDescriptor parameter, - ModelBindingContext bindingContext) + ModelMetadata modelMetadata) { if (!logger.IsEnabled(LogLevel.Debug)) { return; } - var modelMetadata = bindingContext.ModelMetadata; switch (modelMetadata.MetadataKind) { case ModelMetadataKind.Parameter: @@ -1329,14 +1373,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal public static void DoneAttemptingToBindParameterOrProperty( this ILogger logger, ParameterDescriptor parameter, - ModelBindingContext bindingContext) + ModelMetadata modelMetadata) { if (!logger.IsEnabled(LogLevel.Debug)) { return; } - var modelMetadata = bindingContext.ModelMetadata; switch (modelMetadata.MetadataKind) { case ModelMetadataKind.Parameter: @@ -1372,14 +1415,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal public static void AttemptingToValidateParameterOrProperty( this ILogger logger, ParameterDescriptor parameter, - ModelBindingContext bindingContext) + ModelMetadata modelMetadata) { if (!logger.IsEnabled(LogLevel.Debug)) { return; } - var modelMetadata = bindingContext.ModelMetadata; switch (modelMetadata.MetadataKind) { case ModelMetadataKind.Parameter: @@ -1416,14 +1458,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal public static void DoneAttemptingToValidateParameterOrProperty( this ILogger logger, ParameterDescriptor parameter, - ModelBindingContext bindingContext) + ModelMetadata modelMetadata) { if (!logger.IsEnabled(LogLevel.Debug)) { return; } - var modelMetadata = bindingContext.ModelMetadata; switch (modelMetadata.MetadataKind) { case ModelMetadataKind.Parameter: @@ -1500,6 +1541,55 @@ namespace Microsoft.AspNetCore.Mvc.Internal null); } + public static void ParameterBinderRequestPredicateShortCircuit( + this ILogger logger, + ParameterDescriptor parameter, + ModelMetadata modelMetadata) + { + if (!logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + switch (modelMetadata.MetadataKind) + { + case ModelMetadataKind.Parameter: + _parameterBinderRequestPredicateShortCircuitOfParameter( + logger, + modelMetadata.ParameterName, + null); + break; + case ModelMetadataKind.Property: + _parameterBinderRequestPredicateShortCircuitOfProperty( + logger, + modelMetadata.ContainerType, + modelMetadata.PropertyName, + null); + break; + case ModelMetadataKind.Type: + if (parameter is ControllerParameterDescriptor controllerParameterDescriptor) + { + _parameterBinderRequestPredicateShortCircuitOfParameter( + logger, + controllerParameterDescriptor.ParameterInfo.Name, + null); + } + else + { + // Likely binding a page handler parameter. Due to various special cases, parameter.Name may + // be empty. No way to determine actual name. This case is less likely than for binding logging + // (above). Should occur only with a legacy IModelMetadataProvider implementation. + _parameterBinderRequestPredicateShortCircuitOfParameter(logger, parameter.Name, null); + } + break; + } + } + + public static void TransformingClientError(this ILogger logger, Type initialType, Type replacedType, int? statusCode) + { + _transformingClientError(logger, initialType, statusCode, replacedType, null); + } + private static void LogFilterExecutionPlan( ILogger logger, string filterType, diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs new file mode 100644 index 0000000000..df110bee25 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs @@ -0,0 +1,673 @@ +// 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.Linq; +using System.Text; +using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + internal class MvcEndpointDataSource : EndpointDataSource + { + private readonly IActionDescriptorCollectionProvider _actions; + private readonly MvcEndpointInvokerFactory _invokerFactory; + private readonly ParameterPolicyFactory _parameterPolicyFactory; + + // The following are protected by this lock for WRITES only. This pattern is similar + // to DefaultActionDescriptorChangeProvider - see comments there for details on + // all of the threading behaviors. + private readonly object _lock = new object(); + private List _endpoints; + private CancellationTokenSource _cancellationTokenSource; + private IChangeToken _changeToken; + + public MvcEndpointDataSource( + IActionDescriptorCollectionProvider actions, + MvcEndpointInvokerFactory invokerFactory, + ParameterPolicyFactory parameterPolicyFactory) + { + if (actions == null) + { + throw new ArgumentNullException(nameof(actions)); + } + + if (invokerFactory == null) + { + throw new ArgumentNullException(nameof(invokerFactory)); + } + + if (parameterPolicyFactory == null) + { + throw new ArgumentNullException(nameof(parameterPolicyFactory)); + } + + _actions = actions; + _invokerFactory = invokerFactory; + _parameterPolicyFactory = parameterPolicyFactory; + + ConventionalEndpointInfos = new List(); + + // IMPORTANT: this needs to be the last thing we do in the constructor. Change notifications can happen immediately! + // + // It's possible for someone to override the collection provider without providing + // change notifications. If that's the case we won't process changes. + if (actions is ActionDescriptorCollectionProvider collectionProviderWithChangeToken) + { + ChangeToken.OnChange( + () => collectionProviderWithChangeToken.GetChangeToken(), + UpdateEndpoints); + } + } + + public List ConventionalEndpointInfos { get; } + + public override IReadOnlyList Endpoints + { + get + { + Initialize(); + Debug.Assert(_changeToken != null); + Debug.Assert(_endpoints != null); + return _endpoints; + } + } + + public override IChangeToken GetChangeToken() + { + Initialize(); + Debug.Assert(_changeToken != null); + Debug.Assert(_endpoints != null); + return _changeToken; + } + + private void Initialize() + { + if (_endpoints == null) + { + lock (_lock) + { + if (_endpoints == null) + { + UpdateEndpoints(); + } + } + } + } + + private void UpdateEndpoints() + { + lock (_lock) + { + var endpoints = new List(); + StringBuilder patternStringBuilder = null; + + foreach (var action in _actions.ActionDescriptors.Items) + { + if (action.AttributeRouteInfo == null) + { + // In traditional conventional routing setup, the routes defined by a user have a static order + // defined by how they are added into the list. We would like to maintain the same order when building + // up the endpoints too. + // + // Start with an order of '1' for conventional routes as attribute routes have a default order of '0'. + // This is for scenarios dealing with migrating existing Router based code to Endpoint Routing world. + var conventionalRouteOrder = 1; + + // Check each of the conventional patterns to see if the action would be reachable + // If the action and pattern are compatible then create an endpoint with the + // area/controller/action parameter parts replaced with literals + // + // e.g. {controller}/{action} with HomeController.Index and HomeController.Login + // would result in endpoints: + // - Home/Index + // - Home/Login + foreach (var endpointInfo in ConventionalEndpointInfos) + { + // An 'endpointInfo' is applicable if: + // 1. it has a parameter (or default value) for 'required' non-null route value + // 2. it does not have a parameter (or default value) for 'required' null route value + var isApplicable = true; + foreach (var routeKey in action.RouteValues.Keys) + { + if (!MatchRouteValue(action, endpointInfo, routeKey)) + { + isApplicable = false; + break; + } + } + + if (!isApplicable) + { + continue; + } + + conventionalRouteOrder = CreateEndpoints( + endpoints, + ref patternStringBuilder, + action, + conventionalRouteOrder, + endpointInfo.ParsedPattern, + endpointInfo.MergedDefaults, + endpointInfo.Defaults, + endpointInfo.Name, + endpointInfo.DataTokens, + endpointInfo.ParameterPolicies, + suppressLinkGeneration: false, + suppressPathMatching: false); + } + } + else + { + var attributeRoutePattern = RoutePatternFactory.Parse(action.AttributeRouteInfo.Template); + + CreateEndpoints( + endpoints, + ref patternStringBuilder, + action, + action.AttributeRouteInfo.Order, + attributeRoutePattern, + attributeRoutePattern.Defaults, + nonInlineDefaults: null, + action.AttributeRouteInfo.Name, + dataTokens: null, + allParameterPolicies: null, + action.AttributeRouteInfo.SuppressLinkGeneration, + action.AttributeRouteInfo.SuppressPathMatching); + } + } + + // See comments in DefaultActionDescriptorCollectionProvider. These steps are done + // in a specific order to ensure callers always see a consistent state. + + // Step 1 - capture old token + var oldCancellationTokenSource = _cancellationTokenSource; + + // Step 2 - update endpoints + _endpoints = endpoints; + + // Step 3 - create new change token + _cancellationTokenSource = new CancellationTokenSource(); + _changeToken = new CancellationChangeToken(_cancellationTokenSource.Token); + + // Step 4 - trigger old token + oldCancellationTokenSource?.Cancel(); + } + } + + // CreateEndpoints processes the route pattern, replacing area/controller/action parameters with endpoint values + // Because of default values it is possible for a route pattern to resolve to multiple endpoints + private int CreateEndpoints( + List endpoints, + ref StringBuilder patternStringBuilder, + ActionDescriptor action, + int routeOrder, + RoutePattern routePattern, + IReadOnlyDictionary allDefaults, + IReadOnlyDictionary nonInlineDefaults, + string name, + RouteValueDictionary dataTokens, + IDictionary> allParameterPolicies, + bool suppressLinkGeneration, + bool suppressPathMatching) + { + var newPathSegments = routePattern.PathSegments.ToList(); + var hasLinkGenerationEndpoint = false; + + // Create a mutable copy + var nonInlineDefaultsCopy = nonInlineDefaults != null + ? new RouteValueDictionary(nonInlineDefaults) + : null; + + var resolvedRouteValues = ResolveActionRouteValues(action, allDefaults); + + for (var i = 0; i < newPathSegments.Count; i++) + { + // Check if the pattern can be shortened because the remaining parameters are optional + // + // e.g. Matching pattern {controller=Home}/{action=Index} against HomeController.Index + // can resolve to the following endpoints: (sorted by RouteEndpoint.Order) + // - / + // - /Home + // - /Home/Index + if (UseDefaultValuePlusRemainingSegmentsOptional( + i, + action, + resolvedRouteValues, + allDefaults, + ref nonInlineDefaultsCopy, + newPathSegments)) + { + // The route pattern has matching default values AND an optional parameter + // For link generation we need to include an endpoint with parameters and default values + // so the link is correctly shortened + // e.g. {controller=Home}/{action=Index}/{id=17} + if (!hasLinkGenerationEndpoint) + { + var ep = CreateEndpoint( + action, + resolvedRouteValues, + name, + GetPattern(ref patternStringBuilder, newPathSegments), + newPathSegments, + nonInlineDefaultsCopy, + routeOrder++, + dataTokens, + suppressLinkGeneration, + true); + endpoints.Add(ep); + + hasLinkGenerationEndpoint = true; + } + + var subPathSegments = newPathSegments.Take(i); + + var subEndpoint = CreateEndpoint( + action, + resolvedRouteValues, + name, + GetPattern(ref patternStringBuilder, subPathSegments), + subPathSegments, + nonInlineDefaultsCopy, + routeOrder++, + dataTokens, + suppressLinkGeneration, + suppressPathMatching); + endpoints.Add(subEndpoint); + } + + UpdatePathSegments(i, action, resolvedRouteValues, routePattern, newPathSegments, ref allParameterPolicies); + } + + var finalEndpoint = CreateEndpoint( + action, + resolvedRouteValues, + name, + GetPattern(ref patternStringBuilder, newPathSegments), + newPathSegments, + nonInlineDefaultsCopy, + routeOrder++, + dataTokens, + suppressLinkGeneration, + suppressPathMatching); + endpoints.Add(finalEndpoint); + + return routeOrder; + + string GetPattern(ref StringBuilder sb, IEnumerable segments) + { + if (sb == null) + { + sb = new StringBuilder(); + } + + RoutePatternWriter.WriteString(sb, segments); + var rawPattern = sb.ToString(); + sb.Length = 0; + + return rawPattern; + } + } + + private static IDictionary ResolveActionRouteValues(ActionDescriptor action, IReadOnlyDictionary allDefaults) + { + Dictionary resolvedRequiredValues = null; + + foreach (var kvp in action.RouteValues) + { + // Check whether there is a matching default value with a different case + // e.g. {controller=HOME}/{action} with HomeController.Index will have route values: + // - controller = HOME + // - action = Index + if (allDefaults.TryGetValue(kvp.Key, out var value) && + value is string defaultValue && + !string.Equals(kvp.Value, defaultValue, StringComparison.Ordinal) && + string.Equals(kvp.Value, defaultValue, StringComparison.OrdinalIgnoreCase)) + { + if (resolvedRequiredValues == null) + { + resolvedRequiredValues = new Dictionary(action.RouteValues, StringComparer.OrdinalIgnoreCase); + } + + resolvedRequiredValues[kvp.Key] = defaultValue; + } + } + + return resolvedRequiredValues ?? action.RouteValues; + } + + private void UpdatePathSegments( + int i, + ActionDescriptor action, + IDictionary resolvedRequiredValues, + RoutePattern routePattern, + List newPathSegments, + ref IDictionary> allParameterPolicies) + { + List segmentParts = null; // Initialize only as needed + var segment = newPathSegments[i]; + for (var j = 0; j < segment.Parts.Count; j++) + { + var part = segment.Parts[j]; + + if (part is RoutePatternParameterPart parameterPart) + { + if (resolvedRequiredValues.TryGetValue(parameterPart.Name, out var parameterRouteValue)) + { + if (segmentParts == null) + { + segmentParts = segment.Parts.ToList(); + } + if (allParameterPolicies == null) + { + allParameterPolicies = MvcEndpointInfo.BuildParameterPolicies(routePattern.Parameters, _parameterPolicyFactory); + } + + // Route value could be null if it is a "known" route value. + // Do not use the null value to de-normalize the route pattern, + // instead leave the parameter unchanged. + // e.g. + // RouteValues will contain a null "page" value if there are Razor pages + // Skip replacing the {page} parameter + if (parameterRouteValue != null) + { + if (allParameterPolicies.TryGetValue(parameterPart.Name, out var parameterPolicies)) + { + // Check if the parameter has a transformer policy + // Use the first transformer policy + for (var k = 0; k < parameterPolicies.Count; k++) + { + if (parameterPolicies[k] is IOutboundParameterTransformer parameterTransformer) + { + parameterRouteValue = parameterTransformer.TransformOutbound(parameterRouteValue); + break; + } + } + } + + segmentParts[j] = RoutePatternFactory.LiteralPart(parameterRouteValue); + } + } + } + } + + // A parameter part was replaced so replace segment with updated parts + if (segmentParts != null) + { + newPathSegments[i] = RoutePatternFactory.Segment(segmentParts); + } + } + + private bool UseDefaultValuePlusRemainingSegmentsOptional( + int segmentIndex, + ActionDescriptor action, + IDictionary resolvedRequiredValues, + IReadOnlyDictionary allDefaults, + ref RouteValueDictionary nonInlineDefaults, + List pathSegments) + { + // Check whether the remaining segments are all optional and one or more of them is + // for area/controller/action and has a default value + var usedDefaultValue = false; + + for (var i = segmentIndex; i < pathSegments.Count; i++) + { + var segment = pathSegments[i]; + for (var j = 0; j < segment.Parts.Count; j++) + { + var part = segment.Parts[j]; + if (part.IsParameter && part is RoutePatternParameterPart parameterPart) + { + if (allDefaults.TryGetValue(parameterPart.Name, out var v)) + { + if (resolvedRequiredValues.TryGetValue(parameterPart.Name, out var routeValue)) + { + if (string.Equals(v as string, routeValue, StringComparison.OrdinalIgnoreCase)) + { + usedDefaultValue = true; + continue; + } + } + else + { + if (nonInlineDefaults == null) + { + nonInlineDefaults = new RouteValueDictionary(); + } + nonInlineDefaults.TryAdd(parameterPart.Name, v); + + usedDefaultValue = true; + continue; + } + } + + if (parameterPart.IsOptional || parameterPart.IsCatchAll) + { + continue; + } + } + else if (part.IsSeparator && part is RoutePatternSeparatorPart separatorPart + && separatorPart.Content == ".") + { + // Check if this pattern ends in an optional extension, e.g. ".{ext?}" + // Current literal must be "." and followed by a single optional parameter part + var nextPartIndex = j + 1; + + if (nextPartIndex == segment.Parts.Count - 1 + && segment.Parts[nextPartIndex].IsParameter + && segment.Parts[nextPartIndex] is RoutePatternParameterPart extensionParameterPart + && extensionParameterPart.IsOptional) + { + continue; + } + } + + // Stop because there is a non-optional/non-defaulted trailing value + return false; + } + } + + return usedDefaultValue; + } + + private bool MatchRouteValue(ActionDescriptor action, MvcEndpointInfo endpointInfo, string routeKey) + { + if (!action.RouteValues.TryGetValue(routeKey, out var actionValue) || string.IsNullOrWhiteSpace(actionValue)) + { + // Action does not have a value for this routeKey, most likely because action is not in an area + // Check that the pattern does not have a parameter for the routeKey + var matchingParameter = endpointInfo.ParsedPattern.GetParameter(routeKey); + if (matchingParameter == null && + (!endpointInfo.ParsedPattern.Defaults.TryGetValue(routeKey, out var value) || + !string.IsNullOrEmpty(Convert.ToString(value)))) + { + return true; + } + } + else + { + if (endpointInfo.MergedDefaults != null && string.Equals(actionValue, endpointInfo.MergedDefaults[routeKey] as string, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var matchingParameter = endpointInfo.ParsedPattern.GetParameter(routeKey); + if (matchingParameter != null) + { + // Check that the value matches against constraints on that parameter + // e.g. For {controller:regex((Home|Login))} the controller value must match the regex + if (endpointInfo.ParameterPolicies.TryGetValue(routeKey, out var parameterPolicies)) + { + foreach (var policy in parameterPolicies) + { + if (policy is IRouteConstraint constraint + && !constraint.Match(httpContext: null, NullRouter.Instance, routeKey, new RouteValueDictionary(action.RouteValues), RouteDirection.IncomingRequest)) + { + // Did not match constraint + return false; + } + } + } + + return true; + } + } + + return false; + } + + private RouteEndpoint CreateEndpoint( + ActionDescriptor action, + IDictionary actionRouteValues, + string routeName, + string patternRawText, + IEnumerable segments, + object nonInlineDefaults, + int order, + RouteValueDictionary dataTokens, + bool suppressLinkGeneration, + bool suppressPathMatching) + { + RequestDelegate requestDelegate = (context) => + { + var routeData = context.GetRouteData(); + + var actionContext = new ActionContext(context, routeData, action); + + var invoker = _invokerFactory.CreateInvoker(actionContext); + return invoker.InvokeAsync(); + }; + + var defaults = new RouteValueDictionary(nonInlineDefaults); + EnsureRequiredValuesInDefaults(actionRouteValues, defaults, segments); + + var metadataCollection = BuildEndpointMetadata( + action, + routeName, + new RouteValueDictionary(actionRouteValues), + dataTokens, + suppressLinkGeneration, + suppressPathMatching); + + var endpoint = new RouteEndpoint( + requestDelegate, + RoutePatternFactory.Pattern(patternRawText, defaults, parameterPolicies: null, segments), + order, + metadataCollection, + action.DisplayName); + + return endpoint; + } + + private static EndpointMetadataCollection BuildEndpointMetadata( + ActionDescriptor action, + string routeName, + RouteValueDictionary requiredValues, + RouteValueDictionary dataTokens, + bool suppressLinkGeneration, + bool suppressPathMatching) + { + var metadata = new List(); + + // Add action metadata first so it has a low precedence + if (action.EndpointMetadata != null) + { + metadata.AddRange(action.EndpointMetadata); + } + + metadata.Add(action); + + if (dataTokens != null) + { + metadata.Add(new DataTokensMetadata(dataTokens)); + } + + metadata.Add(new RouteValuesAddressMetadata(routeName, requiredValues)); + + // Add filter descriptors to endpoint metadata + if (action.FilterDescriptors != null && action.FilterDescriptors.Count > 0) + { + metadata.AddRange(action.FilterDescriptors.OrderBy(f => f, FilterDescriptorOrderComparer.Comparer) + .Select(f => f.Filter)); + } + + if (action.ActionConstraints != null && action.ActionConstraints.Count > 0) + { + // We explicitly convert a few types of action constraints into MatcherPolicy+Metadata + // to better integrate with the DFA matcher. + // + // Other IActionConstraint data will trigger a back-compat path that can execute + // action constraints. + foreach (var actionConstraint in action.ActionConstraints) + { + if (actionConstraint is HttpMethodActionConstraint httpMethodActionConstraint && + !metadata.OfType().Any()) + { + metadata.Add(new HttpMethodMetadata(httpMethodActionConstraint.HttpMethods)); + } + else if (actionConstraint is ConsumesAttribute consumesAttribute && + !metadata.OfType().Any()) + { + metadata.Add(new ConsumesMetadata(consumesAttribute.ContentTypes.ToArray())); + } + else if (!metadata.Contains(actionConstraint)) + { + // The constraint might have been added earlier, e.g. it is also a filter descriptor + metadata.Add(actionConstraint); + } + } + } + + if (suppressLinkGeneration) + { + metadata.Add(new SuppressLinkGenerationMetadata()); + } + + if (suppressPathMatching) + { + metadata.Add(new SuppressMatchingMetadata()); + } + + var metadataCollection = new EndpointMetadataCollection(metadata); + return metadataCollection; + } + + // Ensure route values are a subset of defaults + // Examples: + // + // Template: {controller}/{action}/{category}/{id?} + // Defaults(in-line or non in-line): category=products + // Required values: controller=foo, action=bar + // Final constructed pattern: foo/bar/{category}/{id?} + // Final defaults: controller=foo, action=bar, category=products + // + // Template: {controller=Home}/{action=Index}/{category=products}/{id?} + // Defaults: controller=Home, action=Index, category=products + // Required values: controller=foo, action=bar + // Final constructed pattern: foo/bar/{category}/{id?} + // Final defaults: controller=foo, action=bar, category=products + private void EnsureRequiredValuesInDefaults( + IDictionary routeValues, + RouteValueDictionary defaults, + IEnumerable segments) + { + foreach (var kvp in routeValues) + { + if (kvp.Value != null) + { + defaults[kvp.Key] = kvp.Value; + } + } + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointInvokerFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointInvokerFactory.cs new file mode 100644 index 0000000000..3737a3b718 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointInvokerFactory.cs @@ -0,0 +1,41 @@ +// 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.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + internal sealed class MvcEndpointInvokerFactory : IActionInvokerFactory + { + private readonly IActionInvokerFactory _invokerFactory; + private readonly IActionContextAccessor _actionContextAccessor; + + public MvcEndpointInvokerFactory( + IActionInvokerFactory invokerFactory) + : this(invokerFactory, actionContextAccessor: null) + { + } + + public MvcEndpointInvokerFactory( + IActionInvokerFactory invokerFactory, + IActionContextAccessor actionContextAccessor) + { + _invokerFactory = invokerFactory; + + // The IActionContextAccessor is optional. We want to avoid the overhead of using CallContext + // if possible. + _actionContextAccessor = actionContextAccessor; + } + + public IActionInvoker CreateInvoker(ActionContext actionContext) + { + if (_actionContextAccessor != null) + { + _actionContextAccessor.ActionContext = actionContext; + } + + return _invokerFactory.CreateInvoker(actionContext); + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/NormalizedRouteValue.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/NormalizedRouteValue.cs index 2da629c22e..3013ee3171 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/NormalizedRouteValue.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/NormalizedRouteValue.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Globalization; namespace Microsoft.AspNetCore.Mvc.Internal { @@ -45,7 +46,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal normalizedValue = value; } - var stringRouteValue = routeValue?.ToString(); + var stringRouteValue = Convert.ToString(routeValue, CultureInfo.InvariantCulture); if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase)) { return normalizedValue; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/NullRouter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/NullRouter.cs new file mode 100644 index 0000000000..c1b800c5bd --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/NullRouter.cs @@ -0,0 +1,27 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + internal class NullRouter : IRouter + { + public static IRouter Instance = new NullRouter(); + + private NullRouter() + { + } + + public VirtualPathData GetVirtualPath(VirtualPathContext context) + { + return null; + } + + public Task RouteAsync(RouteContext context) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ParameterDefaultValues.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ParameterDefaultValues.cs index 0d4a641e5f..8263da1650 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ParameterDefaultValues.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ParameterDefaultValues.cs @@ -30,17 +30,30 @@ namespace Microsoft.AspNetCore.Mvc.Internal private static object GetParameterDefaultValue(ParameterInfo parameterInfo) { - if (!ParameterDefaultValue.TryGetDefaultValue(parameterInfo, out var defaultValue)) + TryGetDeclaredParameterDefaultValue(parameterInfo, out var defaultValue); + if (defaultValue == null && parameterInfo.ParameterType.IsValueType) { - var defaultValueAttribute = parameterInfo.GetCustomAttribute(inherit: false); - defaultValue = defaultValueAttribute?.Value; - - if (defaultValue == null && parameterInfo.ParameterType.IsValueType) - { - defaultValue = Activator.CreateInstance(parameterInfo.ParameterType); - } + defaultValue = Activator.CreateInstance(parameterInfo.ParameterType); } + return defaultValue; } + + public static bool TryGetDeclaredParameterDefaultValue(ParameterInfo parameterInfo, out object defaultValue) + { + if (ParameterDefaultValue.TryGetDefaultValue(parameterInfo, out defaultValue)) + { + return true; + } + + var defaultValueAttribute = parameterInfo.GetCustomAttribute(inherit: false); + if (defaultValueAttribute != null) + { + defaultValue = defaultValueAttribute.Value; + return true; + } + + return false; + } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/RoutePatternWriter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/RoutePatternWriter.cs new file mode 100644 index 0000000000..3f0e0f6445 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/RoutePatternWriter.cs @@ -0,0 +1,79 @@ +// 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 System.Text; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + internal static class RoutePatternWriter + { + public static void WriteString(StringBuilder sb, IEnumerable routeSegments) + { + foreach (var segment in routeSegments) + { + if (sb.Length > 0) + { + sb.Append("/"); + } + + WriteString(sb, segment); + } + } + + private static void WriteString(StringBuilder sb, RoutePatternPathSegment segment) + { + for (int i = 0; i < segment.Parts.Count; i++) + { + WriteString(sb, segment.Parts[i]); + } + } + + private static void WriteString(StringBuilder sb, RoutePatternPart part) + { + if (part.IsParameter && part is RoutePatternParameterPart parameterPart) + { + sb.Append("{"); + if (parameterPart.IsCatchAll) + { + sb.Append("*"); + if (!parameterPart.EncodeSlashes) + { + sb.Append("*"); + } + } + sb.Append(parameterPart.Name); + foreach (var item in parameterPart.ParameterPolicies) + { + sb.Append(":"); + sb.Append(item.Content); + } + if (parameterPart.Default != null) + { + sb.Append("="); + sb.Append(parameterPart.Default); + } + if (parameterPart.IsOptional) + { + sb.Append("?"); + } + sb.Append("}"); + } + else if (part is RoutePatternLiteralPart literalPart) + { + sb.Append(literalPart.Content); + } + else if (part is RoutePatternSeparatorPart separatorPart) + { + sb.Append(separatorPart.Content); + } + else + { + throw new NotSupportedException(); + } + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ValidatorCache.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ValidatorCache.cs index 61fcfe0c15..61e8303d40 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ValidatorCache.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/ValidatorCache.cs @@ -122,7 +122,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal return validators; } - private struct CacheEntry + private readonly struct CacheEntry { public CacheEntry(IReadOnlyList validators) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj index 8014ae9cc2..7a21b34e45 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC core components. Contains common action result types, attribute routing, application model conventions, API explorer, application parts, filters, formatters, model binding, and more. @@ -24,24 +24,28 @@ Microsoft.AspNetCore.Mvc.RouteAttribute - + - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ArrayModelBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ArrayModelBinder.cs index 96c6a31288..c884d8f685 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ArrayModelBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ArrayModelBinder.cs @@ -38,11 +38,35 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders /// The for binding . /// /// The . + /// + /// The binder will not add an error for an unbound top-level model even if + /// is . + /// public ArrayModelBinder(IModelBinder elementBinder, ILoggerFactory loggerFactory) : base(elementBinder, loggerFactory) { } + /// + /// Creates a new . + /// + /// + /// The for binding . + /// + /// The . + /// + /// Indication that validation of top-level models is enabled. If and + /// is for a top-level model, the binder + /// adds a error when the model is not bound. + /// + public ArrayModelBinder( + IModelBinder elementBinder, + ILoggerFactory loggerFactory, + bool allowValidatingTopLevelNodes) + : base(elementBinder, loggerFactory, allowValidatingTopLevelNodes) + { + } + /// public override bool CanCreateInstance(Type targetType) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ArrayModelBinderProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ArrayModelBinderProvider.cs index 190390b9c7..c08263056e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ArrayModelBinderProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ArrayModelBinderProvider.cs @@ -4,6 +4,7 @@ using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { @@ -27,7 +28,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var binderType = typeof(ArrayModelBinder<>).MakeGenericType(elementType); var loggerFactory = context.Services.GetRequiredService(); - return (IModelBinder)Activator.CreateInstance(binderType, elementBinder, loggerFactory); + var mvcOptions = context.Services.GetRequiredService>().Value; + return (IModelBinder)Activator.CreateInstance( + binderType, + elementBinder, + loggerFactory, + mvcOptions.AllowValidatingTopLevelNodes); } return null; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/CollectionModelBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/CollectionModelBinder.cs index 16834ebadc..372a7da770 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/CollectionModelBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/CollectionModelBinder.cs @@ -44,7 +44,29 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders /// /// The for binding elements. /// The . + /// + /// The binder will not add an error for an unbound top-level model even if + /// is . + /// public CollectionModelBinder(IModelBinder elementBinder, ILoggerFactory loggerFactory) + : this(elementBinder, loggerFactory, allowValidatingTopLevelNodes: false) + { + } + + /// + /// Creates a new . + /// + /// The for binding elements. + /// The . + /// + /// Indication that validation of top-level models is enabled. If and + /// is for a top-level model, the binder + /// adds a error when the model is not bound. + /// + public CollectionModelBinder( + IModelBinder elementBinder, + ILoggerFactory loggerFactory, + bool allowValidatingTopLevelNodes) { if (elementBinder == null) { @@ -58,8 +80,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders ElementBinder = elementBinder; Logger = loggerFactory.CreateLogger(GetType()); + AllowValidatingTopLevelNodes = allowValidatingTopLevelNodes; } + // Internal for testing. + internal bool AllowValidatingTopLevelNodes { get; } + /// /// Gets the instances for binding collection elements. /// @@ -94,6 +120,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders model = CreateEmptyCollection(bindingContext.ModelType); } + if (AllowValidatingTopLevelNodes) + { + AddErrorIfBindingRequired(bindingContext); + } + bindingContext.Result = ModelBindingResult.Success(model); } @@ -161,6 +192,34 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders typeof(ICollection).IsAssignableFrom(targetType); } + /// + /// Add a to if + /// . + /// + /// The . + /// + /// + /// This method should be called only when is + /// and a top-level model was not bound. + /// + /// + /// For back-compatibility reasons, must have + /// equal to when a + /// top-level model is not bound. Therefore, ParameterBinder can not detect a + /// failure for collections. Add the error here. + /// + /// + protected void AddErrorIfBindingRequired(ModelBindingContext bindingContext) + { + var modelMetadata = bindingContext.ModelMetadata; + if (modelMetadata.IsBindingRequired) + { + var messageProvider = modelMetadata.ModelBindingMessageProvider; + var message = messageProvider.MissingBindRequiredValueAccessor(bindingContext.FieldName); + bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, message); + } + } + /// /// Create an assignable to . /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/CollectionModelBinderProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/CollectionModelBinderProvider.cs index 0617122b5f..5781289596 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/CollectionModelBinderProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/CollectionModelBinderProvider.cs @@ -7,6 +7,7 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { @@ -32,7 +33,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } var loggerFactory = context.Services.GetRequiredService(); - + var mvcOptions = context.Services.GetRequiredService>().Value; + // If the model type is ICollection<> then we can call its Add method, so we can always support it. var collectionType = ClosedGenericMatcher.ExtractGenericInterface(modelType, typeof(ICollection<>)); if (collectionType != null) @@ -41,11 +43,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var elementBinder = context.CreateBinder(context.MetadataProvider.GetMetadataForType(elementType)); var binderType = typeof(CollectionModelBinder<>).MakeGenericType(collectionType.GenericTypeArguments); - return (IModelBinder)Activator.CreateInstance(binderType, elementBinder, loggerFactory); + return (IModelBinder)Activator.CreateInstance( + binderType, + elementBinder, + loggerFactory, + mvcOptions.AllowValidatingTopLevelNodes); } // If the model type is IEnumerable<> then we need to know if we can assign a List<> to it, since - // that's what we would create. (The cases handled here are IEnumerable<>, IReadOnlyColection<> and + // that's what we would create. (The cases handled here are IEnumerable<>, IReadOnlyCollection<> and // IReadOnlyList<>). var enumerableType = ClosedGenericMatcher.ExtractGenericInterface(modelType, typeof(IEnumerable<>)); if (enumerableType != null) @@ -57,7 +63,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var elementBinder = context.CreateBinder(context.MetadataProvider.GetMetadataForType(elementType)); var binderType = typeof(CollectionModelBinder<>).MakeGenericType(enumerableType.GenericTypeArguments); - return (IModelBinder)Activator.CreateInstance(binderType, elementBinder, loggerFactory); + return (IModelBinder)Activator.CreateInstance( + binderType, + elementBinder, + loggerFactory, + mvcOptions.AllowValidatingTopLevelNodes); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ComplexTypeModelBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ComplexTypeModelBinder.cs index dd02365c96..5c1e82a80c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ComplexTypeModelBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ComplexTypeModelBinder.cs @@ -45,9 +45,33 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders /// The of binders to use for binding properties. /// /// The . + /// + /// The binder will not add an error for an unbound top-level model even if + /// is . + /// public ComplexTypeModelBinder( IDictionary propertyBinders, ILoggerFactory loggerFactory) + : this(propertyBinders, loggerFactory, allowValidatingTopLevelNodes: false) + { + } + + /// + /// Creates a new . + /// + /// + /// The of binders to use for binding properties. + /// + /// The . + /// + /// Indication that validation of top-level models is enabled. If and + /// is for a top-level model, the binder + /// adds a error when the model is not bound. + /// + public ComplexTypeModelBinder( + IDictionary propertyBinders, + ILoggerFactory loggerFactory, + bool allowValidatingTopLevelNodes) { if (propertyBinders == null) { @@ -61,8 +85,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders _propertyBinders = propertyBinders; _logger = loggerFactory.CreateLogger(); + AllowValidatingTopLevelNodes = allowValidatingTopLevelNodes; } + // Internal for testing. + internal bool AllowValidatingTopLevelNodes { get; } + /// public Task BindModelAsync(ModelBindingContext bindingContext) { @@ -91,9 +119,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders bindingContext.Model = CreateModel(bindingContext); } - for (var i = 0; i < bindingContext.ModelMetadata.Properties.Count; i++) + var modelMetadata = bindingContext.ModelMetadata; + var attemptedPropertyBinding = false; + for (var i = 0; i < modelMetadata.Properties.Count; i++) { - var property = bindingContext.ModelMetadata.Properties[i]; + var property = modelMetadata.Properties[i]; if (!CanBindProperty(bindingContext, property)) { continue; @@ -127,15 +157,32 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders if (result.IsModelSet) { + attemptedPropertyBinding = true; SetProperty(bindingContext, modelName, property, result); } else if (property.IsBindingRequired) { + attemptedPropertyBinding = true; var message = property.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(fieldName); bindingContext.ModelState.TryAddModelError(modelName, message); } } + // Have we created a top-level model despite an inability to bind anything in said model and a lack of + // other IsBindingRequired errors? Does that violate [BindRequired] on the model? This case occurs when + // 1. The top-level model has no public settable properties. + // 2. All properties in a [BindRequired] model have [BindNever] or are otherwise excluded from binding. + // 3. No data exists for any property. + if (AllowValidatingTopLevelNodes && + !attemptedPropertyBinding && + bindingContext.IsTopLevelObject && + modelMetadata.IsBindingRequired) + { + var messageProvider = modelMetadata.ModelBindingMessageProvider; + var message = messageProvider.MissingBindRequiredValueAccessor(bindingContext.FieldName); + bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, message); + } + bindingContext.Result = ModelBindingResult.Success(bindingContext.Model); _logger.DoneAttemptingToBindModel(bindingContext); } @@ -216,8 +263,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders return true; } - // 2. Any of the model properties can be bound using a value provider. - if (CanValueBindAnyModelProperties(bindingContext)) + // 2. Any of the model properties can be bound. + if (CanBindAnyModelProperties(bindingContext)) { return true; } @@ -225,7 +272,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders return false; } - private bool CanValueBindAnyModelProperties(ModelBindingContext bindingContext) + private bool CanBindAnyModelProperties(ModelBindingContext bindingContext) { // If there are no properties on the model, there is nothing to bind. We are here means this is not a top // level object. So we return false. @@ -235,20 +282,19 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders return false; } - // We want to check to see if any of the properties of the model can be bound using the value providers, - // because that's all that MutableObjectModelBinder can handle. + // We want to check to see if any of the properties of the model can be bound using the value providers or + // a greedy binder. // - // However, because a property might specify a custom binding source ([FromForm]), it's not correct - // for us to just try bindingContext.ValueProvider.ContainsPrefixAsync(bindingContext.ModelName), - // because that may include other value providers - that would lead us to mistakenly create the model + // Because a property might specify a custom binding source ([FromForm]), it's not correct + // for us to just try bindingContext.ValueProvider.ContainsPrefixAsync(bindingContext.ModelName); + // that may include other value providers - that would lead us to mistakenly create the model // when the data is coming from a source we should use (ex: value found in query string, but the // model has [FromForm]). // // To do this we need to enumerate the properties, and see which of them provide a binding source // through metadata, then we decide what to do. // - // If a property has a binding source, and it's a greedy source, then it's not - // allowed to come from a value provider, so we skip it. + // If a property has a binding source, and it's a greedy source, then it's always bound. // // If a property has a binding source, and it's a non-greedy source, then we'll filter the // the value providers to just that source, and see if we can find a matching prefix @@ -256,12 +302,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // // If a property does not have a binding source, then it's fair game for any value provider. // - // If any property meets the above conditions and has a value from valueproviders, then we'll - // create the model and try to bind it. OR if ALL properties of the model have a greedy source, + // Bottom line, if any property meets the above conditions and has a value from ValueProviders, then we'll + // create the model and try to bind it. Of, if ANY properties of the model have a greedy source, // then we go ahead and create it. // - var hasBindableProperty = false; - var isAnyPropertyEnabledForValueProviderBasedBinding = false; for (var i = 0; i < bindingContext.ModelMetadata.Properties.Count; i++) { var propertyMetadata = bindingContext.ModelMetadata.Properties[i]; @@ -270,41 +314,30 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders continue; } - hasBindableProperty = true; - - // This check will skip properties which are marked explicitly using a non value binder. + // If any property can be bound from a greedy binding source, then success. var bindingSource = propertyMetadata.BindingSource; - if (bindingSource == null || !bindingSource.IsGreedy) + if (bindingSource != null && bindingSource.IsGreedy) { - isAnyPropertyEnabledForValueProviderBasedBinding = true; + return true; + } - var fieldName = propertyMetadata.BinderModelName ?? propertyMetadata.PropertyName; - var modelName = ModelNames.CreatePropertyModelName( - bindingContext.ModelName, - fieldName); - - using (bindingContext.EnterNestedScope( - modelMetadata: propertyMetadata, - fieldName: fieldName, - modelName: modelName, - model: null)) + // Otherwise, check whether the (perhaps filtered) value providers have a match. + var fieldName = propertyMetadata.BinderModelName ?? propertyMetadata.PropertyName; + var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName); + using (bindingContext.EnterNestedScope( + modelMetadata: propertyMetadata, + fieldName: fieldName, + modelName: modelName, + model: null)) + { + // If any property can be bound from a value provider, then success. + if (bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName)) { - // If any property can be bound from a value provider then continue. - if (bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName)) - { - return true; - } + return true; } } } - if (hasBindableProperty && !isAnyPropertyEnabledForValueProviderBasedBinding) - { - // All the properties are marked with a non value provider based marker like [FromHeader] or - // [FromBody]. - return true; - } - _logger.CannotBindToComplexType(bindingContext); return false; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs index f0d6b5989c..ac8d2f13d3 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { @@ -31,7 +32,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } var loggerFactory = context.Services.GetRequiredService(); - return new ComplexTypeModelBinder(propertyBinders, loggerFactory); + var mvcOptions = context.Services.GetRequiredService>().Value; + return new ComplexTypeModelBinder( + propertyBinders, + loggerFactory, + mvcOptions.AllowValidatingTopLevelNodes); } return null; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinder.cs index fbb0c53db1..0954f87e6c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinder.cs @@ -43,6 +43,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders /// The for . /// The for . /// The . + /// + /// The binder will not add an error for an unbound top-level model even if + /// is . + /// public DictionaryModelBinder(IModelBinder keyBinder, IModelBinder valueBinder, ILoggerFactory loggerFactory) : base(new KeyValuePairModelBinder(keyBinder, valueBinder, loggerFactory), loggerFactory) { @@ -54,6 +58,40 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders _valueBinder = valueBinder; } + /// + /// Creates a new . + /// + /// The for . + /// The for . + /// The . + /// + /// Indication that validation of top-level models is enabled. If and + /// is for a top-level model, the binder + /// adds a error when the model is not bound. + /// + public DictionaryModelBinder( + IModelBinder keyBinder, + IModelBinder valueBinder, + ILoggerFactory loggerFactory, + bool allowValidatingTopLevelNodes) + : base( + new KeyValuePairModelBinder(keyBinder, valueBinder, loggerFactory), + loggerFactory, + // CollectionModelBinder should not check IsRequired, done in this model binder. + allowValidatingTopLevelNodes: false) + { + if (valueBinder == null) + { + throw new ArgumentNullException(nameof(valueBinder)); + } + + _valueBinder = valueBinder; + AllowValidatingTopLevelNodes = allowValidatingTopLevelNodes; + } + + // Internal for testing. + internal new bool AllowValidatingTopLevelNodes { get; } + /// public override async Task BindModelAsync(ModelBindingContext bindingContext) { @@ -85,6 +123,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { // No IEnumerableValueProvider available for the fallback approach. For example the user may have // replaced the ValueProvider with something other than a CompositeValueProvider. + if (AllowValidatingTopLevelNodes && bindingContext.IsTopLevelObject) + { + AddErrorIfBindingRequired(bindingContext); + } + return; } @@ -94,6 +137,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders if (keys.Count == 0) { // No entries with the expected keys. + if (AllowValidatingTopLevelNodes && bindingContext.IsTopLevelObject) + { + AddErrorIfBindingRequired(bindingContext); + } + return; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinderProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinderProvider.cs index 0d7db7c064..4ef0c83725 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinderProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinderProvider.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { @@ -34,7 +35,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var binderType = typeof(DictionaryModelBinder<,>).MakeGenericType(dictionaryType.GenericTypeArguments); var loggerFactory = context.Services.GetRequiredService(); - return (IModelBinder)Activator.CreateInstance(binderType, keyBinder, valueBinder, loggerFactory); + var mvcOptions = context.Services.GetRequiredService>().Value; + return (IModelBinder)Activator.CreateInstance( + binderType, + keyBinder, + valueBinder, + loggerFactory, + mvcOptions.AllowValidatingTopLevelNodes); } return null; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/EnumTypeModelBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/EnumTypeModelBinder.cs index 601d02ba44..f6c99138fd 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/EnumTypeModelBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/EnumTypeModelBinder.cs @@ -82,11 +82,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // enum FlagsEnum { Value1 = 1, Value2 = 2, Value4 = 4 } // // Valid Scenarios: - // 1. valueproviderresult="Value2,Value4", model=Value2 | Value4, underlying=6, converted=Value2, Value4 - // 2. valueproviderresult="2,4", model=Value2 | Value4, underlying=6, converted=Value2, Value4 + // 1. valueProviderResult="Value2,Value4", model=Value2 | Value4, underlying=6, converted=Value2, Value4 + // 2. valueProviderResult="2,4", model=Value2 | Value4, underlying=6, converted=Value2, Value4 // // Invalid Scenarios: - // 1. valueproviderresult="2,10", model=12, underlying=12, converted=12 + // 1. valueProviderResult="2,10", model=12, underlying=12, converted=12 // var underlying = Convert.ChangeType( model, diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinder.cs index 5a7f1157ac..76b4bf54ca 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FormFileModelBinder.cs @@ -48,7 +48,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders _logger = loggerFactory.CreateLogger(); } - + /// public async Task BindModelAsync(ModelBindingContext bindingContext) { @@ -85,6 +85,19 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders await GetFormFilesAsync(modelName, bindingContext, postedFiles); + // If ParameterBinder incorrectly overrode ModelName, fall back to OriginalModelName prefix. Comparisons + // are tedious because e.g. top-level parameter or property is named Blah and it contains a BlahBlah + // property. OriginalModelName may be null in tests. + if (postedFiles.Count == 0 && + bindingContext.OriginalModelName != null && + !string.Equals(modelName, bindingContext.OriginalModelName, StringComparison.Ordinal) && + !modelName.StartsWith(bindingContext.OriginalModelName + "[", StringComparison.Ordinal) && + !modelName.StartsWith(bindingContext.OriginalModelName + ".", StringComparison.Ordinal)) + { + modelName = ModelNames.CreatePropertyModelName(bindingContext.OriginalModelName, modelName); + await GetFormFilesAsync(modelName, bindingContext, postedFiles); + } + object value; if (bindingContext.ModelType == typeof(IFormFile)) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/HeaderModelBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/HeaderModelBinder.cs index 48c3ae2304..e9de376246 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/HeaderModelBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/HeaderModelBinder.cs @@ -81,7 +81,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var headerName = bindingContext.FieldName; // Do not set ModelBindingResult to Failed on not finding the value in the header as we want the inner - // modelbinder to do that. This would give a chance to the inner binder to add more useful information. + // ModelBinder to do that. This would give a chance to the inner binder to add more useful information. // For example, SimpleTypeModelBinder adds a model error when binding to let's say an integer and the // model is null. var request = bindingContext.HttpContext.Request; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/FormValueProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/FormValueProvider.cs index 0e347e99d7..da13af979e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/FormValueProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/FormValueProvider.cs @@ -46,7 +46,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding public CultureInfo Culture => _culture; +#pragma warning disable PUB0001 // Pubternal type in public API protected PrefixContainer PrefixContainer +#pragma warning restore PUB0001 { get { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryKeyValuePairNormalizer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryKeyValuePairNormalizer.cs index b0c1c28c0f..0fdd6c2634 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryKeyValuePairNormalizer.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryKeyValuePairNormalizer.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.ModelBinding { - // Normalizes keys, in a keyvaluepair collection, from jQuery format to a format that MVC understands. + // Normalizes keys, in a KeyValuePair collection, from jQuery format to a format that MVC understands. internal static class JQueryKeyValuePairNormalizer { public static IDictionary GetValues( diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryValueProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryValueProvider.cs index 659a943934..6ae1c84377 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryValueProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryValueProvider.cs @@ -52,7 +52,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding public CultureInfo Culture { get; } /// +#pragma warning disable PUB0001 // Pubternal type in public API protected PrefixContainer PrefixContainer +#pragma warning restore PUB0001 { get { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs index bfb1bde8b5..277da94ddd 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadataProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadataProvider.cs index e15c0c3532..e7a2d454d8 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadataProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadataProvider.cs @@ -14,27 +14,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// /// A default implementation of based on reflection. /// - public class DefaultModelMetadataProvider : ModelMetadataProvider, IModelMetadataProvider2 + public class DefaultModelMetadataProvider : ModelMetadataProvider { - private static readonly Func _modelMetadataIdentityForParameter; - private readonly TypeCache _typeCache = new TypeCache(); private readonly Func _cacheEntryFactory; private readonly ModelMetadataCacheEntry _metadataCacheEntryForObjectType; - static DefaultModelMetadataProvider() - { - var forParameterMethod = typeof(ModelMetadataIdentity).GetMethod( - nameof(ModelMetadataIdentity.ForParameter), - BindingFlags.Static | BindingFlags.NonPublic, - binder: null, - types: new[] { typeof(ParameterInfo), typeof(Type) }, - modifiers: null); - - _modelMetadataIdentityForParameter = (Func) - forParameterMethod.CreateDelegate(typeof(Func)); - } - /// /// Creates a new . /// @@ -94,7 +79,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata var cacheEntry = GetCacheEntry(modelType); // We're relying on a safe race-condition for Properties - take care only - // to set the value onces the properties are fully-initialized. + // to set the value once the properties are fully-initialized. if (cacheEntry.Details.Properties == null) { var key = ModelMetadataIdentity.ForType(modelType); @@ -118,10 +103,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata => GetMetadataForParameter(parameter, parameter?.ParameterType); /// - ModelMetadata IModelMetadataProvider2.GetMetadataForParameter(ParameterInfo parameter, Type modelType) - => GetMetadataForParameter(parameter, modelType); - - internal ModelMetadata GetMetadataForParameter(ParameterInfo parameter, Type modelType) + public override ModelMetadata GetMetadataForParameter(ParameterInfo parameter, Type modelType) { if (parameter == null) { @@ -151,12 +133,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata return cacheEntry.Metadata; } - /// - ModelMetadata IModelMetadataProvider2.GetMetadataForProperty(PropertyInfo parameter, Type modelType) - => GetMetadataForProperty(parameter, modelType); - - internal ModelMetadata GetMetadataForProperty(PropertyInfo propertyInfo, Type modelType) + public override ModelMetadata GetMetadataForProperty(PropertyInfo propertyInfo, Type modelType) { if (propertyInfo == null) { @@ -205,7 +183,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata private ModelMetadataCacheEntry GetCacheEntry(ParameterInfo parameter, Type modelType) { return _typeCache.GetOrAdd( - _modelMetadataIdentityForParameter(parameter, modelType), + ModelMetadataIdentity.ForParameter(parameter, modelType), _cacheEntryFactory); } @@ -367,7 +345,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata { } - private struct ModelMetadataCacheEntry + private readonly struct ModelMetadataCacheEntry { public ModelMetadataCacheEntry(ModelMetadata metadata, DefaultMetadataDetails details) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/HasValidatorsValidationMetadataProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/HasValidatorsValidationMetadataProvider.cs new file mode 100644 index 0000000000..6378ba518f --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/IModelMetadataProvider2.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/IModelMetadataProvider2.cs deleted file mode 100644 index b9d8d809f4..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/IModelMetadataProvider2.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Reflection; - -namespace Microsoft.AspNetCore.Mvc.ModelBinding -{ - internal interface IModelMetadataProvider2 - { - /// - /// Supplies metadata describing a parameter. - /// - /// The - /// The actual model type. - /// A instance describing the . - ModelMetadata GetMetadataForParameter(ParameterInfo parameter, Type modelType); - - /// - /// Supplies metadata describing a property. - /// - /// The . - /// The actual model type. - /// A instance describing the . - ModelMetadata GetMetadataForProperty(PropertyInfo propertyInfo, Type modelType); - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelAttributes.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelAttributes.cs index c5d48ed8a9..0f9a5bf103 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelAttributes.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelAttributes.cs @@ -149,7 +149,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// /// A instance with the attributes of the property and its . /// - internal static ModelAttributes GetAttributesForProperty(Type containerType, PropertyInfo property, Type modelType) + public static ModelAttributes GetAttributesForProperty(Type containerType, PropertyInfo property, Type modelType) { if (containerType == null) { @@ -231,7 +231,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// /// A instance with the attributes of the parameter and its . /// - internal static ModelAttributes GetAttributesForParameter(ParameterInfo parameterInfo, Type modelType) + public static ModelAttributes GetAttributesForParameter(ParameterInfo parameterInfo, Type modelType) { if (parameterInfo == null) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ValidationMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ValidationMetadata.cs index bd4ff327aa..d205fdf172 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ValidationMetadata.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ModelBinderFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ModelBinderFactory.cs index 216fc7c803..4b7a53b498 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ModelBinderFactory.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ModelBinderFactory.cs @@ -275,15 +275,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding public override IModelBinder CreateBinder(ModelMetadata metadata) { - return CreateBinder( - metadata, - new BindingInfo() - { - BinderModelName = metadata.BinderModelName, - BinderType = metadata.BinderType, - BindingSource = metadata.BindingSource, - PropertyFilterProvider = metadata.PropertyFilterProvider, - }); + var bindingInfo = new BindingInfo(); + bindingInfo.TryApplyBindingInfo(metadata); + + return CreateBinder(metadata, bindingInfo); } public override IModelBinder CreateBinder(ModelMetadata metadata, BindingInfo bindingInfo) @@ -315,7 +310,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding // the ParameterDescriptor) or in a call to TryUpdateModel (no BindingInfo) or as a collection element. // // We need to be able to tell the difference between these things to avoid over-caching. - private struct Key : IEquatable + private readonly struct Key : IEquatable { private readonly ModelMetadata _metadata; private readonly object _token; // Explicitly using ReferenceEquality for tokens. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ObjectModelValidator.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ObjectModelValidator.cs index 0b89489a5f..6c34f35c2f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ObjectModelValidator.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ObjectModelValidator.cs @@ -101,7 +101,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding public abstract ValidationVisitor GetValidationVisitor( ActionContext actionContext, IModelValidatorProvider validatorProvider, +#pragma warning disable PUB0001 // Pubternal type in public API ValidatorCache validatorCache, +#pragma warning restore PUB0001 IModelMetadataProvider metadataProvider, ValidationStateDictionary validationState); } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ParameterBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ParameterBinder.cs index 7a04fe8be3..0cf3693eb1 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ParameterBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/ParameterBinder.cs @@ -101,28 +101,46 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding protected ILogger Logger { get; } /// - /// Initializes and binds a model specified by . + /// + /// This method overload is obsolete and will be removed in a future version. The recommended alternative is + /// . + /// + /// Initializes and binds a model specified by . /// /// The . /// The . /// The /// The result of model binding. + [Obsolete("This method overload is obsolete and will be removed in a future version. The recommended " + + "alternative is the overload that also takes " + nameof(IModelBinder) + ", " + nameof(ModelMetadata) + + " and " + nameof(Object) + " parameters.")] public Task BindModelAsync( ActionContext actionContext, IValueProvider valueProvider, ParameterDescriptor parameter) { +#pragma warning disable CS0618 // Type or member is obsolete return BindModelAsync(actionContext, valueProvider, parameter, value: null); +#pragma warning restore CS0618 // Type or member is obsolete } /// + /// + /// This method overload is obsolete and will be removed in a future version. The recommended alternative is + /// . + /// + /// /// Binds a model specified by using as the initial value. + /// /// /// The . /// The . /// The /// The initial model value. /// The result of model binding. + [Obsolete("This method overload is obsolete and will be removed in a future version. The recommended " + + "alternative is the overload that also takes " + nameof(IModelBinder) + " and " + nameof(ModelMetadata) + + " parameters.")] public virtual Task BindModelAsync( ActionContext actionContext, IValueProvider valueProvider, @@ -204,8 +222,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding throw new ArgumentNullException(nameof(metadata)); } + Logger.AttemptingToBindParameterOrProperty(parameter, metadata); + if (parameter.BindingInfo?.RequestPredicate?.Invoke(actionContext) == false) { + Logger.ParameterBinderRequestPredicateShortCircuit(parameter, metadata); return ModelBindingResult.Failed(); } @@ -217,8 +238,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding parameter.Name); modelBindingContext.Model = value; - Logger.AttemptingToBindParameterOrProperty(parameter, modelBindingContext); - var parameterModelName = parameter.BindingInfo?.BinderModelName ?? metadata.BinderModelName; if (parameterModelName != null) { @@ -238,14 +257,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding await modelBinder.BindModelAsync(modelBindingContext); - Logger.DoneAttemptingToBindParameterOrProperty(parameter, modelBindingContext); + Logger.DoneAttemptingToBindParameterOrProperty(parameter, metadata); var modelBindingResult = modelBindingContext.Result; if (_mvcOptions.AllowValidatingTopLevelNodes && _objectModelValidator is ObjectModelValidator baseObjectValidator) { - Logger.AttemptingToValidateParameterOrProperty(parameter, modelBindingContext); + Logger.AttemptingToValidateParameterOrProperty(parameter, metadata); EnforceBindRequiredAndValidate( baseObjectValidator, @@ -255,7 +274,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding modelBindingContext, modelBindingResult); - Logger.DoneAttemptingToValidateParameterOrProperty(parameter, modelBindingContext); + Logger.DoneAttemptingToValidateParameterOrProperty(parameter, metadata); } else { @@ -322,7 +341,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding // and we ended up with an empty prefix. modelName = modelBindingContext.FieldName; } - + // Run validation, we expect this to validate [Required]. baseObjectValidator.Validate( actionContext, @@ -344,7 +363,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding if (!modelBindingResult.IsModelSet || modelBindingResult.Model == null || - !(_modelMetadataProvider is IModelMetadataProvider2 modelMetadataProvider)) + !(_modelMetadataProvider is ModelMetadataProvider modelMetadataProvider)) { return; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/QueryStringValueProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/QueryStringValueProvider.cs index a905d72752..e7d62e3852 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/QueryStringValueProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/QueryStringValueProvider.cs @@ -46,7 +46,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding public CultureInfo Culture => _culture; +#pragma warning disable PUB0001 // Pubternal type in public API protected PrefixContainer PrefixContainer +#pragma warning restore PUB0001 { get { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/RouteValueProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/RouteValueProvider.cs index 72f556c1c0..615b4bd437 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/RouteValueProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/RouteValueProvider.cs @@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding } /// - /// Creates a new . + /// Creates a new . /// /// The of the data. /// The values. @@ -57,7 +57,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding Culture = culture; } +#pragma warning disable PUB0001 // Pubternal type in public API protected PrefixContainer PrefixContainer +#pragma warning restore PUB0001 { get { @@ -86,10 +88,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding throw new ArgumentNullException(nameof(key)); } - object value; - if (_values.TryGetValue(key, out value)) + if (_values.TryGetValue(key, out var value)) { - var stringValue = value as string ?? value?.ToString() ?? string.Empty; + var stringValue = value as string ?? Convert.ToString(value, Culture) ?? string.Empty; return new ValueProviderResult(stringValue, Culture); } else diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/UnsupportedContentTypeFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/UnsupportedContentTypeFilter.cs index 0c134d7dc9..fab0cbe68f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/UnsupportedContentTypeFilter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/UnsupportedContentTypeFilter.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc.ModelBinding { @@ -10,8 +11,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// and short-circuits the pipeline /// with an Unsupported Media Type (415) response. /// - public class UnsupportedContentTypeFilter : IActionFilter + public class UnsupportedContentTypeFilter : IActionFilter, IOrderedFilter { + /// + /// Gets or sets the filter order. . + /// + /// Defaults to -3000 to ensure it executes before . + /// + /// + public int Order { get; set; } = -3000; + /// public void OnActionExecuting(ActionExecutingContext context) { @@ -32,7 +41,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding foreach (var kvp in modelState) { var errors = kvp.Value.Errors; - for (int i = 0; i < errors.Count; i++) + for (var i = 0; i < errors.Count; i++) { var error = errors[i]; if (error.Exception is UnsupportedContentTypeException) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelValidatorProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/DefaultModelValidatorProvider.cs similarity index 64% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelValidatorProvider.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/DefaultModelValidatorProvider.cs index 11d5739c02..2174be1417 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Internal/DefaultModelValidatorProvider.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/IMetadataBasedModelValidatorProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/IMetadataBasedModelValidatorProvider.cs new file mode 100644 index 0000000000..4fb9bd0c6f --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/ValidationVisitor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/ValidationVisitor.cs index 499959df27..0b277c68c6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/ValidationVisitor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Validation/ValidationVisitor.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation { @@ -15,6 +17,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation /// public class ValidationVisitor { + private int? _maxValidationDepth; + /// /// Creates a new . /// @@ -26,7 +30,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation public ValidationVisitor( ActionContext actionContext, IModelValidatorProvider validatorProvider, +#pragma warning disable PUB0001 // Pubternal type in public API ValidatorCache validatorCache, +#pragma warning restore PUB0001 IModelMetadataProvider metadataProvider, ValidationStateDictionary validationState) { @@ -58,11 +64,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation protected IModelValidatorProvider ValidatorProvider { get; } protected IModelMetadataProvider MetadataProvider { get; } +#pragma warning disable PUB0001 // Pubternal type in public API protected ValidatorCache Cache { get; } +#pragma warning restore PUB0001 protected ActionContext Context { get; } protected ModelStateDictionary ModelState { get; } protected ValidationStateDictionary ValidationState { get; } +#pragma warning disable PUB0001 // Pubternal type in public API protected ValidationStack CurrentPath { get; } +#pragma warning restore PUB0001 protected object Container { get; set; } protected string Key { get; set; } @@ -70,10 +80,42 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation protected ModelMetadata Metadata { get; set; } protected IValidationStrategy Strategy { get; set; } + /// + /// Gets or sets the maximum depth to constrain the validation visitor when validating. + /// + /// traverses the object graph of the model being validated. For models + /// that are very deep or are infinitely recursive, validation may result in stack overflow. + /// + /// + /// When not , will throw if + /// current traversal depth exceeds the specified value. + /// + /// + public int? MaxValidationDepth + { + get => _maxValidationDepth; + set + { + if (value != null && value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _maxValidationDepth = value; + } + } + /// /// Indicates whether validation of a complex type should be performed if validation fails for any of its children. The default behavior is false. /// public bool ValidateComplexTypesIfChildValidationFails { get; set; } + + /// + /// Gets or sets a value that determines if can short circuit validation when a model + /// does not have any associated validators. + /// + public bool AllowShortCircuitingValidationWhenNoValidatorsArePresent { get; set; } + /// /// Validates a object. /// @@ -99,7 +141,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation if (model == null && key != null && !alwaysValidateAtTopLevel) { var entry = ModelState[key]; - if (entry != null && entry.ValidationState != ModelValidationState.Valid) + + // Rationale: We might see the same model state key for two different objects and want to preserve any + // known invalidity. + if (entry != null && entry.ValidationState != ModelValidationState.Invalid) { entry.ValidationState = ModelValidationState.Valid; } @@ -182,6 +227,33 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation return true; } + if (MaxValidationDepth != null && CurrentPath.Count > MaxValidationDepth) + { + // Non cyclic but too deep an object graph. + + // Pop the current model to make ValidationStack.Dispose happy + CurrentPath.Pop(model); + + string message; + switch (metadata.MetadataKind) + { + case ModelMetadataKind.Property: + message = Resources.FormatValidationVisitor_ExceededMaxPropertyDepth(nameof(ValidationVisitor), MaxValidationDepth, metadata.Name, metadata.ContainerType); + break; + + default: + // Since the minimum depth is never 0, MetadataKind can never be Parameter. Consequently we only special case MetadataKind.Property. + message = Resources.FormatValidationVisitor_ExceededMaxDepth(nameof(ValidationVisitor), MaxValidationDepth, metadata.ModelType); + break; + } + + message += " " + Resources.FormatValidationVisitor_ExceededMaxDepthFix(nameof(MvcOptions), nameof(MvcOptions.MaxValidationDepth)); + throw new InvalidOperationException(message) + { + HelpLink = "https://aka.ms/AA21ue1", + }; + } + var entry = GetValidationEntry(model); key = entry?.Key ?? key ?? string.Empty; metadata = entry?.Metadata ?? metadata; @@ -199,6 +271,26 @@ 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 ( + AllowShortCircuitingValidationWhenNoValidatorsArePresent && + 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)) { @@ -290,7 +382,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation var entries = ModelState.FindKeysWithPrefix(key); foreach (var entry in entries) { - entry.Value.ValidationState = ModelValidationState.Skipped; + if (entry.Value.ValidationState != ModelValidationState.Invalid) + { + entry.Value.ValidationState = ModelValidationState.Skipped; + } } } @@ -305,7 +400,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation return entry; } - protected struct StateManager : IDisposable + protected readonly struct StateManager : IDisposable { private readonly ValidationVisitor _visitor; private readonly object _container; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs index 72634a49fa..a97c9a965e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs @@ -30,6 +30,9 @@ namespace Microsoft.AspNetCore.Mvc private readonly CompatibilitySwitch _allowValidatingTopLevelNodes; private readonly CompatibilitySwitch _inputFormatterExceptionPolicy; private readonly CompatibilitySwitch _suppressBindingUndefinedValueToEnumType; + private readonly CompatibilitySwitch _enableEndpointRouting; + private readonly NullableCompatibilitySwitch _maxValidationDepth; + private readonly CompatibilitySwitch _allowShortCircuitingValidationWhenNoValidatorsArePresent; private readonly ICompatibilitySwitch[] _switches; /// @@ -54,6 +57,9 @@ namespace Microsoft.AspNetCore.Mvc _allowValidatingTopLevelNodes = new CompatibilitySwitch(nameof(AllowValidatingTopLevelNodes)); _inputFormatterExceptionPolicy = new CompatibilitySwitch(nameof(InputFormatterExceptionPolicy), InputFormatterExceptionPolicy.AllExceptions); _suppressBindingUndefinedValueToEnumType = new CompatibilitySwitch(nameof(SuppressBindingUndefinedValueToEnumType)); + _enableEndpointRouting = new CompatibilitySwitch(nameof(EnableEndpointRouting)); + _maxValidationDepth = new NullableCompatibilitySwitch(nameof(MaxValidationDepth)); + _allowShortCircuitingValidationWhenNoValidatorsArePresent = new CompatibilitySwitch(nameof(AllowShortCircuitingValidationWhenNoValidatorsArePresent)); _switches = new ICompatibilitySwitch[] { @@ -62,9 +68,46 @@ namespace Microsoft.AspNetCore.Mvc _allowValidatingTopLevelNodes, _inputFormatterExceptionPolicy, _suppressBindingUndefinedValueToEnumType, + _enableEndpointRouting, + _maxValidationDepth, + _allowShortCircuitingValidationWhenNoValidatorsArePresent, }; } + /// + /// Gets or sets a value that determines if routing should use endpoints internally, or if legacy routing + /// logic should be used. Endpoint routing is used to match HTTP requests to MVC actions, and to generate + /// URLs with . + /// + /// + /// 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 EnableEndpointRouting + { + get => _enableEndpointRouting.Value; + set => _enableEndpointRouting.Value = value; + } + /// /// Gets or sets the flag which decides whether body model binding (for example, on an /// action method parameter with ) should treat empty @@ -359,6 +402,87 @@ namespace Microsoft.AspNetCore.Mvc /// public bool RequireHttpsPermanent { get; set; } + /// + /// Gets or sets the maximum depth to constrain the validation visitor when validating. Set to + /// to disable this feature. + /// + /// traverses the object graph of the model being validated. For models + /// that are very deep or are infinitely recursive, validation may result in stack overflow. + /// + /// + /// When not , will throw if + /// traversing an object exceeds the maximum allowed validation depth. + /// + /// + /// 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 then + /// this setting will have the value 200 unless explicitly configured. + /// + /// + /// If the application's compatibility version is set to or + /// earlier then this setting will have the value unless explicitly configured. + /// + /// + public int? MaxValidationDepth + { + get => _maxValidationDepth.Value; + set + { + if (value != null && value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _maxValidationDepth.Value = value; + } + } + + /// + /// Gets or sets a value that determines if + /// can short-circuit validation when a model does not have any associated validators. + /// + /// + /// The default value is if the version is + /// or later; otherwise. + /// + /// + /// When is , that is, it is determined + /// that a model or any of it's properties or collection elements cannot have any validators, + /// can short-circuit validation for the model and mark the object + /// graph as valid. Setting this property to , allows to + /// perform this optimization. + /// + /// 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 then + /// this setting will have the value unless explicitly configured. + /// + /// + /// If the application's compatibility version is set to or + /// earlier then this setting will have the value unless explicitly configured. + /// + /// + public bool AllowShortCircuitingValidationWhenNoValidatorsArePresent + { + get => _allowShortCircuitingValidationWhenNoValidatorsArePresent.Value; + set => _allowShortCircuitingValidationWhenNoValidatorsArePresent.Value = value; + } + IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable)_switches).GetEnumerator(); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NoContentResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NoContentResult.cs index 09a700398e..f4a23075f9 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NoContentResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NoContentResult.cs @@ -2,13 +2,23 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { + /// + /// A that when executed will produce a 204 No Content response. + /// + [DefaultStatusCode(DefaultStatusCode)] public class NoContentResult : StatusCodeResult { + private const int DefaultStatusCode = StatusCodes.Status204NoContent; + + /// + /// Initializes a new instance. + /// public NoContentResult() - : base(StatusCodes.Status204NoContent) + : base(DefaultStatusCode) { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NotFoundObjectResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NotFoundObjectResult.cs index 9260784017..11bb6d8732 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NotFoundObjectResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NotFoundObjectResult.cs @@ -2,22 +2,26 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { /// /// An that when executed will produce a Not Found (404) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class NotFoundObjectResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status404NotFound; + /// /// Creates a new instance. /// /// The value to format in the entity body. - public NotFoundObjectResult(object value) + public NotFoundObjectResult([ActionResultObjectValue] object value) : base(value) { - StatusCode = StatusCodes.Status404NotFound; + StatusCode = DefaultStatusCode; } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NotFoundResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NotFoundResult.cs index b4b7de1600..ed59417621 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NotFoundResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/NotFoundResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { @@ -9,12 +10,15 @@ namespace Microsoft.AspNetCore.Mvc /// Represents an that when /// executed will produce a Not Found (404) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class NotFoundResult : StatusCodeResult { + private const int DefaultStatusCode = StatusCodes.Status404NotFound; + /// /// Creates a new instance. /// - public NotFoundResult() : base(StatusCodes.Status404NotFound) + public NotFoundResult() : base(DefaultStatusCode) { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ObjectResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ObjectResult.cs index 1148ceffc1..a5a0323089 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ObjectResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ObjectResult.cs @@ -5,25 +5,31 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc { - public class ObjectResult : ActionResult + public class ObjectResult : ActionResult, IStatusCodeActionResult { + private MediaTypeCollection _contentTypes; + public ObjectResult(object value) { Value = value; Formatters = new FormatterCollection(); - ContentTypes = new MediaTypeCollection(); + _contentTypes = new MediaTypeCollection(); } + [ActionResultObjectValue] public object Value { get; set; } public FormatterCollection Formatters { get; set; } - public MediaTypeCollection ContentTypes { get; set; } + public MediaTypeCollection ContentTypes + { + get => _contentTypes; + set => _contentTypes = value ?? throw new ArgumentNullException(nameof(value)); + } public Type DeclaredType { get; set; } @@ -51,6 +57,11 @@ namespace Microsoft.AspNetCore.Mvc if (StatusCode.HasValue) { context.HttpContext.Response.StatusCode = StatusCode.Value; + + if (Value is ProblemDetails details && !details.Status.HasValue) + { + details.Status = StatusCode.Value; + } } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/OkObjectResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/OkObjectResult.cs index 2990ffc843..3f4162b426 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/OkObjectResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/OkObjectResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { @@ -9,8 +10,11 @@ namespace Microsoft.AspNetCore.Mvc /// An that when executed performs content negotiation, formats the entity body, and /// will produce a response if negotiation and formatting succeed. /// + [DefaultStatusCode(DefaultStatusCode)] public class OkObjectResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status200OK; + /// /// Initializes a new instance of the class. /// @@ -18,7 +22,7 @@ namespace Microsoft.AspNetCore.Mvc public OkObjectResult(object value) : base(value) { - StatusCode = StatusCodes.Status200OK; + StatusCode = DefaultStatusCode; } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/OkResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/OkResult.cs index 49cd649c99..cb6476b72d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/OkResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/OkResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { @@ -9,13 +10,16 @@ namespace Microsoft.AspNetCore.Mvc /// An that when executed will produce an empty /// response. /// + [DefaultStatusCode(DefaultStatusCode)] public class OkResult : StatusCodeResult { + private const int DefaultStatusCode = StatusCodes.Status200OK; + /// /// Initializes a new instance of the class. /// public OkResult() - : base(StatusCodes.Status200OK) + : base(DefaultStatusCode) { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs index 35ac215562..9d07004103 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using Newtonsoft.Json; namespace Microsoft.AspNetCore.Mvc { @@ -17,6 +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, PropertyName = "type")] public string Type { get; set; } /// @@ -24,21 +26,39 @@ 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, 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, PropertyName = "status")] public int? Status { get; set; } /// /// A human-readable explanation specific to this occurrence of the problem. /// + [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, PropertyName = "instance")] public string Instance { get; set; } + + /// + /// Gets the for extension members. + /// + /// Problem type definitions MAY extend the problem details object with additional members. Extension members appear in the same namespace as + /// other members of a problem type. + /// + /// + /// + /// The round-tripping behavior for is determined by the implementation of the Input \ Output formatters. + /// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters. + /// + [JsonExtensionData] + public IDictionary Extensions { get; } = new Dictionary(StringComparer.Ordinal); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs index 202591a1b6..e9346f7330 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs @@ -82,9 +82,7 @@ namespace Microsoft.AspNetCore.Mvc throw new ArgumentNullException(nameof(context)); } - var objectResult = context.Result as ObjectResult; - - if (objectResult != null) + if (context.Result is ObjectResult objectResult) { // Check if there are any IFormatFilter in the pipeline, and if any of them is active. If there is one, // do not override the content type value. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesDefaultResponseTypeAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesDefaultResponseTypeAttribute.cs new file mode 100644 index 0000000000..3022327a5b --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesDefaultResponseTypeAttribute.cs @@ -0,0 +1,49 @@ +// 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.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// A filter that specifies the for all HTTP status codes that are not covered by . + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class ProducesDefaultResponseTypeAttribute : Attribute, IApiDefaultResponseMetadataProvider + { + /// + /// Initializes an instance of . + /// + public ProducesDefaultResponseTypeAttribute() + : this(typeof(void)) + { + } + + /// + /// Initializes an instance of . + /// + /// The of object that is going to be written in the response. + public ProducesDefaultResponseTypeAttribute(Type type) + { + Type = type ?? throw new ArgumentNullException(nameof(type)); + } + + /// + /// Gets or sets the type of the value returned by an action. + /// + public Type Type { get; } + + /// + /// Gets or sets the HTTP status code of the response. + /// + public int StatusCode { get; } + + /// + void IApiResponseMetadataProvider.SetContentTypes(MediaTypeCollection contentTypes) + { + // Users are supposed to use the 'Produces' attribute to set the content types that an action can support. + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesErrorResponseTypeAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesErrorResponseTypeAttribute.cs new file mode 100644 index 0000000000..b9c579cea8 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ProducesErrorResponseTypeAttribute.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 System; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// Specifies the type returned by default by controllers annotated with . + /// + /// specifies the error model type associated with a + /// for a client error (HTTP Status Code 4xx) when no value is provided. When no value is specified, MVC assumes the + /// client error type to be , if mapping client errors () + /// is used. + /// + /// + /// Use this to configure the default error type if your application uses a custom error type to respond. + /// + /// + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class ProducesErrorResponseTypeAttribute : Attribute + { + /// + /// Initializes a new instance of . + /// + /// The error type. Use to indicate the absence of a default error type. + public ProducesErrorResponseTypeAttribute(Type type) + { + Type = type ?? throw new ArgumentNullException(nameof(type)); + } + + /// + /// Gets the default error type. + /// + public Type Type { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Properties/AssemblyInfo.cs index abaed09181..14bede0243 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Properties/AssemblyInfo.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Properties/AssemblyInfo.cs @@ -4,7 +4,38 @@ using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Mvc.Formatters; -[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Core.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] -[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.TestCommon, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] -[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] [assembly: TypeForwardedTo(typeof(InputFormatterException))] + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Cors, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.DataAnnotations, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Json, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Xml, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Localization, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Razor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.TagHelpers, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Testing, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ViewFeatures, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Abstractions.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer.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.Cors.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.DataAnnotations.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Json.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Xml.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.IntegrationTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Localization.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Razor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.TagHelpers.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ViewFeatures.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Views.TestCommon, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Performance, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] + +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs index d60a46fe66..8acff61ab2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs @@ -795,7 +795,7 @@ namespace Microsoft.AspNetCore.Mvc.Core => GetString("ModelBinderUtil_ModelMetadataCannotBeNull"); /// - /// A value for the '{0}' property was not provided. + /// A value for the '{0}' parameter or property was not provided. /// internal static string ModelBinding_MissingBindRequiredMember { @@ -803,7 +803,7 @@ namespace Microsoft.AspNetCore.Mvc.Core } /// - /// A value for the '{0}' property was not provided. + /// A value for the '{0}' parameter or property was not provided. /// internal static string FormatModelBinding_MissingBindRequiredMember(object p0) => string.Format(CultureInfo.CurrentCulture, GetString("ModelBinding_MissingBindRequiredMember"), p0); @@ -1313,7 +1313,7 @@ namespace Microsoft.AspNetCore.Mvc.Core => string.Format(CultureInfo.CurrentCulture, GetString("NoRoutesMatchedForPage"), p0); /// - /// The relative page path '{0}' can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. + /// The relative page path '{0}' can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. If you are using {1} then you must provide the current {2} to use relative pages. /// internal static string UrlHelper_RelativePagePathIsNotSupported { @@ -1321,10 +1321,10 @@ namespace Microsoft.AspNetCore.Mvc.Core } /// - /// The relative page path '{0}' can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. + /// The relative page path '{0}' can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. If you are using {1} then you must provide the current {2} to use relative pages. /// - internal static string FormatUrlHelper_RelativePagePathIsNotSupported(object p0) - => string.Format(CultureInfo.CurrentCulture, GetString("UrlHelper_RelativePagePathIsNotSupported"), p0); + internal static string FormatUrlHelper_RelativePagePathIsNotSupported(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("UrlHelper_RelativePagePathIsNotSupported"), p0, p1, p2); /// /// One or more validation errors occurred. @@ -1453,7 +1453,7 @@ namespace Microsoft.AspNetCore.Mvc.Core => string.Format(CultureInfo.CurrentCulture, GetString("ComplexTypeModelBinder_NoParameterlessConstructor_ForParameter"), p0, p1); /// - /// Action '{0}' has more than one parameter that were specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use '{1}' to specify query string bound, '{2}' to specify route bound, and '{3}' for parameters to be bound from body: + /// Action '{0}' has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use '{1}' to specify bound from query, '{2}' to specify bound from route, and '{3}' for parameters to be bound from body: /// internal static string ApiController_MultipleBodyParametersFound { @@ -1461,11 +1461,235 @@ namespace Microsoft.AspNetCore.Mvc.Core } /// - /// Action '{0}' has more than one parameter that were specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use '{1}' to specify query string bound, '{2}' to specify route bound, and '{3}' for parameters to be bound from body: + /// Action '{0}' has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use '{1}' to specify bound from query, '{2}' to specify bound from route, and '{3}' for parameters to be bound from body: /// internal static string FormatApiController_MultipleBodyParametersFound(object p0, object p1, object p2, object p3) => string.Format(CultureInfo.CurrentCulture, GetString("ApiController_MultipleBodyParametersFound"), p0, p1, p2, p3); + /// + /// API convention type '{0}' must be a static type. + /// + internal static string ApiConventionMustBeStatic + { + get => GetString("ApiConventionMustBeStatic"); + } + + /// + /// API convention type '{0}' must be a static type. + /// + internal static string FormatApiConventionMustBeStatic(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("ApiConventionMustBeStatic"), p0); + + /// + /// Invalid type parameter '{0}' specified for '{1}'. + /// + internal static string InvalidTypeTForActionResultOfT + { + get => GetString("InvalidTypeTForActionResultOfT"); + } + + /// + /// Invalid type parameter '{0}' specified for '{1}'. + /// + internal static string FormatInvalidTypeTForActionResultOfT(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("InvalidTypeTForActionResultOfT"), p0, p1); + + /// + /// Method {0} is decorated with the following attributes that are not allowed on an API convention method:{1}The following attributes are allowed on API convention methods: {2}. + /// + internal static string ApiConvention_UnsupportedAttributesOnConvention + { + get => GetString("ApiConvention_UnsupportedAttributesOnConvention"); + } + + /// + /// Method {0} is decorated with the following attributes that are not allowed on an API convention method:{1}The following attributes are allowed on API convention methods: {2}. + /// + internal static string FormatApiConvention_UnsupportedAttributesOnConvention(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("ApiConvention_UnsupportedAttributesOnConvention"), p0, p1, p2); + + /// + /// Method name '{0}' is ambiguous for convention type '{1}'. More than one method found with the name '{0}'. + /// + internal static string ApiConventionMethod_AmbiguousMethodName + { + get => GetString("ApiConventionMethod_AmbiguousMethodName"); + } + + /// + /// Method name '{0}' is ambiguous for convention type '{1}'. More than one method found with the name '{0}'. + /// + internal static string FormatApiConventionMethod_AmbiguousMethodName(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ApiConventionMethod_AmbiguousMethodName"), p0, p1); + + /// + /// A method named '{0}' was not found on convention type '{1}'. + /// + internal static string ApiConventionMethod_NoMethodFound + { + get => GetString("ApiConventionMethod_NoMethodFound"); + } + + /// + /// A method named '{0}' was not found on convention type '{1}'. + /// + internal static string FormatApiConventionMethod_NoMethodFound(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ApiConventionMethod_NoMethodFound"), p0, p1); + + /// + /// {0} exceeded the maximum configured validation depth '{1}' when validating type '{2}'. + /// + internal static string ValidationVisitor_ExceededMaxDepth + { + get => GetString("ValidationVisitor_ExceededMaxDepth"); + } + + /// + /// {0} exceeded the maximum configured validation depth '{1}' when validating type '{2}'. + /// + internal static string FormatValidationVisitor_ExceededMaxDepth(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("ValidationVisitor_ExceededMaxDepth"), p0, p1, p2); + + /// + /// This may indicate a very deep or infinitely recursive object graph. Consider modifying '{0}.{1}' or suppressing validation on the model type. + /// + internal static string ValidationVisitor_ExceededMaxDepthFix + { + get => GetString("ValidationVisitor_ExceededMaxDepthFix"); + } + + /// + /// This may indicate a very deep or infinitely recursive object graph. Consider modifying '{0}.{1}' or suppressing validation on the model type. + /// + internal static string FormatValidationVisitor_ExceededMaxDepthFix(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ValidationVisitor_ExceededMaxDepthFix"), p0, p1); + + /// + /// {0} exceeded the maximum configured validation depth '{1}' when validating property '{2}' on type '{3}'. + /// + internal static string ValidationVisitor_ExceededMaxPropertyDepth + { + get => GetString("ValidationVisitor_ExceededMaxPropertyDepth"); + } + + /// + /// {0} exceeded the maximum configured validation depth '{1}' when validating property '{2}' on type '{3}'. + /// + internal static string FormatValidationVisitor_ExceededMaxPropertyDepth(object p0, object p1, object p2, object p3) + => string.Format(CultureInfo.CurrentCulture, GetString("ValidationVisitor_ExceededMaxPropertyDepth"), p0, p1, p2, p3); + + /// + /// Bad Request + /// + internal static string ApiConventions_Title_400 + { + get => GetString("ApiConventions_Title_400"); + } + + /// + /// Bad Request + /// + internal static string FormatApiConventions_Title_400() + => GetString("ApiConventions_Title_400"); + + /// + /// Unauthorized + /// + internal static string ApiConventions_Title_401 + { + get => GetString("ApiConventions_Title_401"); + } + + /// + /// Unauthorized + /// + internal static string FormatApiConventions_Title_401() + => GetString("ApiConventions_Title_401"); + + /// + /// Forbidden + /// + internal static string ApiConventions_Title_403 + { + get => GetString("ApiConventions_Title_403"); + } + + /// + /// Forbidden + /// + internal static string FormatApiConventions_Title_403() + => GetString("ApiConventions_Title_403"); + + /// + /// Not Found + /// + internal static string ApiConventions_Title_404 + { + get => GetString("ApiConventions_Title_404"); + } + + /// + /// Not Found + /// + internal static string FormatApiConventions_Title_404() + => GetString("ApiConventions_Title_404"); + + /// + /// Not Acceptable + /// + internal static string ApiConventions_Title_406 + { + get => GetString("ApiConventions_Title_406"); + } + + /// + /// Not Acceptable + /// + internal static string FormatApiConventions_Title_406() + => GetString("ApiConventions_Title_406"); + + /// + /// Conflict + /// + internal static string ApiConventions_Title_409 + { + get => GetString("ApiConventions_Title_409"); + } + + /// + /// Conflict + /// + internal static string FormatApiConventions_Title_409() + => GetString("ApiConventions_Title_409"); + + /// + /// Unsupported Media Type + /// + internal static string ApiConventions_Title_415 + { + get => GetString("ApiConventions_Title_415"); + } + + /// + /// Unsupported Media Type + /// + internal static string FormatApiConventions_Title_415() + => GetString("ApiConventions_Title_415"); + + /// + /// Unprocessable Entity + /// + internal static string ApiConventions_Title_422 + { + get => GetString("ApiConventions_Title_422"); + } + + /// + /// Unprocessable Entity + /// + internal static string FormatApiConventions_Title_422() + => GetString("ApiConventions_Title_422"); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx index 909febc6b4..f1d2e78ade 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx @@ -295,7 +295,7 @@ The binding context cannot have a null ModelMetadata. - A value for the '{0}' property was not provided. + A value for the '{0}' parameter or property was not provided. A non-empty request body is required. @@ -410,7 +410,7 @@ No page named '{0}' matches the supplied values. - The relative page path '{0}' can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. + The relative page path '{0}' can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. If you are using {1} then you must provide the current {2} to use relative pages. One or more validation errors occurred. @@ -442,4 +442,52 @@ Action '{0}' has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use '{1}' to specify bound from query, '{2}' to specify bound from route, and '{3}' for parameters to be bound from body: + + API convention type '{0}' must be a static type. + + + Invalid type parameter '{0}' specified for '{1}'. + + + Method {0} is decorated with the following attributes that are not allowed on an API convention method:{1}The following attributes are allowed on API convention methods: {2}. + + + Method name '{0}' is ambiguous for convention type '{1}'. More than one method found with the name '{0}'. + + + A method named '{0}' was not found on convention type '{1}'. + + + {0} exceeded the maximum configured validation depth '{1}' when validating type '{2}'. + + + This may indicate a very deep or infinitely recursive object graph. Consider modifying '{0}.{1}' or suppressing validation on the model type. + + + {0} exceeded the maximum configured validation depth '{1}' when validating property '{2}' on type '{3}'. + + + Bad Request + + + Unauthorized + + + Forbidden + + + Not Found + + + Not Acceptable + + + Conflict + + + Unsupported Media Type + + + Unprocessable Entity + \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ActionConstraintMatcherPolicy.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ActionConstraintMatcherPolicy.cs new file mode 100644 index 0000000000..d31e06daf4 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ActionConstraintMatcherPolicy.cs @@ -0,0 +1,276 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + // This is a bridge that allows us to execute IActionConstraint instance when + // used with Matcher. + internal class ActionConstraintMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy + { + private static readonly IReadOnlyList EmptyEndpoints = Array.Empty(); + + // We need to be able to run IActionConstraints on Endpoints that aren't associated + // with an action. This is a sentinel value we use when the endpoint isn't from MVC. + internal static readonly ActionDescriptor NonAction = new ActionDescriptor(); + + private readonly ActionConstraintCache _actionConstraintCache; + + public ActionConstraintMatcherPolicy(ActionConstraintCache actionConstraintCache) + { + _actionConstraintCache = actionConstraintCache; + } + + // Run really late. + public override int Order => 100000; + + public bool AppliesToEndpoints(IReadOnlyList endpoints) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + // We can skip over action constraints when they aren't any for this set + // of endpoints. This happens once on startup so it removes this component + // from the code path in most scenarios. + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + var action = endpoint.Metadata.GetMetadata(); + if (action?.ActionConstraints?.Count > 0 && HasSignificantActionConstraint(action)) + { + // We need to check for some specific action constraint implementations. + // We've implemented consumes, and HTTP method support inside endpoint routing, so + // we don't need to run an 'action constraint phase' if those are the only constraints. + return true; + } + } + + return false; + + bool HasSignificantActionConstraint(ActionDescriptor a) + { + for (var i = 0; i < a.ActionConstraints.Count; i++) + { + var actionConstraint = a.ActionConstraints[i]; + if (actionConstraint.GetType() == typeof(HttpMethodActionConstraint)) + { + // This one is OK, we implement this in endpoint routing. + } + else if (actionConstraint.GetType().FullName == "Microsoft.AspNetCore.Mvc.Cors.Internal.CorsHttpMethodActionConstraint") + { + // This one is OK, we implement this in endpoint routing. + } + else if (actionConstraint.GetType() == typeof(ConsumesAttribute)) + { + // This one is OK, we implement this in endpoint routing. + } + else + { + return true; + } + } + + return false; + } + } + + public Task ApplyAsync(HttpContext httpContext, EndpointSelectorContext context, CandidateSet candidateSet) + { + var finalMatches = EvaluateActionConstraints(httpContext, candidateSet); + + // We've computed the set of actions that still apply (and their indices) + // First, mark everything as invalid, and then mark everything in the matching + // set as valid. This is O(2n) vs O(n**2) + for (var i = 0; i < candidateSet.Count; i++) + { + candidateSet.SetValidity(i, false); + } + + if (finalMatches != null) + { + for (var i = 0; i < finalMatches.Count; i++) + { + candidateSet.SetValidity(finalMatches[i].index, true); + } + } + + return Task.CompletedTask; + } + + // This is almost the same as the code in ActionSelector, but we can't really share the logic + // because we need to track the index of each candidate - and, each candidate has its own route + // values. + private IReadOnlyList<(int index, ActionSelectorCandidate candidate)> EvaluateActionConstraints( + HttpContext httpContext, + CandidateSet candidateSet) + { + var items = new List<(int index, ActionSelectorCandidate candidate)>(); + + // We want to execute a group at a time (based on score) so keep track of the score that we've seen. + int? score = null; + + // Perf: Avoid allocations + for (var i = 0; i < candidateSet.Count; i++) + { + if (candidateSet.IsValidCandidate(i)) + { + ref var candidate = ref candidateSet[i]; + if (score != null && score != candidate.Score) + { + // This is the end of a group. + var matches = EvaluateActionConstraintsCore(httpContext, candidateSet, items, startingOrder: null); + if (matches?.Count > 0) + { + return matches; + } + + // If we didn't find matches, then reset. + items.Clear(); + } + + score = candidate.Score; + + // If we get here, this is either the first endpoint or the we just (unsuccessfully) + // executed constraints for a group. + // + // So keep adding constraints. + var endpoint = candidate.Endpoint; + var actionDescriptor = endpoint.Metadata.GetMetadata(); + + IReadOnlyList constraints = Array.Empty(); + if (actionDescriptor != null) + { + constraints = _actionConstraintCache.GetActionConstraints(httpContext, actionDescriptor); + } + + // Capture the index. We need this later to look up the endpoint/route values. + items.Add((i, new ActionSelectorCandidate(actionDescriptor ?? NonAction, constraints))); + } + } + + // Handle residue + return EvaluateActionConstraintsCore(httpContext, candidateSet, items, startingOrder: null); + } + + private IReadOnlyList<(int index, ActionSelectorCandidate candidate)> EvaluateActionConstraintsCore( + HttpContext httpContext, + CandidateSet candidateSet, + IReadOnlyList<(int index, ActionSelectorCandidate candidate)> items, + int? startingOrder) + { + // Find the next group of constraints to process. This will be the lowest value of + // order that is higher than startingOrder. + int? order = null; + + // Perf: Avoid allocations + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + var constraints = item.candidate.Constraints; + if (constraints != null) + { + for (var j = 0; j < constraints.Count; j++) + { + var constraint = constraints[j]; + if ((startingOrder == null || constraint.Order > startingOrder) && + (order == null || constraint.Order < order)) + { + order = constraint.Order; + } + } + } + } + + // If we don't find a next then there's nothing left to do. + if (order == null) + { + return items; + } + + // Since we have a constraint to process, bisect the set of endpoints into those with and without a + // constraint for the current order. + var endpointsWithConstraint = new List<(int index, ActionSelectorCandidate candidate)>(); + var endpointsWithoutConstraint = new List<(int index, ActionSelectorCandidate candidate)>(); + + var constraintContext = new ActionConstraintContext + { + Candidates = items.Select(i => i.candidate).ToArray() + }; + + // Perf: Avoid allocations + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + var isMatch = true; + var foundMatchingConstraint = false; + + var constraints = item.candidate.Constraints; + if (constraints != null) + { + constraintContext.CurrentCandidate = item.candidate; + for (var j = 0; j < constraints.Count; j++) + { + var constraint = constraints[j]; + if (constraint.Order == order) + { + foundMatchingConstraint = true; + + // Before we run the constraint, we need to initialize the route values. + // In endpoint routing, the route values are per-endpoint. + constraintContext.RouteContext = new RouteContext(httpContext) + { + RouteData = new RouteData(candidateSet[item.index].Values), + }; + if (!constraint.Accept(constraintContext)) + { + isMatch = false; + break; + } + } + } + } + + if (isMatch && foundMatchingConstraint) + { + endpointsWithConstraint.Add(item); + } + else if (isMatch) + { + endpointsWithoutConstraint.Add(item); + } + } + + // If we have matches with constraints, those are better so try to keep processing those + if (endpointsWithConstraint.Count > 0) + { + var matches = EvaluateActionConstraintsCore(httpContext, candidateSet, endpointsWithConstraint, order); + if (matches?.Count > 0) + { + return matches; + } + } + + // If the set of matches with constraints can't work, then process the set without constraints. + if (endpointsWithoutConstraint.Count == 0) + { + return null; + } + else + { + return EvaluateActionConstraintsCore(httpContext, candidateSet, endpointsWithoutConstraint, order); + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ConsumesMatcherPolicy.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ConsumesMatcherPolicy.cs new file mode 100644 index 0000000000..c0f0dd35bd --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ConsumesMatcherPolicy.cs @@ -0,0 +1,241 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + internal class ConsumesMatcherPolicy : MatcherPolicy, IEndpointComparerPolicy, INodeBuilderPolicy + { + internal const string Http415EndpointDisplayName = "415 HTTP Unsupported Media Type"; + internal const string AnyContentType = "*/*"; + + // Run after HTTP methods, but before 'default'. + public override int Order { get; } = -100; + + public IComparer Comparer { get; } = new ConsumesMetadataEndpointComparer(); + + public bool AppliesToEndpoints(IReadOnlyList endpoints) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + return endpoints.Any(e => e.Metadata.GetMetadata()?.ContentTypes.Count > 0); + } + + public IReadOnlyList GetEdges(IReadOnlyList endpoints) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + // The algorithm here is designed to be preserve the order of the endpoints + // while also being relatively simple. Preserving order is important. + + // First, build a dictionary of all of the content-type patterns that are included + // at this node. + // + // For now we're just building up the set of keys. We don't add any endpoints + // to lists now because we don't want ordering problems. + var edges = new Dictionary>(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + var contentTypes = endpoint.Metadata.GetMetadata()?.ContentTypes; + if (contentTypes == null || contentTypes.Count == 0) + { + contentTypes = new string[] { AnyContentType, }; + } + + for (var j = 0; j < contentTypes.Count; j++) + { + var contentType = contentTypes[j]; + + if (!edges.ContainsKey(contentType)) + { + edges.Add(contentType, new List()); + } + } + } + + // Now in a second loop, add endpoints to these lists. We've enumerated all of + // the states, so we want to see which states this endpoint matches. + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + var contentTypes = endpoint.Metadata.GetMetadata()?.ContentTypes ?? Array.Empty(); + if (contentTypes.Count == 0) + { + // OK this means that this endpoint matches *all* content methods. + // So, loop and add it to all states. + foreach (var kvp in edges) + { + kvp.Value.Add(endpoint); + } + } + else + { + // OK this endpoint matches specific content types -- we have to loop through edges here + // because content types could either be exact (like 'application/json') or they + // could have wildcards (like 'text/*'). We don't expect wildcards to be especially common + // with consumes, but we need to support it. + foreach (var kvp in edges) + { + // The edgeKey maps to a possible request header value + var edgeKey = new MediaType(kvp.Key); + + for (var j = 0; j < contentTypes.Count; j++) + { + var contentType = contentTypes[j]; + + var mediaType = new MediaType(contentType); + + // Example: 'application/json' is subset of 'application/*' + // + // This means that when the request has content-type 'application/json' an endpoint + // what consumes 'application/*' should match. + if (edgeKey.IsSubsetOf(mediaType)) + { + kvp.Value.Add(endpoint); + + // It's possible that a ConsumesMetadata defines overlapping wildcards. Don't add an endpoint + // to any edge twice + break; + } + } + } + } + } + + // If after we're done there isn't any endpoint that accepts */*, then we'll synthesize an + // endpoint that always returns a 415. + if (!edges.ContainsKey(AnyContentType)) + { + edges.Add(AnyContentType, new List() + { + CreateRejectionEndpoint(), + }); + } + + return edges + .Select(kvp => new PolicyNodeEdge(kvp.Key, kvp.Value)) + .ToArray(); + } + + private Endpoint CreateRejectionEndpoint() + { + return new Endpoint( + (context) => + { + context.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; + return Task.CompletedTask; + }, + EndpointMetadataCollection.Empty, + Http415EndpointDisplayName); + } + + public PolicyJumpTable BuildJumpTable(int exitDestination, IReadOnlyList edges) + { + if (edges == null) + { + throw new ArgumentNullException(nameof(edges)); + } + + // Since our 'edges' can have wildcards, we do a sort based on how wildcard-ey they + // are then then execute them in linear order. + var ordered = edges + .Select(e => (mediaType: new MediaType((string)e.State), destination: e.Destination)) + .OrderBy(e => GetScore(e.mediaType)) + .ToArray(); + + // If any edge matches all content types, then treat that as the 'exit'. This will + // always happen because we insert a 415 endpoint. + for (var i = 0; i < ordered.Length; i++) + { + if (ordered[i].mediaType.MatchesAllTypes) + { + exitDestination = ordered[i].destination; + break; + } + } + + return new ConsumesPolicyJumpTable(exitDestination, ordered); + } + + private int GetScore(in MediaType mediaType) + { + // Higher score == lower priority - see comments on MediaType. + if (mediaType.MatchesAllTypes) + { + return 4; + } + else if (mediaType.MatchesAllSubTypes) + { + return 3; + } + else if (mediaType.MatchesAllSubTypesWithoutSuffix) + { + return 2; + } + else + { + return 1; + } + } + + private class ConsumesMetadataEndpointComparer : EndpointMetadataComparer + { + protected override int CompareMetadata(IConsumesMetadata x, IConsumesMetadata y) + { + // Ignore the metadata if it has an empty list of content types. + return base.CompareMetadata( + x?.ContentTypes.Count > 0 ? x : null, + y?.ContentTypes.Count > 0 ? y : null); + } + } + + private class ConsumesPolicyJumpTable : PolicyJumpTable + { + private (MediaType mediaType, int destination)[] _destinations; + private int _exitDestination; + + public ConsumesPolicyJumpTable(int exitDestination, (MediaType mediaType, int destination)[] destinations) + { + _exitDestination = exitDestination; + _destinations = destinations; + } + + public override int GetDestination(HttpContext httpContext) + { + var contentType = httpContext.Request.ContentType; + if (string.IsNullOrEmpty(contentType)) + { + return _exitDestination; + } + + var requestMediaType = new MediaType(contentType); + var destinations = _destinations; + for (var i = 0; i < destinations.Length; i++) + { + if (requestMediaType.IsSubsetOf(destinations[i].mediaType)) + { + return destinations[i].destination; + } + } + + return _exitDestination; + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ConsumesMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ConsumesMetadata.cs new file mode 100644 index 0000000000..40c7c86f1d --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ConsumesMetadata.cs @@ -0,0 +1,23 @@ +// 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; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + internal class ConsumesMetadata : IConsumesMetadata + { + public ConsumesMetadata(string[] contentTypes) + { + if (contentTypes == null) + { + throw new ArgumentNullException(nameof(contentTypes)); + } + + ContentTypes = contentTypes; + } + + public IReadOnlyList ContentTypes { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ControllerLinkGeneratorExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ControllerLinkGeneratorExtensions.cs new file mode 100644 index 0000000000..fb02fae6f4 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/ControllerLinkGeneratorExtensions.cs @@ -0,0 +1,241 @@ +// 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.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Routing; +using System; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Extension methods for using to generate links to MVC controllers. + /// + public static class ControllerLinkGeneratorExtensions + { + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The associated with the current request. + /// + /// The action name. Used to resolve endpoints. Optional. If null is provided, the current action route value + /// will be used. + /// + /// + /// The controller name. Used to resolve endpoints. Optional. If null is provided, the current controller route value + /// will be used. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// An optional URI path base. Prepended to the path in the resulting URI. If not provided, the value of will be used. + /// + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null if a URI cannot be created. + public static string GetPathByAction( + this LinkGenerator generator, + HttpContext httpContext, + string action = default, + string controller = default, + object values = default, + PathString? pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var address = CreateAddress(httpContext, action, controller, values); + return generator.GetPathByAddress( + httpContext, + address, + address.ExplicitValues, + address.AmbientValues, + pathBase, + fragment, + options); + } + + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The action name. Used to resolve endpoints. + /// The controller name. Used to resolve endpoints. + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null if a URI cannot be created. + public static string GetPathByAction( + this LinkGenerator generator, + string action, + string controller, + object values = default, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (controller == null) + { + throw new ArgumentNullException(nameof(controller)); + } + + var address = CreateAddress(httpContext: null, action, controller, values); + return generator.GetPathByAddress(address, address.ExplicitValues, pathBase, fragment, options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The associated with the current request. + /// + /// The action name. Used to resolve endpoints. Optional. If null is provided, the current action route value + /// will be used. + /// + /// + /// The controller name. Used to resolve endpoints. Optional. If null is provided, the current controller route value + /// will be used. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// The URI scheme, applied to the resulting URI. Optional. If not provided, the value of will be used. + /// + /// + /// The URI host/authority, applied to the resulting URI. Optional. If not provided, the value will be used. + /// + /// + /// An optional URI path base. Prepended to the path in the resulting URI. If not provided, the value of will be used. + /// + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A absolute URI, or null if a URI cannot be created. + public static string GetUriByAction( + this LinkGenerator generator, + HttpContext httpContext, + string action = default, + string controller = default, + object values = default, + string scheme = default, + HostString? host = default, + PathString? pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var address = CreateAddress(httpContext, action, controller, values); + return generator.GetUriByAddress( + httpContext, + address, + address.ExplicitValues, + address.AmbientValues, + scheme, + host, + pathBase, + fragment, + options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The action name. Used to resolve endpoints. + /// The controller name. Used to resolve endpoints. + /// The route values. May be null. Used to resolve endpoints and expand parameters in the route template. + /// The URI scheme, applied to the resulting URI. + /// The URI host/authority, applied to the resulting URI. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A absolute URI, or null if a URI cannot be created. + public static string GetUriByAction( + this LinkGenerator generator, + string action, + string controller, + object values, + string scheme, + HostString host, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (controller == null) + { + throw new ArgumentNullException(nameof(controller)); + } + + var address = CreateAddress(httpContext: null, action, controller, values); + return generator.GetUriByAddress(address, address.ExplicitValues, scheme, host, pathBase, fragment, options); + } + + private static RouteValuesAddress CreateAddress(HttpContext httpContext, string action, string controller, object values) + { + var explicitValues = new RouteValueDictionary(values); + var ambientValues = GetAmbientValues(httpContext); + + UrlHelperBase.NormalizeRouteValuesForAction(action, controller, explicitValues, ambientValues); + + return new RouteValuesAddress() + { + AmbientValues = ambientValues, + ExplicitValues = explicitValues + }; + } + + private static RouteValueDictionary GetAmbientValues(HttpContext httpContext) + { + return httpContext?.Features.Get()?.RouteValues; + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/EndpointRoutingUrlHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/EndpointRoutingUrlHelper.cs new file mode 100644 index 0000000000..528d6e2efe --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/EndpointRoutingUrlHelper.cs @@ -0,0 +1,108 @@ +// 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.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + /// + /// An implementation of that uses to build URLs + /// for ASP.NET MVC within an application. + /// + internal class EndpointRoutingUrlHelper : UrlHelperBase + { + private readonly ILogger _logger; + private readonly LinkGenerator _linkGenerator; + + /// + /// Initializes a new instance of the class using the specified + /// . + /// + /// The for the current request. + /// The used to generate the link. + /// The . + public EndpointRoutingUrlHelper( + ActionContext actionContext, + LinkGenerator linkGenerator, + ILogger logger) + : base(actionContext) + { + if (linkGenerator == null) + { + throw new ArgumentNullException(nameof(linkGenerator)); + } + + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + _linkGenerator = linkGenerator; + _logger = logger; + } + + /// + public override string Action(UrlActionContext urlActionContext) + { + if (urlActionContext == null) + { + throw new ArgumentNullException(nameof(urlActionContext)); + } + + var values = GetValuesDictionary(urlActionContext.Values); + + if (urlActionContext.Action == null) + { + if (!values.ContainsKey("action") && + AmbientValues.TryGetValue("action", out var action)) + { + values["action"] = action; + } + } + else + { + values["action"] = urlActionContext.Action; + } + + if (urlActionContext.Controller == null) + { + if (!values.ContainsKey("controller") && + AmbientValues.TryGetValue("controller", out var controller)) + { + values["controller"] = controller; + } + } + else + { + values["controller"] = urlActionContext.Controller; + } + + + var path = _linkGenerator.GetPathByRouteValues( + ActionContext.HttpContext, + routeName: null, + values, + fragment: new FragmentString(urlActionContext.Fragment == null ? null : "#" + urlActionContext.Fragment)); + return GenerateUrl(urlActionContext.Protocol, urlActionContext.Host, path); + } + + /// + public override string RouteUrl(UrlRouteContext routeContext) + { + if (routeContext == null) + { + throw new ArgumentNullException(nameof(routeContext)); + } + + var path = _linkGenerator.GetPathByRouteValues( + ActionContext.HttpContext, + routeContext.RouteName, + routeContext.Values, + fragment: new FragmentString(routeContext.Fragment == null ? null : "#" + routeContext.Fragment)); + return GenerateUrl(routeContext.Protocol, routeContext.Host, path); + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/HttpMethodAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/HttpMethodAttribute.cs index 44095ee4ae..cdf2138204 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/HttpMethodAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/HttpMethodAttribute.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; namespace Microsoft.AspNetCore.Mvc.Routing { /// - /// Identifies an action that only supports a given set of HTTP methods. + /// Identifies an action that supports a given set of HTTP methods. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] public abstract class HttpMethodAttribute : Attribute, IActionHttpMethodProvider, IRouteTemplateProvider diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/IConsumesMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/IConsumesMetadata.cs new file mode 100644 index 0000000000..83e04931b7 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/IConsumesMetadata.cs @@ -0,0 +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.Collections.Generic; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + internal interface IConsumesMetadata + { + IReadOnlyList ContentTypes { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/KnownRouteValueConstraint.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/KnownRouteValueConstraint.cs index a80b3b510c..d3a7f5a32a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/KnownRouteValueConstraint.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/KnownRouteValueConstraint.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Core; @@ -14,8 +15,26 @@ namespace Microsoft.AspNetCore.Mvc.Routing { public class KnownRouteValueConstraint : IRouteConstraint { + private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; private RouteValuesCollection _cachedValuesCollection; + [Obsolete("This constructor is obsolete. Use KnownRouteValueConstraint.ctor(IActionDescriptorCollectionProvider) instead.")] + public KnownRouteValueConstraint() + { + // Empty constructor for backwards compatibility + // Services will need to be resolved from HttpContext when this ctor is used + } + + public KnownRouteValueConstraint(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + { + if (actionDescriptorCollectionProvider == null) + { + throw new ArgumentNullException(nameof(actionDescriptorCollectionProvider)); + } + + _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; + } + public bool Match( HttpContext httpContext, IRouter route, @@ -23,16 +42,6 @@ namespace Microsoft.AspNetCore.Mvc.Routing RouteValueDictionary values, RouteDirection routeDirection) { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - - if (route == null) - { - throw new ArgumentNullException(nameof(route)); - } - if (routeKey == null) { throw new ArgumentNullException(nameof(routeKey)); @@ -43,13 +52,14 @@ namespace Microsoft.AspNetCore.Mvc.Routing throw new ArgumentNullException(nameof(values)); } - object obj; - if (values.TryGetValue(routeKey, out obj)) + if (values.TryGetValue(routeKey, out var obj)) { - var value = obj as string; + var value = Convert.ToString(obj, CultureInfo.InvariantCulture); if (value != null) { - var allValues = GetAndCacheAllMatchingValues(routeKey, httpContext); + var actionDescriptors = GetAndValidateActionDescriptors(httpContext); + + var allValues = GetAndCacheAllMatchingValues(routeKey, actionDescriptors); foreach (var existingValue in allValues) { if (string.Equals(value, existingValue, StringComparison.OrdinalIgnoreCase)) @@ -63,9 +73,36 @@ namespace Microsoft.AspNetCore.Mvc.Routing return false; } - private string[] GetAndCacheAllMatchingValues(string routeKey, HttpContext httpContext) + private ActionDescriptorCollection GetAndValidateActionDescriptors(HttpContext httpContext) + { + var actionDescriptorsProvider = _actionDescriptorCollectionProvider; + + if (actionDescriptorsProvider == null) + { + // Only validate that HttpContext was passed to constraint if it is needed + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var services = httpContext.RequestServices; + actionDescriptorsProvider = services.GetRequiredService(); + } + + var actionDescriptors = actionDescriptorsProvider.ActionDescriptors; + if (actionDescriptors == null) + { + throw new InvalidOperationException( + Resources.FormatPropertyOfTypeCannotBeNull( + nameof(IActionDescriptorCollectionProvider.ActionDescriptors), + actionDescriptorsProvider.GetType())); + } + + return actionDescriptors; + } + + private string[] GetAndCacheAllMatchingValues(string routeKey, ActionDescriptorCollection actionDescriptors) { - var actionDescriptors = GetAndValidateActionDescriptorCollection(httpContext); var version = actionDescriptors.Version; var valuesCollection = _cachedValuesCollection; @@ -77,8 +114,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing { var action = actionDescriptors.Items[i]; - string value; - if (action.RouteValues.TryGetValue(routeKey, out value) && + if (action.RouteValues.TryGetValue(routeKey, out var value) && !string.IsNullOrEmpty(value)) { values.Add(value); @@ -92,22 +128,6 @@ namespace Microsoft.AspNetCore.Mvc.Routing return _cachedValuesCollection.Items; } - private static ActionDescriptorCollection GetAndValidateActionDescriptorCollection(HttpContext httpContext) - { - var services = httpContext.RequestServices; - var provider = services.GetRequiredService(); - var descriptors = provider.ActionDescriptors; - - if (descriptors == null) - { - throw new InvalidOperationException( - Resources.FormatPropertyOfTypeCannotBeNull("ActionDescriptors", - provider.GetType())); - } - - return descriptors; - } - private class RouteValuesCollection { public RouteValuesCollection(int version, string[] items) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/PageLinkGeneratorExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/PageLinkGeneratorExtensions.cs new file mode 100644 index 0000000000..032831560b --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/PageLinkGeneratorExtensions.cs @@ -0,0 +1,233 @@ +// 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.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Extension methods for using to generate links to Razor Pages. + /// + public static class PageLinkGeneratorExtensions + { + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The associated with the current request. + /// + /// The page name. Used to resolve endpoints. Optional. If null is provided, the current page route value + /// will be used. + /// + /// + /// The page handler name. Used to resolve endpoints. Optional. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// An optional URI path base. Prepended to the path in the resulting URI. If not provided, the value of will be used. + /// + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null if a URI cannot be created. + public static string GetPathByPage( + this LinkGenerator generator, + HttpContext httpContext, + string page = default, + string handler = default, + object values = default, + PathString? pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var address = CreateAddress(httpContext, page, handler, values); + return generator.GetPathByAddress( + httpContext, + address, + address.ExplicitValues, + address.AmbientValues, + pathBase, + fragment, + options); + } + + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// + /// The page name. Used to resolve endpoints. + /// + /// + /// The page handler name. Used to resolve endpoints. Optional. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null if a URI cannot be created. + public static string GetPathByPage( + this LinkGenerator generator, + string page, + string handler = default, + object values = default, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (page == null) + { + throw new ArgumentNullException(nameof(page)); + } + + var address = CreateAddress(httpContext: null, page, handler, values); + return generator.GetPathByAddress(address, address.ExplicitValues, pathBase, fragment, options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The associated with the current request. + /// + /// The page name. Used to resolve endpoints. Optional. If null is provided, the current page route value + /// will be used. + /// + /// + /// The page handler name. Used to resolve endpoints. Optional. + /// + /// The route values. Optional. Used to resolve endpoints and expand parameters in the route template. + /// + /// The URI scheme, applied to the resulting URI. Optional. If not provided, the value of will be used. + /// + /// + /// The URI host/authority, applied to the resulting URI. Optional. If not provided, the value will be used. + /// + /// + /// An optional URI path base. Prepended to the path in the resulting URI. If not provided, the value of will be used. + /// + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A absolute URI, or null if a URI cannot be created. + public static string GetUriByPage( + this LinkGenerator generator, + HttpContext httpContext, + string page = default, + string handler = default, + object values = default, + string scheme = default, + HostString? host = default, + PathString? pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var address = CreateAddress(httpContext, page, handler, values); + return generator.GetUriByAddress( + httpContext, + address, + address.ExplicitValues, + address.AmbientValues, + scheme, + host, + pathBase, + fragment, + options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The page name. Used to resolve endpoints. + /// The page handler name. May be null. + /// The route values. May be null. Used to resolve endpoints and expand parameters in the route template. + /// The URI scheme, applied to the resulting URI. + /// The URI host/authority, applied to the resulting URI. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// A URI fragment. Optional. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A absolute URI, or null if a URI cannot be created. + public static string GetUriByPage( + this LinkGenerator generator, + string page, + string handler, + object values, + string scheme, + HostString host, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (page == null) + { + throw new ArgumentNullException(nameof(page)); + } + + var address = CreateAddress(httpContext: null, page, handler, values); + return generator.GetUriByAddress(address, address.ExplicitValues, scheme, host, pathBase, fragment, options); + } + + private static RouteValuesAddress CreateAddress(HttpContext httpContext, string page, string handler, object values) + { + var explicitValues = new RouteValueDictionary(values); + var ambientValues = GetAmbientValues(httpContext); + + UrlHelperBase.NormalizeRouteValuesForPage(context: null, page, handler, explicitValues, ambientValues); + + return new RouteValuesAddress() + { + AmbientValues = ambientValues, + ExplicitValues = explicitValues + }; + } + + private static RouteValueDictionary GetAmbientValues(HttpContext httpContext) + { + return httpContext?.Features.Get()?.RouteValues; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelper.cs index 31a8b4a9fd..33f7d36f3e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelper.cs @@ -2,9 +2,6 @@ // 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.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -14,38 +11,18 @@ namespace Microsoft.AspNetCore.Mvc.Routing /// An implementation of that contains methods to /// build URLs for ASP.NET MVC within an application. /// - public class UrlHelper : IUrlHelper + public class UrlHelper : UrlHelperBase { - - // Perf: Share the StringBuilder object across multiple calls of GenerateURL for this UrlHelper - private StringBuilder _stringBuilder; - // Perf: Reuse the RouteValueDictionary across multiple calls of Action for this UrlHelper - private readonly RouteValueDictionary _routeValueDictionary; - /// /// Initializes a new instance of the class using the specified /// . /// /// The for the current request. public UrlHelper(ActionContext actionContext) + : base(actionContext) { - if (actionContext == null) - { - throw new ArgumentNullException(nameof(actionContext)); - } - - ActionContext = actionContext; - _routeValueDictionary = new RouteValueDictionary(); } - /// - public ActionContext ActionContext { get; } - - /// - /// Gets the associated with the current request. - /// - protected RouteValueDictionary AmbientValues => ActionContext.RouteData.Values; - /// /// Gets the associated with the current request. /// @@ -58,7 +35,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing protected IRouter Router => ActionContext.RouteData.Routers[0]; /// - public virtual string Action(UrlActionContext actionContext) + public override string Action(UrlActionContext actionContext) { if (actionContext == null) { @@ -67,87 +44,14 @@ namespace Microsoft.AspNetCore.Mvc.Routing var valuesDictionary = GetValuesDictionary(actionContext.Values); - if (actionContext.Action == null) - { - object action; - if (!valuesDictionary.ContainsKey("action") && - AmbientValues.TryGetValue("action", out action)) - { - valuesDictionary["action"] = action; - } - } - else - { - valuesDictionary["action"] = actionContext.Action; - } - - if (actionContext.Controller == null) - { - object controller; - if (!valuesDictionary.ContainsKey("controller") && - AmbientValues.TryGetValue("controller", out controller)) - { - valuesDictionary["controller"] = controller; - } - } - else - { - valuesDictionary["controller"] = actionContext.Controller; - } + NormalizeRouteValuesForAction(actionContext.Action, actionContext.Controller, valuesDictionary, AmbientValues); var virtualPathData = GetVirtualPathData(routeName: null, values: valuesDictionary); return GenerateUrl(actionContext.Protocol, actionContext.Host, virtualPathData, actionContext.Fragment); } /// - public virtual bool IsLocalUrl(string url) - { - if (string.IsNullOrEmpty(url)) - { - return false; - } - - // Allows "/" or "/foo" but not "//" or "/\". - if (url[0] == '/') - { - // url is exactly "/" - if (url.Length == 1) - { - return true; - } - - // url doesn't start with "//" or "/\" - if (url[1] != '/' && url[1] != '\\') - { - return true; - } - - return false; - } - - // Allows "~/" or "~/foo" but not "~//" or "~/\". - if (url[0] == '~' && url.Length > 1 && url[1] == '/') - { - // url is exactly "~/" - if (url.Length == 2) - { - return true; - } - - // url doesn't start with "~//" or "~/\" - if (url[2] != '/' && url[2] != '\\') - { - return true; - } - - return false; - } - - return false; - } - - /// - public virtual string RouteUrl(UrlRouteContext routeContext) + public override string RouteUrl(UrlRouteContext routeContext) { if (routeContext == null) { @@ -167,7 +71,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing /// /// /// The . The uses these values, in combination with - /// , to generate the URL. + /// , to generate the URL. /// /// The . protected virtual VirtualPathData GetVirtualPathData(string routeName, RouteValueDictionary values) @@ -176,128 +80,6 @@ namespace Microsoft.AspNetCore.Mvc.Routing return Router.GetVirtualPath(context); } - // Internal for unit testing. - internal void AppendPathAndFragment(StringBuilder builder, VirtualPathData pathData, string fragment) - { - var pathBase = HttpContext.Request.PathBase; - - if (!pathBase.HasValue) - { - if (pathData.VirtualPath.Length == 0) - { - builder.Append("/"); - } - else - { - if (!pathData.VirtualPath.StartsWith("/", StringComparison.Ordinal)) - { - builder.Append("/"); - } - - builder.Append(pathData.VirtualPath); - } - } - else - { - if (pathData.VirtualPath.Length == 0) - { - builder.Append(pathBase.Value); - } - else - { - builder.Append(pathBase.Value); - - if (pathBase.Value.EndsWith("/", StringComparison.Ordinal)) - { - builder.Length--; - } - - if (!pathData.VirtualPath.StartsWith("/", StringComparison.Ordinal)) - { - builder.Append("/"); - } - - builder.Append(pathData.VirtualPath); - } - } - - if (!string.IsNullOrEmpty(fragment)) - { - builder.Append("#").Append(fragment); - } - } - - /// - public virtual string Content(string contentPath) - { - if (string.IsNullOrEmpty(contentPath)) - { - return null; - } - else if (contentPath[0] == '~') - { - var segment = new PathString(contentPath.Substring(1)); - var applicationPath = HttpContext.Request.PathBase; - - return applicationPath.Add(segment).Value; - } - - return contentPath; - } - - /// - public virtual string Link(string routeName, object values) - { - return RouteUrl(new UrlRouteContext() - { - RouteName = routeName, - Values = values, - Protocol = HttpContext.Request.Scheme, - Host = HttpContext.Request.Host.ToUriComponent() - }); - } - - private RouteValueDictionary GetValuesDictionary(object values) - { - // Perf: RouteValueDictionary can be cast to IDictionary, but it is - // special cased to avoid allocating boxed Enumerator. - var routeValuesDictionary = values as RouteValueDictionary; - if (routeValuesDictionary != null) - { - _routeValueDictionary.Clear(); - foreach (var kvp in routeValuesDictionary) - { - _routeValueDictionary.Add(kvp.Key, kvp.Value); - } - - return _routeValueDictionary; - } - - var dictionaryValues = values as IDictionary; - if (dictionaryValues != null) - { - _routeValueDictionary.Clear(); - foreach (var kvp in dictionaryValues) - { - _routeValueDictionary.Add(kvp.Key, kvp.Value); - } - - return _routeValueDictionary; - } - - return new RouteValueDictionary(values); - } - - private StringBuilder GetStringBuilder() - { - if(_stringBuilder == null) - { - _stringBuilder = new StringBuilder(); - } - - return _stringBuilder; - } - /// /// Generates the URL using the specified components. /// @@ -308,85 +90,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing /// The generated URL. protected virtual string GenerateUrl(string protocol, string host, VirtualPathData pathData, string fragment) { - if (pathData == null) - { - return null; - } - - // VirtualPathData.VirtualPath returns string.Empty instead of null. - Debug.Assert(pathData.VirtualPath != null); - - // Perf: In most of the common cases, GenerateUrl is called with a null protocol, host and fragment. - // In such cases, we might not need to build any URL as the url generated is mostly same as the virtual path available in pathData. - // For such common cases, this FastGenerateUrl method saves a string allocation per GenerateUrl call. - string url; - if (TryFastGenerateUrl(protocol, host, pathData, fragment, out url)) - { - return url; - } - - var builder = GetStringBuilder(); - try - { - if (string.IsNullOrEmpty(protocol) && string.IsNullOrEmpty(host)) - { - AppendPathAndFragment(builder, pathData, fragment); - // We're returning a partial URL (just path + query + fragment), but we still want it to be rooted. - if (builder.Length == 0 || builder[0] != '/') - { - builder.Insert(0, '/'); - } - } - else - { - protocol = string.IsNullOrEmpty(protocol) ? "http" : protocol; - builder.Append(protocol); - - builder.Append("://"); - - host = string.IsNullOrEmpty(host) ? HttpContext.Request.Host.Value : host; - builder.Append(host); - AppendPathAndFragment(builder, pathData, fragment); - } - - var path = builder.ToString(); - return path; - } - finally - { - // Clear the StringBuilder so that it can reused for the next call. - builder.Clear(); - } - } - - private bool TryFastGenerateUrl( - string protocol, - string host, - VirtualPathData pathData, - string fragment, - out string url) - { - var pathBase = HttpContext.Request.PathBase; - url = null; - - if (string.IsNullOrEmpty(protocol) - && string.IsNullOrEmpty(host) - && string.IsNullOrEmpty(fragment) - && !pathBase.HasValue) - { - if (pathData.VirtualPath.Length == 0) - { - url = "/"; - return true; - } - else if (pathData.VirtualPath.StartsWith("/", StringComparison.Ordinal)) - { - url = pathData.VirtualPath; - return true; - } - } - - return false; + return GenerateUrl(protocol, host, pathData?.VirtualPath, fragment); } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperBase.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperBase.cs new file mode 100644 index 0000000000..8e84b081b3 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperBase.cs @@ -0,0 +1,466 @@ +// 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.Globalization; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + public abstract class UrlHelperBase : IUrlHelper + { + // Perf: Share the StringBuilder object across multiple calls of GenerateURL for this UrlHelper + private StringBuilder _stringBuilder; + + // Perf: Reuse the RouteValueDictionary across multiple calls of Action for this UrlHelper + private readonly RouteValueDictionary _routeValueDictionary; + + protected UrlHelperBase(ActionContext actionContext) + { + if (actionContext == null) + { + throw new ArgumentNullException(nameof(actionContext)); + } + + ActionContext = actionContext; + AmbientValues = actionContext.RouteData.Values; + _routeValueDictionary = new RouteValueDictionary(); + } + + /// + /// Gets the associated with the current request. + /// + protected RouteValueDictionary AmbientValues { get; } + + /// + public ActionContext ActionContext { get; } + + /// + public virtual bool IsLocalUrl(string url) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + // Allows "/" or "/foo" but not "//" or "/\". + if (url[0] == '/') + { + // url is exactly "/" + if (url.Length == 1) + { + return true; + } + + // url doesn't start with "//" or "/\" + if (url[1] != '/' && url[1] != '\\') + { + return true; + } + + return false; + } + + // Allows "~/" or "~/foo" but not "~//" or "~/\". + if (url[0] == '~' && url.Length > 1 && url[1] == '/') + { + // url is exactly "~/" + if (url.Length == 2) + { + return true; + } + + // url doesn't start with "~//" or "~/\" + if (url[2] != '/' && url[2] != '\\') + { + return true; + } + + return false; + } + + return false; + } + + /// + public virtual string Content(string contentPath) + { + if (string.IsNullOrEmpty(contentPath)) + { + return null; + } + else if (contentPath[0] == '~') + { + var segment = new PathString(contentPath.Substring(1)); + var applicationPath = ActionContext.HttpContext.Request.PathBase; + + return applicationPath.Add(segment).Value; + } + + return contentPath; + } + + /// + public virtual string Link(string routeName, object values) + { + return RouteUrl(new UrlRouteContext() + { + RouteName = routeName, + Values = values, + Protocol = ActionContext.HttpContext.Request.Scheme, + Host = ActionContext.HttpContext.Request.Host.ToUriComponent() + }); + } + + /// + public abstract string Action(UrlActionContext actionContext); + + /// + public abstract string RouteUrl(UrlRouteContext routeContext); + + protected RouteValueDictionary GetValuesDictionary(object values) + { + // Perf: RouteValueDictionary can be cast to IDictionary, but it is + // special cased to avoid allocating boxed Enumerator. + if (values is RouteValueDictionary routeValuesDictionary) + { + _routeValueDictionary.Clear(); + foreach (var kvp in routeValuesDictionary) + { + _routeValueDictionary.Add(kvp.Key, kvp.Value); + } + + return _routeValueDictionary; + } + + if (values is IDictionary dictionaryValues) + { + _routeValueDictionary.Clear(); + foreach (var kvp in dictionaryValues) + { + _routeValueDictionary.Add(kvp.Key, kvp.Value); + } + + return _routeValueDictionary; + } + + return new RouteValueDictionary(values); + } + + protected string GenerateUrl(string protocol, string host, string virtualPath, string fragment) + { + if (virtualPath == null) + { + return null; + } + + // Perf: In most of the common cases, GenerateUrl is called with a null protocol, host and fragment. + // In such cases, we might not need to build any URL as the url generated is mostly same as the virtual path available in pathData. + // For such common cases, this FastGenerateUrl method saves a string allocation per GenerateUrl call. + if (TryFastGenerateUrl(protocol, host, virtualPath, fragment, out var url)) + { + return url; + } + + var builder = GetStringBuilder(); + try + { + var pathBase = ActionContext.HttpContext.Request.PathBase; + + if (string.IsNullOrEmpty(protocol) && string.IsNullOrEmpty(host)) + { + AppendPathAndFragment(builder, pathBase, virtualPath, fragment); + // We're returning a partial URL (just path + query + fragment), but we still want it to be rooted. + if (builder.Length == 0 || builder[0] != '/') + { + builder.Insert(0, '/'); + } + } + else + { + protocol = string.IsNullOrEmpty(protocol) ? "http" : protocol; + builder.Append(protocol); + + builder.Append("://"); + + host = string.IsNullOrEmpty(host) ? ActionContext.HttpContext.Request.Host.Value : host; + builder.Append(host); + AppendPathAndFragment(builder, pathBase, virtualPath, fragment); + } + + var path = builder.ToString(); + return path; + } + finally + { + // Clear the StringBuilder so that it can reused for the next call. + builder.Clear(); + } + } + + /// + /// Generates a URI from the provided components. + /// + /// The URI scheme/protocol. + /// The URI host. + /// The URI path and remaining portions (path, query, and fragment). + /// + /// An absolute URI if the or is specified, otherwise generates a + /// URI with an absolute path. + /// + protected string GenerateUrl(string protocol, string host, string path) + { + // This method is similar to GenerateUrl, but it's used for EndpointRouting. It ignores pathbase and fragment + // because those have already been incorporated. + if (path == null) + { + return null; + } + + // Perf: In most of the common cases, GenerateUrl is called with a null protocol, host and fragment. + // In such cases, we might not need to build any URL as the url generated is mostly same as the virtual path available in pathData. + // For such common cases, this FastGenerateUrl method saves a string allocation per GenerateUrl call. + if (TryFastGenerateUrl(protocol, host, path, fragment: null, out var url)) + { + return url; + } + + var builder = GetStringBuilder(); + try + { + if (string.IsNullOrEmpty(protocol) && string.IsNullOrEmpty(host)) + { + AppendPathAndFragment(builder, pathBase: null, path, fragment: null); + + // We're returning a partial URL (just path + query + fragment), but we still want it to be rooted. + if (builder.Length == 0 || builder[0] != '/') + { + builder.Insert(0, '/'); + } + } + else + { + protocol = string.IsNullOrEmpty(protocol) ? "http" : protocol; + builder.Append(protocol); + + builder.Append("://"); + + host = string.IsNullOrEmpty(host) ? ActionContext.HttpContext.Request.Host.Value : host; + builder.Append(host); + AppendPathAndFragment(builder, pathBase: null, path, fragment: null); + } + + return builder.ToString(); + } + finally + { + // Clear the StringBuilder so that it can reused for the next call. + builder.Clear(); + } + } + + internal static void NormalizeRouteValuesForAction( + string action, + string controller, + RouteValueDictionary values, + RouteValueDictionary ambientValues) + { + object obj = null; + if (action == null) + { + if (!values.ContainsKey("action") && + (ambientValues?.TryGetValue("action", out obj) ?? false)) + { + values["action"] = obj; + } + } + else + { + values["action"] = action; + } + + if (controller == null) + { + if (!values.ContainsKey("controller") && + (ambientValues?.TryGetValue("controller", out obj) ?? false)) + { + values["controller"] = obj; + } + } + else + { + values["controller"] = controller; + } + } + + internal static void NormalizeRouteValuesForPage( + ActionContext context, + string page, + string handler, + RouteValueDictionary values, + RouteValueDictionary ambientValues) + { + object value = null; + if (string.IsNullOrEmpty(page)) + { + if (!values.ContainsKey("page") && + (ambientValues?.TryGetValue("page", out value) ?? false)) + { + values["page"] = value; + } + } + else + { + values["page"] = CalculatePageName(context, ambientValues, page); + } + + if (string.IsNullOrEmpty(handler)) + { + if (!values.ContainsKey("handler") && + (ambientValues?.ContainsKey("handler") ?? false)) + { + // Clear out form action unless it's explicitly specified in the routeValues. + values["handler"] = null; + } + } + else + { + values["handler"] = handler; + } + } + + private static object CalculatePageName(ActionContext context, RouteValueDictionary ambientValues, string pageName) + { + Debug.Assert(pageName.Length > 0); + // Paths not qualified with a leading slash are treated as relative to the current page. + if (pageName[0] != '/') + { + // OK now we should get the best 'normalized' version of the page route value that we can. + string currentPagePath; + if (context != null) + { + currentPagePath = NormalizedRouteValue.GetNormalizedRouteValue(context, "page"); + } + else if (ambientValues != null) + { + currentPagePath = Convert.ToString(ambientValues["page"], CultureInfo.InvariantCulture); + } + else + { + currentPagePath = null; + } + + if (string.IsNullOrEmpty(currentPagePath)) + { + // Disallow the use sibling page routing, a Razor page specific feature, from a non-page action. + // OR - this is a call from LinkGenerator where the HttpContext was not specified. + // + // We can't use a relative path in either case, because we don't know the base path. + throw new InvalidOperationException(Resources.FormatUrlHelper_RelativePagePathIsNotSupported( + pageName, + nameof(LinkGenerator), + nameof(HttpContext))); + } + + return ViewEnginePath.CombinePath(currentPagePath, pageName); + } + + return pageName; + } + + // for unit testing + internal static void AppendPathAndFragment(StringBuilder builder, PathString pathBase, string virtualPath, string fragment) + { + if (!pathBase.HasValue) + { + if (virtualPath.Length == 0) + { + builder.Append("/"); + } + else + { + if (!virtualPath.StartsWith("/", StringComparison.Ordinal)) + { + builder.Append("/"); + } + + builder.Append(virtualPath); + } + } + else + { + if (virtualPath.Length == 0) + { + builder.Append(pathBase.Value); + } + else + { + builder.Append(pathBase.Value); + + if (pathBase.Value.EndsWith("/", StringComparison.Ordinal)) + { + builder.Length--; + } + + if (!virtualPath.StartsWith("/", StringComparison.Ordinal)) + { + builder.Append("/"); + } + + builder.Append(virtualPath); + } + } + + if (!string.IsNullOrEmpty(fragment)) + { + builder.Append("#").Append(fragment); + } + } + + private bool TryFastGenerateUrl( + string protocol, + string host, + string virtualPath, + string fragment, + out string url) + { + var pathBase = ActionContext.HttpContext.Request.PathBase; + url = null; + + if (string.IsNullOrEmpty(protocol) + && string.IsNullOrEmpty(host) + && string.IsNullOrEmpty(fragment) + && !pathBase.HasValue) + { + if (virtualPath.Length == 0) + { + url = "/"; + return true; + } + else if (virtualPath.StartsWith("/", StringComparison.Ordinal)) + { + url = virtualPath; + return true; + } + } + + return false; + } + + private StringBuilder GetStringBuilder() + { + if (_stringBuilder == null) + { + _stringBuilder = new StringBuilder(); + } + + return _stringBuilder; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperFactory.cs index f9b8006850..dfe16c2421 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperFactory.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/Routing/UrlHelperFactory.cs @@ -3,7 +3,11 @@ using System; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Mvc.Routing { @@ -37,16 +41,32 @@ namespace Microsoft.AspNetCore.Mvc.Routing } // Perf: Create only one UrlHelper per context - object value; - if (httpContext.Items.TryGetValue(typeof(IUrlHelper), out value) && value is IUrlHelper) + if (httpContext.Items.TryGetValue(typeof(IUrlHelper), out var value) && value is IUrlHelper) { return (IUrlHelper)value; } - var urlHelper = new UrlHelper(context); + IUrlHelper urlHelper; + var endpointFeature = httpContext.Features.Get(); + if (endpointFeature?.Endpoint != null) + { + var services = httpContext.RequestServices; + var linkGenerator = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + + urlHelper = new EndpointRoutingUrlHelper( + context, + linkGenerator, + logger); + } + else + { + urlHelper = new UrlHelper(context); + } + httpContext.Items[typeof(IUrlHelper)] = urlHelper; return urlHelper; } } -} +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ServiceFilterAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ServiceFilterAttribute.cs index 525d1a82bb..9e16cb4715 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ServiceFilterAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ServiceFilterAttribute.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; @@ -58,14 +57,11 @@ namespace Microsoft.AspNetCore.Mvc throw new ArgumentNullException(nameof(serviceProvider)); } - var service = serviceProvider.GetRequiredService(ServiceType); - - var filter = service as IFilterMetadata; - if (filter == null) + var filter = (IFilterMetadata)serviceProvider.GetRequiredService(ServiceType); + if (filter is IFilterFactory filterFactory) { - throw new InvalidOperationException(Resources.FormatFilterFactoryAttribute_TypeMustImplementIFilter( - typeof(ServiceFilterAttribute).Name, - typeof(IFilterMetadata).Name)); + // Unwrap filter factories + filter = filterFactory.CreateInstance(serviceProvider); } return filter; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/StatusCodeResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/StatusCodeResult.cs index 8db690c94e..70de9cdafe 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/StatusCodeResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/StatusCodeResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -12,14 +13,14 @@ namespace Microsoft.AspNetCore.Mvc /// Represents an that when executed will /// produce an HTTP response with the given response status code. /// - public class StatusCodeResult : ActionResult + public class StatusCodeResult : ActionResult, IClientErrorActionResult { /// /// Initializes a new instance of the class /// with the given . /// /// The HTTP status code of the response. - public StatusCodeResult(int statusCode) + public StatusCodeResult([ActionResultStatusCode] int statusCode) { StatusCode = statusCode; } @@ -29,6 +30,8 @@ namespace Microsoft.AspNetCore.Mvc /// public int StatusCode { get; } + int? IStatusCodeActionResult.StatusCode => StatusCode; + /// public override void ExecuteResult(ActionContext context) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/TypeFilterAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/TypeFilterAttribute.cs index bc5c19802f..a2048512a6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/TypeFilterAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/TypeFilterAttribute.cs @@ -73,11 +73,17 @@ namespace Microsoft.AspNetCore.Mvc if (_factory == null) { var argumentTypes = Arguments?.Select(a => a.GetType())?.ToArray(); - _factory = ActivatorUtilities.CreateFactory(ImplementationType, argumentTypes ?? Type.EmptyTypes); } - return (IFilterMetadata)_factory(serviceProvider, Arguments); + var filter = (IFilterMetadata)_factory(serviceProvider, Arguments); + if (filter is IFilterFactory filterFactory) + { + // Unwrap filter factories + filter = filterFactory.CreateInstance(serviceProvider); + } + + return filter; } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnauthorizedObjectResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnauthorizedObjectResult.cs new file mode 100644 index 0000000000..f4a9a4556f --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnauthorizedObjectResult.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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// An that when executed will produce a Unauthorized (401) response. + /// + [DefaultStatusCode(DefaultStatusCode)] + public class UnauthorizedObjectResult : ObjectResult + { + private const int DefaultStatusCode = StatusCodes.Status401Unauthorized; + + /// + /// Creates a new instance. + /// + public UnauthorizedObjectResult([ActionResultObjectValue] object value) : base(value) + { + StatusCode = DefaultStatusCode; + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnauthorizedResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnauthorizedResult.cs index a92106ec84..7e3bc7bb7f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnauthorizedResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnauthorizedResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { @@ -9,12 +10,15 @@ namespace Microsoft.AspNetCore.Mvc /// Represents an that when /// executed will produce an Unauthorized (401) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class UnauthorizedResult : StatusCodeResult { + private const int DefaultStatusCode = StatusCodes.Status401Unauthorized; + /// /// Creates a new instance. /// - public UnauthorizedResult() : base(StatusCodes.Status401Unauthorized) + public UnauthorizedResult() : base(DefaultStatusCode) { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnprocessableEntityObjectResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnprocessableEntityObjectResult.cs index 002d9d97af..dcee40ef05 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnprocessableEntityObjectResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnprocessableEntityObjectResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc @@ -9,13 +10,16 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that when executed will produce a Unprocessable Entity (422) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class UnprocessableEntityObjectResult : ObjectResult { + private const int DefaultStatusCode = StatusCodes.Status422UnprocessableEntity; + /// /// Creates a new instance. /// /// containing the validation errors. - public UnprocessableEntityObjectResult(ModelStateDictionary modelState) + public UnprocessableEntityObjectResult([ActionResultObjectValue] ModelStateDictionary modelState) : this(new SerializableError(modelState)) { } @@ -24,10 +28,10 @@ namespace Microsoft.AspNetCore.Mvc /// Creates a new instance. /// /// Contains errors to be returned to the client. - public UnprocessableEntityObjectResult(object error) + public UnprocessableEntityObjectResult([ActionResultObjectValue] object error) : base(error) { - StatusCode = StatusCodes.Status422UnprocessableEntity; + StatusCode = DefaultStatusCode; } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnprocessableEntityResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnprocessableEntityResult.cs index 0851057499..d82cf642a7 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnprocessableEntityResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnprocessableEntityResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { @@ -9,13 +10,16 @@ namespace Microsoft.AspNetCore.Mvc /// A that when /// executed will produce a Unprocessable Entity (422) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class UnprocessableEntityResult : StatusCodeResult { + private const int DefaultStatusCode = StatusCodes.Status422UnprocessableEntity; + /// /// Creates a new instance. /// public UnprocessableEntityResult() - : base(StatusCodes.Status422UnprocessableEntity) + : base(DefaultStatusCode) { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnsupportedMediaTypeResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnsupportedMediaTypeResult.cs index 445ded67d0..175c886874 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnsupportedMediaTypeResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UnsupportedMediaTypeResult.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc { @@ -9,12 +10,15 @@ namespace Microsoft.AspNetCore.Mvc /// A that when /// executed will produce a UnsupportedMediaType (415) response. /// + [DefaultStatusCode(DefaultStatusCode)] public class UnsupportedMediaTypeResult : StatusCodeResult { + private const int DefaultStatusCode = StatusCodes.Status415UnsupportedMediaType; + /// /// Creates a new instance of . /// - public UnsupportedMediaTypeResult() : base(StatusCodes.Status415UnsupportedMediaType) + public UnsupportedMediaTypeResult() : base(DefaultStatusCode) { } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs index 93720a5826..40c673da8c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs @@ -2,9 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Diagnostics; -using Microsoft.AspNetCore.Mvc.Core; -using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; @@ -108,7 +106,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// Generates a URL with an absolute path for an action method, which contains the specified /// name, name, route , and - /// to use. + /// to use. See the remarks section for important security information. /// /// The . /// The name of the action method. @@ -116,6 +114,14 @@ namespace Microsoft.AspNetCore.Mvc /// An object that contains route values. /// The protocol for the URL, such as "http" or "https". /// The generated URL. + /// + /// + /// This method uses the value of to populate the host section of the generated URI. + /// Relying on the value of the current request can allow untrusted input to influence the resulting URI unless + /// the Host header has been validated. See the deployment documentation for instructions on how to properly + /// validate the Host header in your deployment environment. + /// + /// public static string Action( this IUrlHelper helper, string action, @@ -136,7 +142,7 @@ namespace Microsoft.AspNetCore.Mvc /// name, name, route , /// to use, and name. /// Generates an absolute URL if the and are - /// non-null. + /// non-null. See the remarks section for important security information. /// /// The . /// The name of the action method. @@ -145,6 +151,14 @@ namespace Microsoft.AspNetCore.Mvc /// The protocol for the URL, such as "http" or "https". /// The host name for the URL. /// The generated URL. + /// + /// + /// The value of should be a trusted value. Relying on the value of the current request + /// can allow untrusted input to influence the resulting URI unless the Host header has been validated. + /// See the deployment documentation for instructions on how to properly validate the Host header in + /// your deployment environment. + /// + /// public static string Action( this IUrlHelper helper, string action, @@ -166,7 +180,7 @@ namespace Microsoft.AspNetCore.Mvc /// name, name, route , /// to use, name, and . /// Generates an absolute URL if the and are - /// non-null. + /// non-null. See the remarks section for important security information. /// /// The . /// The name of the action method. @@ -176,6 +190,14 @@ namespace Microsoft.AspNetCore.Mvc /// The host name for the URL. /// The fragment for the URL. /// The generated URL. + /// + /// + /// The value of should be a trusted value. Relying on the value of the current request + /// can allow untrusted input to influence the resulting URI unless the Host header has been validated. + /// See the deployment documentation for instructions on how to properly validate the Host header in + /// your deployment environment. + /// + /// public static string Action( this IUrlHelper helper, string action, @@ -253,13 +275,22 @@ namespace Microsoft.AspNetCore.Mvc /// /// Generates a URL with an absolute path for the specified route and route - /// , which contains the specified to use. + /// , which contains the specified to use. See the + /// remarks section for important security information. /// /// The . /// The name of the route that is used to generate URL. /// An object that contains route values. /// The protocol for the URL, such as "http" or "https". /// The generated URL. + /// + /// + /// This method uses the value of to populate the host section of the generated URI. + /// Relying on the value of the current request can allow untrusted input to influence the resulting URI unless + /// the Host header has been validated. See the deployment documentation for instructions on how to properly + /// validate the Host header in your deployment environment. + /// + /// public static string RouteUrl( this IUrlHelper helper, string routeName, @@ -279,6 +310,7 @@ namespace Microsoft.AspNetCore.Mvc /// , which contains the specified to use and /// name. Generates an absolute URL if /// and are non-null. + /// See the remarks section for important security information. /// /// The . /// The name of the route that is used to generate URL. @@ -286,6 +318,14 @@ namespace Microsoft.AspNetCore.Mvc /// The protocol for the URL, such as "http" or "https". /// The host name for the URL. /// The generated URL. + /// + /// + /// The value of should be a trusted value. Relying on the value of the current request + /// can allow untrusted input to influence the resulting URI unless the Host header has been validated. + /// See the deployment documentation for instructions on how to properly validate the Host header in + /// your deployment environment. + /// + /// public static string RouteUrl( this IUrlHelper helper, string routeName, @@ -306,6 +346,7 @@ namespace Microsoft.AspNetCore.Mvc /// , which contains the specified to use, /// name and . Generates an absolute URL if /// and are non-null. + /// See the remarks section for important security information. /// /// The . /// The name of the route that is used to generate URL. @@ -314,6 +355,14 @@ namespace Microsoft.AspNetCore.Mvc /// The host name for the URL. /// The fragment for the URL. /// The generated URL. + /// + /// + /// The value of should be a trusted value. Relying on the value of the current request + /// can allow untrusted input to influence the resulting URI unless the Host header has been validated. + /// See the deployment documentation for instructions on how to properly validate the Host header in + /// your deployment environment. + /// + /// public static string RouteUrl( this IUrlHelper helper, string routeName, @@ -338,7 +387,7 @@ namespace Microsoft.AspNetCore.Mvc } /// - /// Generates a URL with an absolute path for the specified . + /// Generates a URL with a relative path for the specified . /// /// The . /// The page name to generate the url for. @@ -347,7 +396,7 @@ namespace Microsoft.AspNetCore.Mvc => Page(urlHelper, pageName, values: null); /// - /// Generates a URL with an absolute path for the specified . + /// Generates a URL with a relative path for the specified . /// /// The . /// The page name to generate the url for. @@ -357,7 +406,7 @@ namespace Microsoft.AspNetCore.Mvc => Page(urlHelper, pageName, pageHandler, values: null); /// - /// Generates a URL with an absolute path for the specified . + /// Generates a URL with a relative path for the specified . /// /// The . /// The page name to generate the url for. @@ -367,7 +416,7 @@ namespace Microsoft.AspNetCore.Mvc => Page(urlHelper, pageName, pageHandler: null, values: values); /// - /// Generates a URL with an absolute path for the specified . + /// Generates a URL with a relative path for the specified . /// /// The . /// The page name to generate the url for. @@ -382,7 +431,8 @@ namespace Microsoft.AspNetCore.Mvc => Page(urlHelper, pageName, pageHandler, values, protocol: null); /// - /// Generates a URL with an absolute path for the specified . + /// Generates a URL with an absolute path for the specified . See the remarks section + /// for important security information. /// /// The . /// The page name to generate the url for. @@ -390,6 +440,14 @@ namespace Microsoft.AspNetCore.Mvc /// An object that contains route values. /// The protocol for the URL, such as "http" or "https". /// The generated URL. + /// + /// + /// This method uses the value of to populate the host section of the generated URI. + /// Relying on the value of the current request can allow untrusted input to influence the resulting URI unless + /// the Host header has been validated. See the deployment documentation for instructions on how to properly + /// validate the Host header in your deployment environment. + /// + /// public static string Page( this IUrlHelper urlHelper, string pageName, @@ -399,7 +457,8 @@ namespace Microsoft.AspNetCore.Mvc => Page(urlHelper, pageName, pageHandler, values, protocol, host: null, fragment: null); /// - /// Generates a URL with an absolute path for the specified . + /// Generates a URL with an absolute path for the specified . See the remarks section for + /// important security information. /// /// The . /// The page name to generate the url for. @@ -408,6 +467,14 @@ namespace Microsoft.AspNetCore.Mvc /// The protocol for the URL, such as "http" or "https". /// The host name for the URL. /// The generated URL. + /// + /// + /// The value of should be a trusted value. Relying on the value of the current request + /// can allow untrusted input to influence the resulting URI unless the Host header has been validated. + /// See the deployment documentation for instructions on how to properly validate the Host header in + /// your deployment environment. + /// + /// public static string Page( this IUrlHelper urlHelper, string pageName, @@ -418,7 +485,8 @@ namespace Microsoft.AspNetCore.Mvc => Page(urlHelper, pageName, pageHandler, values, protocol, host, fragment: null); /// - /// Generates a URL with an absolute path for the specified . + /// Generates a URL with an absolute path for the specified . See the remarks section for + /// important security information. /// /// The . /// The page name to generate the url for. @@ -428,6 +496,14 @@ namespace Microsoft.AspNetCore.Mvc /// The host name for the URL. /// The fragment for the URL. /// The generated URL. + /// + /// + /// The value of should be a trusted value. Relying on the value of the current request + /// can allow untrusted input to influence the resulting URI unless the Host header has been validated. + /// See the deployment documentation for instructions on how to properly validate the Host header in + /// your deployment environment. + /// + /// public static string Page( this IUrlHelper urlHelper, string pageName, @@ -444,32 +520,8 @@ namespace Microsoft.AspNetCore.Mvc var routeValues = new RouteValueDictionary(values); var ambientValues = urlHelper.ActionContext.RouteData.Values; - if (string.IsNullOrEmpty(pageName)) - { - if (!routeValues.ContainsKey("page") && - ambientValues.TryGetValue("page", out var value)) - { - routeValues["page"] = value; - } - } - else - { - routeValues["page"] = CalculatePageName(urlHelper.ActionContext, pageName); - } - if (string.IsNullOrEmpty(pageHandler)) - { - if (!routeValues.ContainsKey("handler") && - ambientValues.TryGetValue("handler", out var handler)) - { - // Clear out formaction unless it's explicitly specified in the routeValues. - routeValues["handler"] = null; - } - } - else - { - routeValues["handler"] = pageHandler; - } + UrlHelperBase.NormalizeRouteValuesForPage(urlHelper.ActionContext, pageName, pageHandler, routeValues, ambientValues); return urlHelper.RouteUrl( routeName: null, @@ -478,24 +530,5 @@ namespace Microsoft.AspNetCore.Mvc host: host, fragment: fragment); } - - private static object CalculatePageName(ActionContext actionContext, string pageName) - { - Debug.Assert(pageName.Length > 0); - // Paths not qualified with a leading slash are treated as relative to the current page. - if (pageName[0] != '/') - { - var currentPagePath = NormalizedRouteValue.GetNormalizedRouteValue(actionContext, "page"); - if (string.IsNullOrEmpty(currentPagePath)) - { - // Disallow the use sibling page routing, a Razor page specific feature, from a non-page action. - throw new InvalidOperationException(Resources.FormatUrlHelper_RelativePagePathIsNotSupported(pageName)); - } - - return ViewEnginePath.CombinePath(currentPagePath, pageName); - } - - return pageName; - } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ValidationProblemDetails.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ValidationProblemDetails.cs index b27e790a90..56fdd49e9d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/ValidationProblemDetails.cs +++ b/src/Mvc/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 { @@ -14,13 +15,17 @@ namespace Microsoft.AspNetCore.Mvc public class ValidationProblemDetails : ProblemDetails { /// - /// Intializes a new instance of . + /// Initializes a new instance of . /// public ValidationProblemDetails() { Title = Resources.ValidationProblemDescription_Title; } + /// + /// Initializes a new instance of using the specified . + /// + /// containing the validation errors. public ValidationProblemDetails(ModelStateDictionary modelState) : this() { @@ -62,8 +67,24 @@ namespace Microsoft.AspNetCore.Mvc } /// - /// Gets or sets the validation errors associated with this instance of . + /// Initializes a new instance of using the specified . /// + /// The validation errors. + public ValidationProblemDetails(IDictionary errors) + : this() + { + if (errors == null) + { + throw new ArgumentNullException(nameof(errors)); + } + + Errors = new Dictionary(errors, StringComparer.Ordinal); + } + + /// + /// Gets the validation errors associated with this instance of . + /// + [JsonProperty(PropertyName = "errors")] public IDictionary Errors { get; } = new Dictionary(StringComparer.Ordinal); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/breakingchanges.netcore.json b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/breakingchanges.netcore.json new file mode 100644 index 0000000000..3fd0e6ddff --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Core/breakingchanges.netcore.json @@ -0,0 +1,6 @@ +[ + { + "TypeId": "public class Microsoft.AspNetCore.Mvc.ApiControllerAttribute : Microsoft.AspNetCore.Mvc.ControllerAttribute, Microsoft.AspNetCore.Mvc.Internal.IApiBehaviorMetadata", + "Kind": "Removal" + } +] \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Internal/CorsApplicationModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Internal/CorsApplicationModelProvider.cs index 4410412e28..352a8b4ee6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Internal/CorsApplicationModelProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Internal/CorsApplicationModelProvider.cs @@ -6,7 +6,7 @@ using System.Linq; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Internal; -using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Mvc.Cors.Internal { @@ -67,17 +67,18 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal if (isCorsEnabledGlobally || corsOnController || corsOnAction) { - UpdateHttpMethodActionConstraint(actionModel); + UpdateActionToAcceptCorsPreflight(actionModel); } } } } - private static void UpdateHttpMethodActionConstraint(ActionModel actionModel) + private static void UpdateActionToAcceptCorsPreflight(ActionModel actionModel) { for (var i = 0; i < actionModel.Selectors.Count; i++) { var selectorModel = actionModel.Selectors[i]; + for (var j = 0; j < selectorModel.ActionConstraints.Count; j++) { if (selectorModel.ActionConstraints[j] is HttpMethodActionConstraint httpConstraint) @@ -85,6 +86,14 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal selectorModel.ActionConstraints[j] = new CorsHttpMethodActionConstraint(httpConstraint); } } + + for (int j = 0; j < selectorModel.EndpointMetadata.Count; j++) + { + if (selectorModel.EndpointMetadata[j] is HttpMethodMetadata httpMethodMetadata) + { + selectorModel.EndpointMetadata[j] = new HttpMethodMetadata(httpMethodMetadata.HttpMethods, true); + } + } } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Internal/CorsHttpMethodActionConstraint.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Internal/CorsHttpMethodActionConstraint.cs index 71ba2d696c..de3ff06468 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Internal/CorsHttpMethodActionConstraint.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Internal/CorsHttpMethodActionConstraint.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc.Internal; namespace Microsoft.AspNetCore.Mvc.Cors.Internal { + // Don't casually change the name of this. We reference the full type name in ActionConstraintCache. public class CorsHttpMethodActionConstraint : HttpMethodActionConstraint { private readonly string OriginHeader = "Origin"; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Microsoft.AspNetCore.Mvc.Cors.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Microsoft.AspNetCore.Mvc.Cors.csproj index f6056e62e5..379eca0738 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Microsoft.AspNetCore.Mvc.Cors.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Cors/Microsoft.AspNetCore.Mvc.Cors.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC cross-origin resource sharing (CORS) features. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsModelValidatorProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/DataAnnotationsModelValidatorProvider.cs similarity index 84% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsModelValidatorProvider.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/DataAnnotationsModelValidatorProvider.cs index 5ece07f042..b6266958c3 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsModelValidatorProvider.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsLocalizationServices.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsLocalizationServices.cs index f54cd9dd5d..3ef1fde707 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsLocalizationServices.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsLocalizationServices.cs @@ -27,6 +27,9 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal , MvcDataAnnotationsLocalizationOptionsSetup>()); } + + services.TryAddEnumerable( + ServiceDescriptor.Transient, MvcDataAnnotationsLocalizationConfigureCompatibilityOptions>()); } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsMetadataProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsMetadataProvider.cs index 0248cee5c5..d0a2875c5a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsMetadataProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsMetadataProvider.cs @@ -182,7 +182,19 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var groupedDisplayNamesAndValues = new List>(); var namesAndValues = new Dictionary(); - var enumLocalizer = _stringLocalizerFactory?.Create(underlyingType); + + IStringLocalizer enumLocalizer = null; + if (_localizationOptions.AllowDataAnnotationsLocalizationForEnumDisplayAttributes) + { + if (_stringLocalizerFactory != null && _localizationOptions.DataAnnotationLocalizerProvider != null) + { + enumLocalizer = _localizationOptions.DataAnnotationLocalizerProvider(underlyingType, _stringLocalizerFactory); + } + } + else + { + enumLocalizer = _stringLocalizerFactory?.Create(underlyingType); + } var enumFields = Enum.GetNames(underlyingType) .Select(name => underlyingType.GetField(name)) @@ -305,15 +317,30 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal throw new ArgumentNullException(nameof(context)); } + var attributes = new List(context.Attributes.Count); + + for (var i = 0; i < context.Attributes.Count; i++) + { + var attribute = context.Attributes[i]; + if (attribute is ValidationProviderAttribute validationProviderAttribute) + { + attributes.AddRange(validationProviderAttribute.GetValidationAttributes()); + } + else + { + attributes.Add(attribute); + } + } + // RequiredAttribute marks a property as required by validation - this means that it // must have a non-null value on the model during validation. - var requiredAttribute = context.Attributes.OfType().FirstOrDefault(); + var requiredAttribute = attributes.OfType().FirstOrDefault(); if (requiredAttribute != null) { context.ValidationMetadata.IsRequired = true; } - foreach (var attribute in context.Attributes.OfType()) + foreach (var attribute in attributes.OfType()) { // If another provider has already added this attribute, do not repeat it. // This will prevent attributes like RemoteAttribute (which implement ValidationAttribute and diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Microsoft.AspNetCore.Mvc.DataAnnotations.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Microsoft.AspNetCore.Mvc.DataAnnotations.csproj index 336474aae0..33df42b291 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Microsoft.AspNetCore.Mvc.DataAnnotations.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Microsoft.AspNetCore.Mvc.DataAnnotations.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC metadata and validation system using System.ComponentModel.DataAnnotations. @@ -11,8 +11,6 @@ - - diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/MvcDataAnnotationsLocalizationConfigureCompatibilityOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/MvcDataAnnotationsLocalizationConfigureCompatibilityOptions.cs new file mode 100644 index 0000000000..e3eab734b7 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/MvcDataAnnotationsLocalizationConfigureCompatibilityOptions.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc.DataAnnotations +{ + internal class MvcDataAnnotationsLocalizationConfigureCompatibilityOptions : ConfigureCompatibilityOptions + { + public MvcDataAnnotationsLocalizationConfigureCompatibilityOptions( + ILoggerFactory loggerFactory, + IOptions compatibilityOptions) + : base(loggerFactory, compatibilityOptions) + { + } + + protected override IReadOnlyDictionary DefaultValues + { + get + { + var values = new Dictionary(); + + if (Version >= CompatibilityVersion.Version_2_2) + { + values[nameof(MvcDataAnnotationsLocalizationOptions.AllowDataAnnotationsLocalizationForEnumDisplayAttributes)] = true; + } + + return values; + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/MvcDataAnnotationsLocalizationOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/MvcDataAnnotationsLocalizationOptions.cs index 99d8742296..911c7139c6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/MvcDataAnnotationsLocalizationOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/MvcDataAnnotationsLocalizationOptions.cs @@ -2,6 +2,9 @@ // 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 Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Localization; namespace Microsoft.AspNetCore.Mvc.DataAnnotations @@ -9,11 +12,60 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations /// /// Provides programmatic configuration for DataAnnotations localization in the MVC framework. /// - public class MvcDataAnnotationsLocalizationOptions + public class MvcDataAnnotationsLocalizationOptions : IEnumerable { + private readonly CompatibilitySwitch _allowDataAnnotationsLocalizationForEnumDisplayAttributes; + private readonly ICompatibilitySwitch[] _switches; + /// /// The delegate to invoke for creating . /// public Func DataAnnotationLocalizerProvider; + + /// + /// Instantiates a new instance of the class. + /// + public MvcDataAnnotationsLocalizationOptions() + { + _allowDataAnnotationsLocalizationForEnumDisplayAttributes = new CompatibilitySwitch(nameof(AllowDataAnnotationsLocalizationForEnumDisplayAttributes)); + + _switches = new ICompatibilitySwitch[] + { + _allowDataAnnotationsLocalizationForEnumDisplayAttributes + }; + } + + /// + /// Gets or sets a value that determines if should be used while localizing types. + /// If set to true will be used in localizing types. + /// If set to false the localization will search for values in resource files for the . + /// + /// + /// + /// 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 then + /// this setting will have the value false unless explicitly configured. + /// + /// + /// If the application's compatibility version is set to or + /// higher then this setting will have the value true unless explicitly configured. + /// + /// + public bool AllowDataAnnotationsLocalizationForEnumDisplayAttributes + { + get => _allowDataAnnotationsLocalizationForEnumDisplayAttributes.Value; + set => _allowDataAnnotationsLocalizationForEnumDisplayAttributes.Value = value; + } + + public IEnumerator GetEnumerator() => ((IEnumerable)_switches).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/AssemblyInfo.cs index c375950300..a0584f4754 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/AssemblyInfo.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/ValidationProviderAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/ValidationProviderAttribute.cs new file mode 100644 index 0000000000..c745850b64 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.DataAnnotations/ValidationProviderAttribute.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.AspNetCore.Mvc.DataAnnotations +{ + /// + /// Abstract class for grouping attributes of type into + /// one + /// + public abstract class ValidationProviderAttribute : Attribute + { + /// + /// Gets instances associated with this attribute. + /// + /// Sequence of associated with this attribute. + public abstract IEnumerable GetValidationAttributes(); + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/JsonResultExecutor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/JsonResultExecutor.cs index f7e2fc09f2..332d1bdc59 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/JsonResultExecutor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/JsonResultExecutor.cs @@ -86,7 +86,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal /// The . /// The . /// A which will complete when writing has completed. - public virtual Task ExecuteAsync(ActionContext context, JsonResult result) + public virtual async Task ExecuteAsync(ActionContext context, JsonResult result) { if (context == null) { @@ -128,9 +128,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal var jsonSerializer = JsonSerializer.Create(serializerSettings); jsonSerializer.Serialize(jsonWriter, result.Value); } - } - return Task.CompletedTask; + // Perf: call FlushAsync to call WriteAsync on the stream with any content left in the TextWriter's + // buffers. This is better than just letting dispose handle it (which would result in a synchronous write). + await writer.FlushAsync(); + } } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonResult.cs index 4380224057..a4a576a83f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonResult.cs @@ -4,6 +4,7 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Formatters.Json.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; @@ -12,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// An action result which formats the given object as JSON. /// - public class JsonResult : ActionResult + public class JsonResult : ActionResult, IStatusCodeActionResult { /// /// Creates a new with the given . diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonSerializerSettingsProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonSerializerSettingsProvider.cs index 8b6761559d..00793a13e6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonSerializerSettingsProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonSerializerSettingsProvider.cs @@ -15,10 +15,12 @@ namespace Microsoft.AspNetCore.Mvc.Formatters // return shared resolver by default for perf so slow reflection logic is cached once // developers can set their own resolver after the settings are returned if desired - private static readonly DefaultContractResolver SharedContractResolver = new DefaultContractResolver + private static readonly DefaultContractResolver SharedContractResolver; + + static JsonSerializerSettingsProvider() { - NamingStrategy = new CamelCaseNamingStrategy(), - }; + SharedContractResolver = CreateContractResolver(); + } /// /// Creates default . @@ -41,5 +43,14 @@ namespace Microsoft.AspNetCore.Mvc.Formatters TypeNameHandling = TypeNameHandling.None, }; } + + // To enable unit testing + internal static DefaultContractResolver CreateContractResolver() + { + return new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy(), + }; + } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Microsoft.AspNetCore.Mvc.Formatters.Json.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Microsoft.AspNetCore.Mvc.Formatters.Json.csproj index 608e370257..45749b70ec 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Microsoft.AspNetCore.Mvc.Formatters.Json.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Microsoft.AspNetCore.Mvc.Formatters.Json.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC formatters for JSON input and output and for JSON PATCH input using Json.NET. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptions.cs index f27cf0b00c..28df9825b5 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptions.cs @@ -32,7 +32,7 @@ namespace Microsoft.AspNetCore.Mvc } /// - /// Gets or sets a flag to determine whether error messsages from JSON deserialization by the + /// Gets or sets a flag to determine whether error messages from JSON deserialization by the /// will be added to the . The default /// value is false, meaning that a generic error message will be used instead. /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptionsExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptionsExtensions.cs new file mode 100644 index 0000000000..876325d281 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptionsExtensions.cs @@ -0,0 +1,88 @@ +// 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.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class MvcJsonOptionsExtensions + { + /// + /// Configures the casing behavior of JSON serialization to use camel case for property names, + /// and optionally for dynamic types and dictionary keys. + /// + /// + /// This method modifies . + /// + /// + /// If true will camel case dictionary keys and properties of dynamic objects. + /// with camel case settings. + public static MvcJsonOptions UseCamelCasing(this MvcJsonOptions options, bool processDictionaryKeys) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (options.SerializerSettings.ContractResolver is DefaultContractResolver resolver) + { + resolver.NamingStrategy = new CamelCaseNamingStrategy + { + ProcessDictionaryKeys = processDictionaryKeys + }; + } + else + { + if (options.SerializerSettings.ContractResolver == null) + { + throw new InvalidOperationException(Resources.FormatContractResolverCannotBeNull(nameof(JsonSerializerSettings.ContractResolver))); + } + + var contractResolverName = options.SerializerSettings.ContractResolver.GetType().Name; + throw new InvalidOperationException( + Resources.FormatInvalidContractResolverForJsonCasingConfiguration(contractResolverName, nameof(DefaultContractResolver))); + } + + return options; + } + + /// + /// Configures the casing behavior of JSON serialization to use the member's casing for property names, + /// properties of dynamic types, and dictionary keys. + /// + /// + /// This method modifies . + /// + /// + /// with member casing settings. + public static MvcJsonOptions UseMemberCasing(this MvcJsonOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (options.SerializerSettings.ContractResolver is DefaultContractResolver resolver) + { + resolver.NamingStrategy = new DefaultNamingStrategy(); + } + else + { + if (options.SerializerSettings.ContractResolver == null) + { + throw new InvalidOperationException(Resources.FormatContractResolverCannotBeNull(nameof(JsonSerializerSettings.ContractResolver))); + } + + var contractResolverName = options.SerializerSettings.ContractResolver.GetType().Name; + throw new InvalidOperationException( + Resources.FormatInvalidContractResolverForJsonCasingConfiguration(contractResolverName, nameof(DefaultContractResolver))); + } + + return options; + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7982959233 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Json.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/Resources.Designer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..b552db2345 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/Resources.Designer.cs @@ -0,0 +1,58 @@ +// +namespace Microsoft.AspNetCore.Mvc.Formatters.Json +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.AspNetCore.Mvc.Formatters.Json.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// {0} cannot be null. + /// + internal static string ContractResolverCannotBeNull + { + get => GetString("ContractResolverCannotBeNull"); + } + + /// + /// {0} cannot be null. + /// + internal static string FormatContractResolverCannotBeNull(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("ContractResolverCannotBeNull"), p0); + + /// + /// Cannot configure JSON casing behavior on '{0}' contract resolver. The supported contract resolver is {1}. + /// + internal static string InvalidContractResolverForJsonCasingConfiguration + { + get => GetString("InvalidContractResolverForJsonCasingConfiguration"); + } + + /// + /// Cannot configure JSON casing behavior on '{0}' contract resolver. The supported contract resolver is {1}. + /// + internal static string FormatInvalidContractResolverForJsonCasingConfiguration(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("InvalidContractResolverForJsonCasingConfiguration"), 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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Resources.resx b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Resources.resx new file mode 100644 index 0000000000..729545d687 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Resources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + {0} cannot be null. + + + Cannot configure JSON casing behavior on '{0}' contract resolver. The supported contract resolver is {1}. + + \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcBuilderExtensions.cs index 4210fbe0bc..17bce01d5e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcBuilderExtensions.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcCoreBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcCoreBuilderExtensions.cs index 5e14f41647..9608d3fc18 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/DependencyInjection/MvcXmlMvcCoreBuilderExtensions.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Microsoft.AspNetCore.Mvc.Formatters.Xml.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Microsoft.AspNetCore.Mvc.Formatters.Xml.csproj index b716f5cbbb..18dcb55e3a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Microsoft.AspNetCore.Mvc.Formatters.Xml.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Microsoft.AspNetCore.Mvc.Formatters.Xml.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC formatters for XML input and output using DataContractSerializer and XmlSerializer. @@ -10,8 +10,5 @@ - - - diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptions.cs new file mode 100644 index 0000000000..d8ada74590 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptionsConfigureCompatibilityOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/MvcXmlOptionsConfigureCompatibilityOptions.cs new file mode 100644 index 0000000000..c5d1d3e340 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetails21Wrapper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetails21Wrapper.cs new file mode 100644 index 0000000000..9a4dcf0bd7 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs new file mode 100644 index 0000000000..f7d2056806 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs @@ -0,0 +1,189 @@ +// 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("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 + /// + protected static readonly string EmptyKey = SerializableErrorWrapper.EmptyKey; + + /// + /// Initializes a new instance of . + /// + public ProblemDetailsWrapper() + : this(new ProblemDetails()) + { + } + + /// + /// Initializes a new instance of . + /// + public ProblemDetailsWrapper(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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapperProviderFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapperProviderFactory.cs new file mode 100644 index 0000000000..9b93b86c6b --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..61797e230b --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Xml.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/SerializableErrorWrapper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/SerializableErrorWrapper.cs index d247b2fab9..8a72825a80 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/SerializableErrorWrapper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/SerializableErrorWrapper.cs @@ -14,6 +14,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml [XmlRoot("Error")] public sealed class SerializableErrorWrapper : IXmlSerializable, IUnwrappable { + // Element name used when ModelStateEntry's Key is empty. Dash in element name should avoid collisions with + // other ModelState entries because the character is not legal in an expression name. + internal static readonly string EmptyKey = "MVC-Empty"; + // Note: XmlSerializer requires to have default constructor public SerializableErrorWrapper() { @@ -63,6 +67,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml { var key = XmlConvert.DecodeName(reader.LocalName); var value = reader.ReadInnerXml(); + if (string.Equals(EmptyKey, key, StringComparison.Ordinal)) + { + key = string.Empty; + } SerializableError.Add(key, value); reader.MoveToContent(); @@ -81,6 +89,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml { var key = keyValuePair.Key; var value = keyValuePair.Value; + if (string.IsNullOrEmpty(key)) + { + key = EmptyKey; + } + writer.WriteStartElement(XmlConvert.EncodeLocalName(key)); if (value != null) { @@ -102,4 +115,4 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml return SerializableError; } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetails21Wrapper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetails21Wrapper.cs new file mode 100644 index 0000000000..e138c0f3d1 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs new file mode 100644 index 0000000000..a454fb5d0a --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs @@ -0,0 +1,126 @@ +// 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("problem", Namespace = "urn:ietf:rfc:7807")] + public class ValidationProblemDetailsWrapper : ProblemDetailsWrapper, IUnwrappable + { + private static readonly string ErrorKey = "MVC-Errors"; + + /// + /// Initializes a new instance of . + /// + public ValidationProblemDetailsWrapper() + : this(new ValidationProblemDetails()) + { + } + + /// + /// Initializes a new instance of for the specified + /// . + /// + /// The . + public ValidationProblemDetailsWrapper(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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs index 7916715002..f0954c36a4 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs @@ -46,8 +46,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters _serializerSettings = new DataContractSerializerSettings(); - WrapperProviderFactories = new List(); - WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); + WrapperProviderFactories = new List + { + new SerializableErrorWrapperProviderFactory(), + }; } /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerMvcOptionsSetup.cs similarity index 57% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerMvcOptionsSetup.cs index c1cd77f685..eba6b9d5fd 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlDataContractSerializerMvcOptionsSetup.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs index a0f374cc27..9b89042636 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs @@ -76,9 +76,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters _serializerSettings = new DataContractSerializerSettings(); - WrapperProviderFactories = new List(); + WrapperProviderFactories = new List() + { + new SerializableErrorWrapperProviderFactory(), + }; WrapperProviderFactories.Add(new EnumerableWrapperProviderFactory(WrapperProviderFactories)); - WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); _logger = loggerFactory?.CreateLogger(GetType()); } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs index 6708c81257..4d530a015b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs @@ -43,8 +43,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedMediaTypes.Add(MediaTypeHeaderValues.TextXml); SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyXmlSyntax); - WrapperProviderFactories = new List(); - WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); + WrapperProviderFactories = new List + { + new SerializableErrorWrapperProviderFactory(), + }; } /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerMvcOptionsSetup.cs similarity index 51% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerMvcOptionsSetup.cs index 6c7546e332..1a57d167ce 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MvcXmlSerializerMvcOptionsSetup.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs index c71be18264..c289972ea4 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs @@ -73,9 +73,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters WriterSettings = writerSettings; - WrapperProviderFactories = new List(); + WrapperProviderFactories = new List + { + new SerializableErrorWrapperProviderFactory(), + }; WrapperProviderFactories.Add(new EnumerableWrapperProviderFactory(WrapperProviderFactories)); - WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); _logger = loggerFactory?.CreateLogger(GetType()); } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/DependencyInjection/MvcLocalizationMvcBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/DependencyInjection/MvcLocalizationMvcBuilderExtensions.cs index ddeb4f25b4..d5c3c6df4c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/DependencyInjection/MvcLocalizationMvcBuilderExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/DependencyInjection/MvcLocalizationMvcBuilderExtensions.cs @@ -3,7 +3,7 @@ using System; using Microsoft.AspNetCore.Mvc.DataAnnotations; -using Microsoft.AspNetCore.Mvc.Localization.Internal; +using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.Localization; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/DependencyInjection/MvcLocalizationMvcCoreBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/DependencyInjection/MvcLocalizationMvcCoreBuilderExtensions.cs index 27b1f90a7f..e0a970ab88 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/DependencyInjection/MvcLocalizationMvcCoreBuilderExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/DependencyInjection/MvcLocalizationMvcCoreBuilderExtensions.cs @@ -3,7 +3,7 @@ using System; using Microsoft.AspNetCore.Mvc.DataAnnotations; -using Microsoft.AspNetCore.Mvc.Localization.Internal; +using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.Localization; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/LocalizedHtmlString.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/LocalizedHtmlString.cs index ed91460d69..fdc99aeebc 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/LocalizedHtmlString.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/LocalizedHtmlString.cs @@ -72,7 +72,7 @@ namespace Microsoft.AspNetCore.Mvc.Localization public string Name { get; } /// - /// The string resource. + /// The original resource string, prior to formatting with any constructor arguments. /// public string Value { get; } @@ -98,4 +98,4 @@ namespace Microsoft.AspNetCore.Mvc.Localization formattableString.WriteTo(writer, encoder); } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Microsoft.AspNetCore.Mvc.Localization.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Microsoft.AspNetCore.Mvc.Localization.csproj index ea50d25312..a20bb199aa 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Microsoft.AspNetCore.Mvc.Localization.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Microsoft.AspNetCore.Mvc.Localization.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC features that enable globalization and localization of applications. @@ -17,7 +17,6 @@ Microsoft.AspNetCore.Mvc.Localization.IViewLocalizer - diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Internal/MvcLocalizationServices.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/MvcLocalizationServices.cs similarity index 84% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Internal/MvcLocalizationServices.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/MvcLocalizationServices.cs index 4e1f26ebc0..a1b056250b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Internal/MvcLocalizationServices.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/MvcLocalizationServices.cs @@ -7,16 +7,16 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Localization; -namespace Microsoft.AspNetCore.Mvc.Localization.Internal +namespace Microsoft.AspNetCore.Mvc.Localization { - public static class MvcLocalizationServices + internal static class MvcLocalizationServices { public static void AddLocalizationServices( IServiceCollection services, LanguageViewLocationExpanderFormat format, Action setupAction) { - AddMvcViewLocalizationServices(services, format, setupAction); + AddMvcViewLocalizationServices(services, format); if (setupAction == null) { @@ -31,8 +31,7 @@ namespace Microsoft.AspNetCore.Mvc.Localization.Internal // To enable unit testing only 'MVC' specific services public static void AddMvcViewLocalizationServices( IServiceCollection services, - LanguageViewLocationExpanderFormat format, - Action setupAction) + LanguageViewLocationExpanderFormat format) { services.Configure( options => diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..8110c12929 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Localization/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Localization.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/ApplicationParts/RazorCompiledItemFeatureProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/ApplicationParts/RazorCompiledItemFeatureProvider.cs index d59c4e9ffb..ccfc49127d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/ApplicationParts/RazorCompiledItemFeatureProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/ApplicationParts/RazorCompiledItemFeatureProvider.cs @@ -25,12 +25,12 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationParts if (duplicates != null) { - var viewsDiffereningInCase = string.Join(Environment.NewLine, duplicates.Select(d => d.Identifier)); + var viewsDifferingInCase = string.Join(Environment.NewLine, duplicates.Select(d => d.Identifier)); var message = string.Join( Environment.NewLine, Resources.RazorViewCompiler_ViewPathsDifferOnlyInCase, - viewsDiffereningInCase); + viewsDifferingInCase); throw new InvalidOperationException(message); } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/IViewCompilationMemoryCacheProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/IViewCompilationMemoryCacheProvider.cs new file mode 100644 index 0000000000..5abbd550f3 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/IViewCompilationMemoryCacheProvider.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 Microsoft.Extensions.Caching.Memory; + +namespace Microsoft.AspNetCore.Mvc.Razor.Compilation +{ + /// + /// Provides an instance of that is used to store compiled Razor views. + /// + public interface IViewCompilationMemoryCacheProvider + { + IMemoryCache CompilationMemoryCache { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/MetadataReferenceFeature.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/MetadataReferenceFeature.cs index 7ad99a2afe..478d21eed8 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/MetadataReferenceFeature.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/MetadataReferenceFeature.cs @@ -1,6 +1,7 @@ // 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.CodeAnalysis; @@ -9,6 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation /// /// Specifies the list of used in Razor compilation. /// + [Obsolete("This type is obsolete and will be removed in a future version. See https://aka.ms/AA1x4gg for details.")] public class MetadataReferenceFeature { /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/MetadataReferenceFeatureProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/MetadataReferenceFeatureProvider.cs index f5a42ace01..8050671a1f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/MetadataReferenceFeatureProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/MetadataReferenceFeatureProvider.cs @@ -17,6 +17,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation /// uses for registered instances to create /// . /// + [Obsolete("This type is obsolete and will be removed in a future version. See https://aka.ms/AA1x4gg for details.")] public class MetadataReferenceFeatureProvider : IApplicationFeatureProvider { /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/RazorReferenceManager.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/RazorReferenceManager.cs index 5b11ce10e3..df09623169 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/RazorReferenceManager.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/RazorReferenceManager.cs @@ -1,6 +1,7 @@ // 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.CodeAnalysis; @@ -9,6 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation /// /// Manages compilation references for Razor compilation. /// + [Obsolete("This type is obsolete and will be removed in a future version. See https://aka.ms/AA1x4gg for details.")] public abstract class RazorReferenceManager { /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/RazorViewCompilationMemoryCacheProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/RazorViewCompilationMemoryCacheProvider.cs new file mode 100644 index 0000000000..9a11db0b3f --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/RazorViewCompilationMemoryCacheProvider.cs @@ -0,0 +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 Microsoft.Extensions.Caching.Memory; + +namespace Microsoft.AspNetCore.Mvc.Razor.Compilation +{ + internal class RazorViewCompilationMemoryCacheProvider : IViewCompilationMemoryCacheProvider + { + IMemoryCache IViewCompilationMemoryCacheProvider.CompilationMemoryCache { get; } = new MemoryCache(new MemoryCacheOptions()); + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/ViewsFeatureProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/ViewsFeatureProvider.cs index deb57205de..e7e7143fbb 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/ViewsFeatureProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/ViewsFeatureProvider.cs @@ -35,12 +35,12 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation { // Ensure parts do not specify views with differing cases. This is not supported // at runtime and we should flag at as such for precompiled views. - var viewsDiffereningInCase = string.Join(Environment.NewLine, duplicates.Select(d => d.RelativePath)); + var viewsDifferingInCase = string.Join(Environment.NewLine, duplicates.Select(d => d.RelativePath)); var message = string.Join( Environment.NewLine, Resources.RazorViewCompiler_ViewPathsDifferOnlyInCase, - viewsDiffereningInCase); + viewsDifferingInCase); throw new InvalidOperationException(message); } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/DependencyInjection/MvcRazorMvcCoreBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/DependencyInjection/MvcRazorMvcCoreBuilderExtensions.cs index 2eb17bc862..131bc13391 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/DependencyInjection/MvcRazorMvcCoreBuilderExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/DependencyInjection/MvcRazorMvcCoreBuilderExtensions.cs @@ -10,9 +10,11 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.Caching.Memory; @@ -65,10 +67,12 @@ namespace Microsoft.Extensions.DependencyInjection private static void AddRazorViewEngineFeatureProviders(IMvcCoreBuilder builder) { +#pragma warning disable CS0618 // Type or member is obsolete if (!builder.PartManager.FeatureProviders.OfType().Any()) { builder.PartManager.FeatureProviders.Add(new MetadataReferenceFeatureProvider()); } +#pragma warning restore CS0618 // Type or member is obsolete if (!builder.PartManager.FeatureProviders.OfType().Any()) { @@ -78,7 +82,7 @@ namespace Microsoft.Extensions.DependencyInjection // ViewFeature items have precedence semantics - when two views have the same path \ identifier, // the one that appears earlier in the list wins. Therefore the ordering of // RazorCompiledItemFeatureProvider and ViewsFeatureProvider is pertinent - any view compiled - // using the Sdk will be prefered to views compiled using MvcPrecompilation. + // using the Sdk will be preferred to views compiled using MvcPrecompilation. if (!builder.PartManager.FeatureProviders.OfType().Any()) { builder.PartManager.FeatureProviders.Add(new RazorCompiledItemFeatureProvider()); @@ -146,7 +150,9 @@ namespace Microsoft.Extensions.DependencyInjection internal static void AddRazorViewEngineServices(IServiceCollection services) { services.TryAddSingleton(); +#pragma warning disable CS0618 // Type or member is obsolete services.TryAddSingleton(); +#pragma warning restore CS0618 // Type or member is obsolete services.TryAddEnumerable( ServiceDescriptor.Transient, MvcRazorMvcViewOptionsSetup>()); @@ -154,6 +160,9 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddEnumerable( ServiceDescriptor.Transient, RazorViewEngineOptionsSetup>()); + services.TryAddEnumerable( + ServiceDescriptor.Transient, RazorViewEngineOptionsSetup>()); + services.TryAddSingleton< IRazorViewEngineFileProviderAccessor, DefaultRazorViewEngineFileProviderAccessor>(); @@ -172,6 +181,7 @@ namespace Microsoft.Extensions.DependencyInjection return viewEngine; }); services.TryAddSingleton(); + services.TryAddSingleton(); // In the default scenario the following services are singleton by virtue of being initialized as part of // creating the singleton RazorViewEngine instance. @@ -219,8 +229,10 @@ namespace Microsoft.Extensions.DependencyInjection // TagHelperComponents manager services.TryAddScoped(); - // Consumed by the Cache tag helper to cache results across the lifetime of the application. + // Infrastructure for MVC TagHelpers services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs new file mode 100644 index 0000000000..276bca6cbc --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/IModelTypeProvider.cs @@ -0,0 +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; + +namespace Microsoft.AspNetCore.Mvc.Razor +{ + internal interface IModelTypeProvider + { + Type GetModelType(); + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/CryptographyAlgorithms.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/CryptographyAlgorithms.cs similarity index 88% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/CryptographyAlgorithms.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/CryptographyAlgorithms.cs index 02c28552e1..2442315477 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/CryptographyAlgorithms.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/CryptographyAlgorithms.cs @@ -3,9 +3,9 @@ using System.Security.Cryptography; -namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal +namespace Microsoft.AspNetCore.Mvc.Razor.Infrastructure { - public static class CryptographyAlgorithms + internal static class CryptographyAlgorithms { public static SHA256 CreateSHA256() { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/DefaultFileVersionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/DefaultFileVersionProvider.cs new file mode 100644 index 0000000000..ae4bd7c1a6 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/DefaultFileVersionProvider.cs @@ -0,0 +1,110 @@ +// 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.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.AspNetCore.Mvc.Razor.Infrastructure +{ + /// + /// Provides version hash for a specified file. + /// + internal class DefaultFileVersionProvider : IFileVersionProvider + { + private const string VersionKey = "v"; + private static readonly char[] QueryStringAndFragmentTokens = new [] { '?', '#' }; + + public DefaultFileVersionProvider( + IHostingEnvironment hostingEnvironment, + TagHelperMemoryCacheProvider cacheProvider) + { + if (hostingEnvironment == null) + { + throw new ArgumentNullException(nameof(hostingEnvironment)); + } + + if (cacheProvider == null) + { + throw new ArgumentNullException(nameof(cacheProvider)); + } + + FileProvider = hostingEnvironment.WebRootFileProvider; + Cache = cacheProvider.Cache; + } + + public IFileProvider FileProvider { get; } + + public IMemoryCache Cache { get; } + + public string AddFileVersionToPath(PathString requestPathBase, string path) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + var resolvedPath = path; + + var queryStringOrFragmentStartIndex = path.IndexOfAny(QueryStringAndFragmentTokens); + if (queryStringOrFragmentStartIndex != -1) + { + resolvedPath = path.Substring(0, queryStringOrFragmentStartIndex); + } + + if (Uri.TryCreate(resolvedPath, UriKind.Absolute, out var uri) && !uri.IsFile) + { + // Don't append version if the path is absolute. + return path; + } + + if (Cache.TryGetValue(path, out string value)) + { + return value; + } + + var cacheEntryOptions = new MemoryCacheEntryOptions(); + cacheEntryOptions.AddExpirationToken(FileProvider.Watch(resolvedPath)); + var fileInfo = FileProvider.GetFileInfo(resolvedPath); + + if (!fileInfo.Exists && + requestPathBase.HasValue && + resolvedPath.StartsWith(requestPathBase.Value, StringComparison.OrdinalIgnoreCase)) + { + var requestPathBaseRelativePath = resolvedPath.Substring(requestPathBase.Value.Length); + cacheEntryOptions.AddExpirationToken(FileProvider.Watch(requestPathBaseRelativePath)); + fileInfo = FileProvider.GetFileInfo(requestPathBaseRelativePath); + } + + if (fileInfo.Exists) + { + value = QueryHelpers.AddQueryString(path, VersionKey, GetHashForFile(fileInfo)); + } + else + { + // if the file is not in the current server. + value = path; + } + + cacheEntryOptions.SetSize(value.Length * sizeof(char)); + value = Cache.Set(path, value, cacheEntryOptions); + return value; + } + + private static string GetHashForFile(IFileInfo fileInfo) + { + using (var sha256 = CryptographyAlgorithms.CreateSHA256()) + { + using (var readStream = fileInfo.CreateReadStream()) + { + var hash = sha256.ComputeHash(readStream); + return WebEncoders.Base64UrlEncode(hash); + } + } + } + } +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/TagHelperMemoryCacheProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/TagHelperMemoryCacheProvider.cs new file mode 100644 index 0000000000..c80b9850c7 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Infrastructure/TagHelperMemoryCacheProvider.cs @@ -0,0 +1,23 @@ +// 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.Caching.Memory; + +namespace Microsoft.AspNetCore.Mvc.Razor.Infrastructure +{ + /// + /// This API supports the MVC's infrastructure and is not intended to be used + /// directly from your code. This API may change in future releases. + /// + public sealed class TagHelperMemoryCacheProvider + { + /// + /// This API supports the MVC's infrastructure and is not intended to be used + /// directly from your code. This API may change in future releases. + /// + public IMemoryCache Cache { get; internal set; } = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = 10 * 1024 * 1024 // 10MB + }); + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/CSharpCompiler.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/CSharpCompiler.cs index 93703d49c9..496d2f9ee7 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/CSharpCompiler.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/CSharpCompiler.cs @@ -19,7 +19,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal { public class CSharpCompiler { +#pragma warning disable CS0618 // Type or member is obsolete private readonly RazorReferenceManager _referenceManager; +#pragma warning restore CS0618 // Type or member is obsolete private readonly IHostingEnvironment _hostingEnvironment; private bool _optionsInitialized; private CSharpParseOptions _parseOptions; @@ -27,7 +29,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal private EmitOptions _emitOptions; private bool _emitPdb; +#pragma warning disable CS0618 // Type or member is obsolete public CSharpCompiler(RazorReferenceManager manager, IHostingEnvironment hostingEnvironment) +#pragma warning restore CS0618 // Type or member is obsolete { _referenceManager = manager ?? throw new ArgumentNullException(nameof(manager)); _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/CompilationFailedExceptionFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/CompilationFailedExceptionFactory.cs index 0be6fa6b7d..02b096ec75 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/CompilationFailedExceptionFactory.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/CompilationFailedExceptionFactory.cs @@ -77,9 +77,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal string.Equals(CS0234, g.Id, StringComparison.OrdinalIgnoreCase) || string.Equals(CS0246, g.Id, StringComparison.OrdinalIgnoreCase))) { - additionalMessage = Resources.FormatCompilation_DependencyContextIsNotSpecified( - "Microsoft.NET.Sdk.Web", - "PreserveCompilationContext"); + additionalMessage = Resources.FormatCompilation_MissingReferences( + "CopyRefAssembliesToPublishDirectory"); } var compilationFailure = new CompilationFailure( diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorReferenceManager.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorReferenceManager.cs index f1d59ef770..04965e52a4 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorReferenceManager.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorReferenceManager.cs @@ -11,7 +11,9 @@ using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.Razor.Internal { +#pragma warning disable CS0618 // Type or member is obsolete public class DefaultRazorReferenceManager : RazorReferenceManager +#pragma warning restore CS0618 // Type or member is obsolete { private readonly ApplicationPartManager _partManager; private readonly IList _additionalMetadataReferences; @@ -24,7 +26,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal IOptions optionsAccessor) { _partManager = partManager; +#pragma warning disable CS0618 // Type or member is obsolete _additionalMetadataReferences = optionsAccessor.Value.AdditionalCompilationReferences; +#pragma warning restore CS0618 // Type or member is obsolete } public override IReadOnlyList CompilationReferences @@ -41,7 +45,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal private IReadOnlyList GetCompilationReferences() { +#pragma warning disable CS0618 // Type or member is obsolete var feature = new MetadataReferenceFeature(); +#pragma warning restore CS0618 // Type or member is obsolete _partManager.PopulateFeature(feature); var applicationReferences = feature.MetadataReferences; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/FileProviderRazorProject.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/FileProviderRazorProject.cs index 52af4ae3f3..9e5718fa2f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/FileProviderRazorProject.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/FileProviderRazorProject.cs @@ -16,20 +16,20 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal private readonly IFileProvider _provider; private readonly IHostingEnvironment _hostingEnvironment; - public FileProviderRazorProjectFileSystem(IRazorViewEngineFileProviderAccessor accessor, IHostingEnvironment hostingEnviroment) + public FileProviderRazorProjectFileSystem(IRazorViewEngineFileProviderAccessor accessor, IHostingEnvironment hostingEnvironment) { if (accessor == null) { throw new ArgumentNullException(nameof(accessor)); } - if (hostingEnviroment == null) + if (hostingEnvironment == null) { - throw new ArgumentNullException(nameof(hostingEnviroment)); + throw new ArgumentNullException(nameof(hostingEnvironment)); } _provider = accessor.FileProvider; - _hostingEnvironment = hostingEnviroment; + _hostingEnvironment = hostingEnvironment; } public override RazorProjectItem GetItem(string path) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/LazyMetadataReferenceFeature.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/LazyMetadataReferenceFeature.cs index ead4b29630..5479991463 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/LazyMetadataReferenceFeature.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/LazyMetadataReferenceFeature.cs @@ -11,9 +11,13 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal { public class LazyMetadataReferenceFeature : IMetadataReferenceFeature { +#pragma warning disable CS0618 // Type or member is obsolete private readonly RazorReferenceManager _referenceManager; +#pragma warning restore CS0618 // Type or member is obsolete +#pragma warning disable CS0618 // Type or member is obsolete public LazyMetadataReferenceFeature(RazorReferenceManager referenceManager) +#pragma warning restore CS0618 // Type or member is obsolete { _referenceManager = referenceManager; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs index 93b871cc76..0f43c19f44 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs @@ -34,9 +34,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal private readonly IFileProvider _fileProvider; private readonly RazorProjectEngine _projectEngine; private readonly Action _compilationCallback; + private readonly IMemoryCache _cache; private readonly ILogger _logger; private readonly CSharpCompiler _csharpCompiler; - private readonly IMemoryCache _cache; public RazorViewCompiler( IFileProvider fileProvider, @@ -44,6 +44,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal CSharpCompiler csharpCompiler, Action compilationCallback, IList precompiledViews, + IMemoryCache cache, ILogger logger) { if (fileProvider == null) @@ -82,13 +83,14 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal _compilationCallback = compilationCallback; _logger = logger; + _normalizedPathCache = new ConcurrentDictionary(StringComparer.Ordinal); // This is our L0 cache, and is a durable store. Views migrate into the cache as they are requested // from either the set of known precompiled views, or by being compiled. - _cache = new MemoryCache(new MemoryCacheOptions()); + _cache = cache; - // We need to validate that the all of the precompiled views are unique by path (case-insenstive). + // We need to validate that the all of the precompiled views are unique by path (case-insensitive). // We do this because there's no good way to canonicalize paths on windows, and it will create // problems when deploying to linux. Rather than deal with these issues, we just don't support // views that differ only by case. @@ -114,6 +116,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal } } + public bool AllowRecompilingViewsOnFileChange { get; set; } + /// public Task CompileAsync(string relativePath) { @@ -177,7 +181,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal cacheEntryOptions.ExpirationTokens.Add(item.ExpirationTokens[i]); } - taskSource = new TaskCompletionSource(); + taskSource = new TaskCompletionSource(creationOptions: TaskCreationOptions.RunContinuationsAsynchronously); if (item.SupportsCompilation) { // We'll compile in just a sec, be patient. @@ -252,16 +256,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal // Used to validate and recompile NormalizedPath = normalizedPath, - ExpirationTokens = new List(), - }; - var checksums = precompiledView.Item.GetChecksumMetadata(); - for (var i = 0; i < checksums.Count; i++) - { - // We rely on Razor to provide the right set of checksums. Trust the compiler, it has to do a good job, - // so it probably will. - item.ExpirationTokens.Add(_fileProvider.Watch(checksums[i].Identifier)); - } + ExpirationTokens = GetExpirationTokens(precompiledView), + }; // We also need to create a new descriptor, because the original one doesn't have expiration tokens on // it. These will be used by the view location cache, which is like an L1 cache for views (this class is @@ -280,10 +277,13 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal private ViewCompilerWorkItem CreateRuntimeCompilationWorkItem(string normalizedPath) { - var expirationTokens = new List() + IList expirationTokens = Array.Empty(); + + if (AllowRecompilingViewsOnFileChange) { - _fileProvider.Watch(normalizedPath), - }; + var changeToken = _fileProvider.Watch(normalizedPath); + expirationTokens = new List { changeToken }; + } var projectItem = _projectEngine.FileSystem.GetItem(normalizedPath); if (!projectItem.Exists) @@ -291,7 +291,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal _logger.ViewCompilerCouldNotFindFileAtPath(normalizedPath); // If the file doesn't exist, we can't do compilation right now - we still want to cache - // the fact that we tried. This will allow us to retrigger compilation if the view file + // the fact that we tried. This will allow us to re-trigger compilation if the view file // is added. return new ViewCompilerWorkItem() { @@ -311,9 +311,46 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal _logger.ViewCompilerFoundFileToCompile(normalizedPath); + GetChangeTokensFromImports(expirationTokens, projectItem); + + return new ViewCompilerWorkItem() + { + SupportsCompilation = true, + + NormalizedPath = normalizedPath, + ExpirationTokens = expirationTokens, + }; + } + + private IList GetExpirationTokens(CompiledViewDescriptor precompiledView) + { + if (!AllowRecompilingViewsOnFileChange) + { + return Array.Empty(); + } + + var checksums = precompiledView.Item.GetChecksumMetadata(); + var expirationTokens = new List(checksums.Count); + + for (var i = 0; i < checksums.Count; i++) + { + // We rely on Razor to provide the right set of checksums. Trust the compiler, it has to do a good job, + // so it probably will. + expirationTokens.Add(_fileProvider.Watch(checksums[i].Identifier)); + } + + return expirationTokens; + } + + private void GetChangeTokensFromImports(IList expirationTokens, RazorProjectItem projectItem) + { + if (!AllowRecompilingViewsOnFileChange) + { + return; + } + // OK this means we can do compilation. For now let's just identify the other files we need to watch // so we can create the cache entry. Compilation will happen after we release the lock. - var importFeature = _projectEngine.ProjectFeatures.OfType().FirstOrDefault(); // There should always be an import feature unless someone has misconfigured their RazorProjectEngine. @@ -328,14 +365,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal { expirationTokens.Add(_fileProvider.Watch(physicalImport.FilePath)); } - - return new ViewCompilerWorkItem() - { - SupportsCompilation = true, - - NormalizedPath = normalizedPath, - ExpirationTokens = expirationTokens, - }; } protected virtual CompiledViewDescriptor CompileAndEmit(string relativePath) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompilerProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompilerProvider.cs index c705a88aac..3776bc845f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompilerProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompilerProvider.cs @@ -18,6 +18,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal private readonly ApplicationPartManager _applicationPartManager; private readonly IRazorViewEngineFileProviderAccessor _fileProviderAccessor; private readonly CSharpCompiler _csharpCompiler; + private readonly IViewCompilationMemoryCacheProvider _compilationMemoryCacheProvider; private readonly RazorViewEngineOptions _viewEngineOptions; private readonly ILogger _logger; private readonly Func _createCompiler; @@ -32,12 +33,14 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal IRazorViewEngineFileProviderAccessor fileProviderAccessor, CSharpCompiler csharpCompiler, IOptions viewEngineOptionsAccessor, + IViewCompilationMemoryCacheProvider compilationMemoryCacheProvider, ILoggerFactory loggerFactory) { _applicationPartManager = applicationPartManager; _razorProjectEngine = razorProjectEngine; _fileProviderAccessor = fileProviderAccessor; _csharpCompiler = csharpCompiler; + _compilationMemoryCacheProvider = compilationMemoryCacheProvider; _viewEngineOptions = viewEngineOptionsAccessor.Value; _logger = loggerFactory.CreateLogger(); @@ -72,9 +75,15 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal _fileProviderAccessor.FileProvider, _razorProjectEngine, _csharpCompiler, +#pragma warning disable CS0618 // Type or member is obsolete _viewEngineOptions.CompilationCallback, +#pragma warning restore CS0618 // Type or member is obsolete feature.ViewDescriptors, - _logger); + _compilationMemoryCacheProvider.CompilationMemoryCache, + _logger) + { + AllowRecompilingViewsOnFileChange = _viewEngineOptions.AllowRecompilingViewsOnFileChange, + }; } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ViewLocationCacheItem.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ViewLocationCacheItem.cs index 23fe788101..64333eb02f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ViewLocationCacheItem.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ViewLocationCacheItem.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal /// /// An item in . /// - public struct ViewLocationCacheItem + public readonly struct ViewLocationCacheItem { /// /// Initializes a new instance of . diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ViewLocationCacheKey.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ViewLocationCacheKey.cs index 7643882f79..00f3d3c8ec 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ViewLocationCacheKey.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ViewLocationCacheKey.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal /// /// Key for entries in . /// - public struct ViewLocationCacheKey : IEquatable + public readonly struct ViewLocationCacheKey : IEquatable { /// /// Initializes a new instance of . diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Microsoft.AspNetCore.Mvc.Razor.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Microsoft.AspNetCore.Mvc.Razor.csproj index 74e943a8d9..4bef4f811f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Microsoft.AspNetCore.Mvc.Razor.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Microsoft.AspNetCore.Mvc.Razor.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC Razor view engine for CSHTML files. @@ -19,10 +19,10 @@ - - - - + + + + diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Properties/AssemblyInfo.cs index 831fbe57e1..0e1a8e8695 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Properties/AssemblyInfo.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Properties/AssemblyInfo.cs @@ -3,5 +3,10 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.TagHelpers, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] + [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Razor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.TagHelpers.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Properties/Resources.Designer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Properties/Resources.Designer.cs index 3b6a518dd3..a2e6436e78 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Properties/Resources.Designer.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Properties/Resources.Designer.cs @@ -267,18 +267,18 @@ namespace Microsoft.AspNetCore.Mvc.Razor => string.Format(CultureInfo.CurrentCulture, GetString("LayoutHasCircularReference"), p0, p1); /// - /// One or more compilation references are missing. Ensure that your project is referencing '{0}' and the '{1}' property is not set to false. + /// One or more compilation references may be missing. If you're seeing this in a published application, set '{0}' to true in your project file to ensure files in the refs directory are published. /// - internal static string Compilation_DependencyContextIsNotSpecified + internal static string Compilation_MissingReferences { - get => GetString("Compilation_DependencyContextIsNotSpecified"); + get => GetString("Compilation_MissingReferences"); } /// - /// One or more compilation references are missing. Ensure that your project is referencing '{0}' and the '{1}' property is not set to false. + /// One or more compilation references may be missing. If you're seeing this in a published application, set '{0}' to true in your project file to ensure files in the refs directory are published. /// - internal static string FormatCompilation_DependencyContextIsNotSpecified(object p0, object p1) - => string.Format(CultureInfo.CurrentCulture, GetString("Compilation_DependencyContextIsNotSpecified"), p0, p1); + internal static string FormatCompilation_MissingReferences(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("Compilation_MissingReferences"), p0); /// /// '{0}' cannot be empty. These locations are required to locate a view for rendering. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPage.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPage.cs index 3d13f977f7..53fd192c24 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPage.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPage.cs @@ -196,11 +196,12 @@ namespace Microsoft.AspNetCore.Mvc.Razor else if (required) { // If the section is not found, and it is not optional, throw an error. - var message = Resources.FormatSectionNotDefined( - ViewContext.ExecutingFilePath, - sectionName, - ViewContext.View.Path); - throw new InvalidOperationException(message); + var viewContext = ViewContext; + throw new InvalidOperationException( + Resources.FormatSectionNotDefined( + viewContext.ExecutingFilePath, + sectionName, + viewContext.View.Path)); } else { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageActivator.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageActivator.cs index 758255f494..cc9a20c8cb 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageActivator.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageActivator.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Mvc.Razor { @@ -19,7 +20,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor { // Name of the "public TModel Model" property on RazorPage private const string ModelPropertyName = "Model"; - private readonly ConcurrentDictionary _activationInfo; + private readonly ConcurrentDictionary _activationInfo; private readonly IModelMetadataProvider _metadataProvider; // Value accessors for common singleton properties activated in a RazorPage. @@ -36,7 +37,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor HtmlEncoder htmlEncoder, IModelExpressionProvider modelExpressionProvider) { - _activationInfo = new ConcurrentDictionary(); + _activationInfo = new ConcurrentDictionary(); _metadataProvider = metadataProvider; _propertyAccessors = new RazorPagePropertyActivator.PropertyValueAccessors @@ -62,26 +63,76 @@ namespace Microsoft.AspNetCore.Mvc.Razor throw new ArgumentNullException(nameof(context)); } + var propertyActivator = GetOrAddCacheEntry(page); + propertyActivator.Activate(page, context); + } + + internal RazorPagePropertyActivator GetOrAddCacheEntry(IRazorPage page) + { var pageType = page.GetType(); - RazorPagePropertyActivator propertyActivator; - if (!_activationInfo.TryGetValue(pageType, out propertyActivator)) + Type providedModelType = null; + if (page is IModelTypeProvider modelTypeProvider) + { + providedModelType = modelTypeProvider.GetModelType(); + } + + // We only need to vary by providedModelType since it varies at runtime. Defined model type + // is synonymous with the pageType and consequently does not need to be accounted for in the cache key. + var cacheKey = new CacheKey(pageType, providedModelType); + if (!_activationInfo.TryGetValue(cacheKey, out var propertyActivator)) { // Look for a property named "Model". If it is non-null, we'll assume this is // the equivalent of TModel Model property on RazorPage. // // Otherwise if we don't have a model property the activator will just skip setting // the view data. - var modelType = pageType.GetRuntimeProperty(ModelPropertyName)?.PropertyType; + var modelType = providedModelType; + if (modelType == null) + { + modelType = pageType.GetRuntimeProperty(ModelPropertyName)?.PropertyType; + } + propertyActivator = new RazorPagePropertyActivator( pageType, modelType, _metadataProvider, _propertyAccessors); - propertyActivator = _activationInfo.GetOrAdd(pageType, propertyActivator); + propertyActivator = _activationInfo.GetOrAdd(cacheKey, propertyActivator); } - propertyActivator.Activate(page, context); + return propertyActivator; + } + + private readonly struct CacheKey : IEquatable + { + public CacheKey(Type pageType, Type providedModelType) + { + PageType = pageType; + ProvidedModelType = providedModelType; + } + + public Type PageType { get; } + + public Type ProvidedModelType { get; } + + public bool Equals(CacheKey other) + { + return PageType == other.PageType && + ProvidedModelType == other.ProvidedModelType; + } + + public override int GetHashCode() + { + var hashCodeCombiner = HashCodeCombiner.Start(); + hashCodeCombiner.Add(PageType); + if (ProvidedModelType != null) + { + hashCodeCombiner.Add(ProvidedModelType); + } + + return hashCodeCombiner.CombinedHash; + } } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageBase.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageBase.cs index 96ef957f60..016076520f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageBase.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageBase.cs @@ -51,13 +51,13 @@ namespace Microsoft.AspNetCore.Mvc.Razor { get { - if (ViewContext == null) + var viewContext = ViewContext; + if (viewContext == null) { - var message = Resources.FormatViewContextMustBeSet("ViewContext", "Output"); - throw new InvalidOperationException(message); + throw new InvalidOperationException(Resources.FormatViewContextMustBeSet(nameof(ViewContext), nameof(Output))); } - return ViewContext.Writer; + return viewContext.Writer; } } @@ -183,8 +183,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// public void StartTagHelperWritingScope(HtmlEncoder encoder) { + var viewContext = ViewContext; var buffer = new ViewBuffer(BufferScope, Path, ViewBuffer.TagHelperPageSize); - TagHelperScopes.Push(new TagHelperScopeInfo(buffer, HtmlEncoder, ViewContext.Writer)); + TagHelperScopes.Push(new TagHelperScopeInfo(buffer, HtmlEncoder, viewContext.Writer)); // If passed an HtmlEncoder, override the property. if (encoder != null) @@ -194,7 +195,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor // We need to replace the ViewContext's Writer to ensure that all content (including content written // from HTML helpers) is redirected. - ViewContext.Writer = new ViewBufferTextWriter(buffer, ViewContext.Writer.Encoding); + viewContext.Writer = new ViewBufferTextWriter(buffer, viewContext.Writer.Encoding); } /// @@ -238,7 +239,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor throw new InvalidOperationException(Resources.RazorPage_NestingAttributeWritingScopesNotSupported); } - _pageWriter = ViewContext.Writer; + var viewContext = ViewContext; + _pageWriter = viewContext.Writer; if (_valueBuffer == null) { @@ -247,7 +249,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor // We need to replace the ViewContext's Writer to ensure that all content (including content written // from HTML helpers) is redirected. - ViewContext.Writer = _valueBuffer; + viewContext.Writer = _valueBuffer; } @@ -284,15 +286,18 @@ namespace Microsoft.AspNetCore.Mvc.Razor throw new ArgumentNullException(nameof(writer)); } - _textWriterStack.Push(ViewContext.Writer); - ViewContext.Writer = writer; + var viewContext = ViewContext; + _textWriterStack.Push(viewContext.Writer); + viewContext.Writer = writer; } // Internal for unit testing. protected internal virtual TextWriter PopWriter() { - ViewContext.Writer = _textWriterStack.Pop(); - return ViewContext.Writer; + var viewContext = ViewContext; + var writer = _textWriterStack.Pop(); + viewContext.Writer = writer; + return writer; } public virtual string Href(string contentPath) @@ -304,9 +309,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor if (_urlHelper == null) { - var services = ViewContext?.HttpContext.RequestServices; + var viewContext = ViewContext; + var services = viewContext?.HttpContext.RequestServices; var factory = services.GetRequiredService(); - _urlHelper = factory.GetUrlHelper(ViewContext); + _urlHelper = factory.GetUrlHelper(viewContext); } return _urlHelper.Content(contentPath); @@ -637,8 +643,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// before flushes the headers. public virtual HtmlString SetAntiforgeryCookieAndHeader() { - var antiforgery = ViewContext?.HttpContext.RequestServices.GetRequiredService(); - antiforgery.SetCookieTokenAndHeader(ViewContext?.HttpContext); + var viewContext = ViewContext; + var antiforgery = viewContext?.HttpContext.RequestServices.GetRequiredService(); + antiforgery.SetCookieTokenAndHeader(viewContext?.HttpContext); return HtmlString.Empty; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageFactoryResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageFactoryResult.cs index 31f7b1ce94..45537550f7 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageFactoryResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageFactoryResult.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// /// Result of . /// - public struct RazorPageFactoryResult + public readonly struct RazorPageFactoryResult { /// /// Initializes a new instance of with the diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageResult.cs index c299e350fe..044e3ee581 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorPageResult.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// /// Result of locating a . /// - public struct RazorPageResult + public readonly struct RazorPageResult { /// /// Initializes a new instance of for a successful discovery. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs index 240c80681c..10ef8139da 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs @@ -96,6 +96,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// public IReadOnlyList ViewStartPages { get; } + internal Action OnAfterPageActivated { get; set; } + /// public virtual async Task RenderAsync(ViewContext context) { @@ -167,6 +169,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor page.ViewContext = context; _pageActivator.Activate(page, context); + OnAfterPageActivated?.Invoke(page, context); + _diagnosticSource.BeforeViewPage(page, context); try diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs index 532c924d32..6c6148cb24 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs @@ -2,8 +2,10 @@ // 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 Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.CodeAnalysis; using Microsoft.Extensions.FileProviders; @@ -13,10 +15,21 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// /// Provides programmatic configuration for the . /// - public class RazorViewEngineOptions + public class RazorViewEngineOptions : IEnumerable { + private readonly ICompatibilitySwitch[] _switches; + private readonly CompatibilitySwitch _allowRecompilingViewsOnFileChange; private Action _compilationCallback = c => { }; + public RazorViewEngineOptions() + { + _allowRecompilingViewsOnFileChange = new CompatibilitySwitch(nameof(AllowRecompilingViewsOnFileChange)); + _switches = new[] + { + _allowRecompilingViewsOnFileChange, + }; + } + /// /// Gets a used by the . /// @@ -157,6 +170,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// Gets the instances that should be included in Razor compilation, along with /// those discovered by s. /// + [Obsolete("This property is obsolete and will be removed in a future version. See https://aka.ms/AA1x4gg for details.")] public IList AdditionalCompilationReferences { get; } = new List(); /// @@ -166,6 +180,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// /// Customizations made here would not reflect in tooling (Intellisense). /// + [Obsolete("This property is obsolete and will be removed in a future version. See https://aka.ms/AA1x4gg for details.")] public Action CompilationCallback { get => _compilationCallback; @@ -179,5 +194,52 @@ namespace Microsoft.AspNetCore.Mvc.Razor _compilationCallback = value; } } + + /// + /// Gets or sets a value that determines if Razor files (Razor Views and Razor Pages) are recompiled and updated + /// if files change on disk. + /// + /// When , MVC will use to watch for changes to + /// Razor files in configured instances. + /// + /// + /// + /// The default value is if the version is + /// or earlier. If the version is later and is Development, + /// the default value is . Otherwise, the default value is . + /// + /// + /// + /// 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 + /// is Development or the value is explicitly configured. + /// + /// + public bool AllowRecompilingViewsOnFileChange + { + // Note: When compatibility switches are removed in 3.0, this property should be retained as a regular boolean property. + get => _allowRecompilingViewsOnFileChange.Value; + set => _allowRecompilingViewsOnFileChange.Value = value; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)_switches).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator(); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewEngineOptionsSetup.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptionsSetup.cs similarity index 50% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewEngineOptionsSetup.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptionsSetup.cs index e8c49113ba..6747d87726 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewEngineOptionsSetup.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptionsSetup.cs @@ -2,27 +2,45 @@ // 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.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Microsoft.AspNetCore.Mvc.Razor.Internal +namespace Microsoft.AspNetCore.Mvc.Razor { - /// - /// Sets up default options for . - /// - public class RazorViewEngineOptionsSetup : IConfigureOptions + internal class RazorViewEngineOptionsSetup : + ConfigureCompatibilityOptions, + IConfigureOptions { private readonly IHostingEnvironment _hostingEnvironment; - /// - /// Initializes a new instance of . - /// - /// for the application. - public RazorViewEngineOptionsSetup(IHostingEnvironment hostingEnvironment) + public RazorViewEngineOptionsSetup( + IHostingEnvironment hostingEnvironment, + ILoggerFactory loggerFactory, + IOptions compatibilityOptions) + : base(loggerFactory, compatibilityOptions) { _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); } + protected override IReadOnlyDictionary DefaultValues + { + get + { + var values = new Dictionary(); + if (Version < CompatibilityVersion.Version_2_2) + { + // Default to true in 2.1 or earlier. In 2.2, we have to conditionally enable this + // and consequently this switch has no default value. + values[nameof(RazorViewEngineOptions.AllowRecompilingViewsOnFileChange)] = true; + } + + return values; + } + } + public void Configure(RazorViewEngineOptions options) { if (options == null) @@ -41,6 +59,11 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal options.AreaViewLocationFormats.Add("/Areas/{2}/Views/{1}/{0}" + RazorViewEngine.ViewExtension); options.AreaViewLocationFormats.Add("/Areas/{2}/Views/Shared/{0}" + RazorViewEngine.ViewExtension); options.AreaViewLocationFormats.Add("/Views/Shared/{0}" + RazorViewEngine.ViewExtension); + + if (_hostingEnvironment.IsDevelopment()) + { + options.AllowRecompilingViewsOnFileChange = true; + } } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Resources.resx b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Resources.resx index 19b0e8f053..bab3fddf83 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Resources.resx +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Razor/Resources.resx @@ -173,8 +173,8 @@ A circular layout reference was detected when rendering '{0}'. The layout page '{1}' has already been rendered. - - One or more compilation references are missing. Ensure that your project is referencing '{0}' and the '{1}' property is not set to false. + + One or more compilation references may be missing. If you're seeing this in a published application, set '{0}' to true in your project file to ensure files in the refs directory are published. '{0}' cannot be empty. These locations are required to locate a view for rendering. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageApplicationModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/DefaultPageApplicationModelProvider.cs similarity index 92% rename from src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageApplicationModelProvider.cs rename to src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/DefaultPageApplicationModelProvider.cs index cc6a02af02..b486d40e8c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageApplicationModelProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/DefaultPageApplicationModelProvider.cs @@ -3,36 +3,43 @@ using System; using System.Reflection; -using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Mvc.RazorPages.Internal; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Options; +using Resources = Microsoft.AspNetCore.Mvc.RazorPages.Resources; -namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal +namespace Microsoft.AspNetCore.Mvc.ApplicationModels { - public class DefaultPageApplicationModelProvider : IPageApplicationModelProvider + internal class DefaultPageApplicationModelProvider : IPageApplicationModelProvider { private const string ModelPropertyName = "Model"; private readonly PageHandlerPageFilter _pageHandlerPageFilter = new PageHandlerPageFilter(); private readonly PageHandlerResultFilter _pageHandlerResultFilter = new PageHandlerResultFilter(); private readonly IModelMetadataProvider _modelMetadataProvider; - private readonly MvcOptions _options; + private readonly MvcOptions _mvcOptions; + private readonly RazorPagesOptions _razorPagesOptions; private readonly Func _supportsAllRequests; private readonly Func _supportsNonGetRequests; - + private readonly HandleOptionsRequestsPageFilter _handleOptionsRequestsFilter; public DefaultPageApplicationModelProvider( IModelMetadataProvider modelMetadataProvider, - IOptions options) + IOptions options, + IOptions razorPagesOptions) { _modelMetadataProvider = modelMetadataProvider; - _options = options.Value; + _mvcOptions = options.Value; + _razorPagesOptions = razorPagesOptions.Value; _supportsAllRequests = _ => true; - _supportsNonGetRequests = context => !string.Equals(context.HttpContext.Request.Method, "GET", StringComparison.OrdinalIgnoreCase); + _supportsNonGetRequests = context => !HttpMethods.IsGet(context.HttpContext.Request.Method); + _handleOptionsRequestsFilter = new HandleOptionsRequestsPageFilter(); } /// @@ -175,6 +182,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { pageModel.Filters.Add(_pageHandlerResultFilter); } + + if (_razorPagesOptions.AllowDefaultHandlingForOptionsRequests) + { + pageModel.Filters.Add(_handleOptionsRequestsFilter); + } } /// @@ -237,7 +249,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal var attributes = parameter.GetCustomAttributes(inherit: true); BindingInfo bindingInfo; - if (_options.AllowValidatingTopLevelNodes && _modelMetadataProvider is ModelMetadataProvider modelMetadataProviderBase) + if (_mvcOptions.AllowValidatingTopLevelNodes && _modelMetadataProvider is ModelMetadataProvider modelMetadataProviderBase) { var modelMetadata = modelMetadataProviderBase.GetMetadataForParameter(parameter); bindingInfo = BindingInfo.GetBindingInfo(attributes, modelMetadata); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs index 8c94ae18a7..a150977094 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Mvc.ApplicationModels @@ -96,7 +95,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels public IList Selectors { get; } /// - /// Gets a collection of route values that must be present in the + /// Gets a collection of route values that must be present in the /// for the corresponding page to be selected. /// /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteTransformerConvention.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteTransformerConvention.cs new file mode 100644 index 0000000000..df47d3b7c2 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteTransformerConvention.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.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + /// + /// An that sets page route resolution + /// to use the specified on . + /// This convention does not effect controller action routes. + /// + public class PageRouteTransformerConvention : IPageRouteModelConvention + { + private IOutboundParameterTransformer _parameterTransformer; + + /// + /// Creates a new instance of with the specified . + /// + /// The to use resolve page routes. + public PageRouteTransformerConvention(IOutboundParameterTransformer parameterTransformer) + { + if (parameterTransformer == null) + { + throw new ArgumentNullException(nameof(parameterTransformer)); + } + + _parameterTransformer = parameterTransformer; + } + + public void Apply(PageRouteModel model) + { + if (ShouldApply(model)) + { + model.Properties[typeof(IOutboundParameterTransformer)] = _parameterTransformer; + } + } + + protected virtual bool ShouldApply(PageRouteModel action) => true; + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/AutoValidateAntiforgeryPageApplicationModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/AutoValidateAntiforgeryPageApplicationModelProvider.cs new file mode 100644 index 0000000000..5503ba5d03 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/AutoValidateAntiforgeryPageApplicationModelProvider.cs @@ -0,0 +1,41 @@ +// 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 Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ViewFeatures; + +namespace Microsoft.AspNetCore.Mvc.RazorPages +{ + internal class AutoValidateAntiforgeryPageApplicationModelProvider : IPageApplicationModelProvider + { + // The order is set to execute after the DefaultPageApplicationModelProvider. + public int Order => -1000 + 10; + + public void OnProvidersExecuted(PageApplicationModelProviderContext context) + { + } + + public void OnProvidersExecuting(PageApplicationModelProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var pageApplicationModel = context.PageApplicationModel; + + // ValidateAntiforgeryTokenAttribute relies on order to determine if it's the effective policy. + // When two antiforgery filters of the same order are added to the application model, the effective policy is determined + // by whatever appears later in the list (closest to the action). This causes filters listed on the model to be pre-empted + // by the one added here. We'll resolve this unusual behavior by skipping the addition of the AutoValidateAntiforgeryTokenAttribute + // when another already exists. + if (!pageApplicationModel.Filters.OfType().Any()) + { + // Always require an antiforgery token on post + pageApplicationModel.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/MvcRazorPagesMvcBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/MvcRazorPagesMvcBuilderExtensions.cs index 6340d87005..78a301947a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/MvcRazorPagesMvcBuilderExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/MvcRazorPagesMvcBuilderExtensions.cs @@ -5,6 +5,7 @@ using System; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.RazorPages; +using Resources = Microsoft.AspNetCore.Mvc.RazorPages.Resources; namespace Microsoft.Extensions.DependencyInjection { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs index 35a6e75a45..2f17cfbbca 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/MvcRazorPagesMvcCoreBuilderExtensions.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.AspNetCore.Mvc.RazorPages.Internal; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using Resources = Microsoft.AspNetCore.Mvc.RazorPages.Resources; namespace Microsoft.Extensions.DependencyInjection { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/HandleOptionsRequestsPageFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/HandleOptionsRequestsPageFilter.cs new file mode 100644 index 0000000000..0c9221e507 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/HandleOptionsRequestsPageFilter.cs @@ -0,0 +1,55 @@ +// 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.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure +{ + /// + /// A filter that handles OPTIONS requests page when no handler method is available. + /// + /// a) MVC treats no handler being selected no differently than a page having no handler, both execute the + /// page. + /// b) A common model for programming Razor Pages is to initialize content required by a page in the + /// OnGet handler. Executing a page without running the handler may result in runtime exceptions - + /// e.g. null ref or out of bounds exception if you expected a property or collection to be initialized. + /// + /// + /// Some web crawlers use OPTIONS request when probing servers. In the absence of an uncommon OnOptions + /// handler, executing the page will likely result in runtime errors as described in earlier. This filter + /// attempts to avoid this pit of failure by handling OPTIONS requests and returning a 200 if no handler is selected. + /// + /// + internal sealed class HandleOptionsRequestsPageFilter : IPageFilter, IOrderedFilter + { + /// + /// Ordered to run after filters with default order. + /// + public int Order => 1000; + + public void OnPageHandlerExecuted(PageHandlerExecutedContext context) + { + } + + public void OnPageHandlerExecuting(PageHandlerExecutingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.HandlerMethod == null && + context.Result == null && + HttpMethods.IsOptions(context.HttpContext.Request.Method)) + { + context.Result = new OkResult(); + } + } + + public void OnPageHandlerSelected(PageHandlerSelectedContext context) + { + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/HandlerParameterDescriptor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/HandlerParameterDescriptor.cs index cc1be6d7b8..099b86192c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/HandlerParameterDescriptor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/HandlerParameterDescriptor.cs @@ -3,7 +3,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs index 7e1dbb47c2..806a032c35 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs @@ -4,10 +4,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.RazorPages.Internal; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure @@ -75,20 +80,22 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { var descriptor = new PageActionDescriptor { + ActionConstraints = selector.ActionConstraints.ToList(), + AreaName = model.AreaName, AttributeRouteInfo = new AttributeRouteInfo { Name = selector.AttributeRouteModel.Name, Order = selector.AttributeRouteModel.Order ?? 0, - Template = selector.AttributeRouteModel.Template, + Template = TransformPageRoute(model, selector), SuppressLinkGeneration = selector.AttributeRouteModel.SuppressLinkGeneration, SuppressPathMatching = selector.AttributeRouteModel.SuppressPathMatching, }, DisplayName = $"Page: {model.ViewEnginePath}", + EndpointMetadata = selector.EndpointMetadata.ToList(), FilterDescriptors = Array.Empty(), Properties = new Dictionary(model.Properties), RelativePath = model.RelativePath, ViewEnginePath = model.ViewEnginePath, - AreaName = model.AreaName, }; foreach (var kvp in model.RouteValues) @@ -107,5 +114,37 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure actions.Add(descriptor); } } + + private static string TransformPageRoute(PageRouteModel model, SelectorModel selectorModel) + { + model.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out var transformer); + var pageRouteTransformer = transformer as IOutboundParameterTransformer; + + // Transformer not set on page route + if (pageRouteTransformer == null) + { + return selectorModel.AttributeRouteModel.Template; + } + + var pageRouteMetadata = selectorModel.EndpointMetadata.OfType().SingleOrDefault(); + if (pageRouteMetadata == null) + { + // Selector does not have expected metadata + // This selector was likely configured by AddPageRouteModelConvention + // Use the existing explicitly configured template + return selectorModel.AttributeRouteModel.Template; + } + + var segments = pageRouteMetadata.PageRoute.Split('/'); + for (var i = 0; i < segments.Length; i++) + { + segments[i] = pageRouteTransformer.TransformOutbound(segments[i]); + } + + var transformedPageRoute = string.Join("/", segments); + + // Combine transformed page route with template + return AttributeRouteModel.CombineTemplates(transformedPageRoute, pageRouteMetadata.RouteTemplate); + } } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageBoundPropertyDescriptor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageBoundPropertyDescriptor.cs index 4bb1f43ca9..cb02222300 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageBoundPropertyDescriptor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageBoundPropertyDescriptor.cs @@ -3,7 +3,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageResultExecutor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageResultExecutor.cs index 10539c0b2a..967c6fa07d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageResultExecutor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageResultExecutor.cs @@ -76,13 +76,29 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure } var viewContext = result.Page.ViewContext; + var pageAdapter = new RazorPageAdapter(result.Page, pageContext.ActionDescriptor.DeclaredModelTypeInfo); + viewContext.View = new RazorView( _razorViewEngine, _razorPageActivator, viewStarts, - new RazorPageAdapter(result.Page), + pageAdapter, _htmlEncoder, - _diagnosticSource); + _diagnosticSource) + { + OnAfterPageActivated = (page, currentViewContext) => + { + if (page != pageAdapter) + { + return; + } + + // ViewContext is always activated with the "right" ViewData type. + // Copy that over to the PageContext since PageContext.ViewData is exposed + // as the ViewData property on the Page that the user works with. + pageContext.ViewData = currentViewContext.ViewData; + }, + }; return ExecuteAsync(viewContext, result.ContentType, result.StatusCode); } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/RazorPageAdapter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/RazorPageAdapter.cs index 452161083b..52c319f22a 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/RazorPageAdapter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/RazorPageAdapter.cs @@ -14,10 +14,12 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure // // The page gets activated before handler methods run, but the RazorView will also activate // each page. - public class RazorPageAdapter : IRazorPage + public class RazorPageAdapter : IRazorPage, IModelTypeProvider { private readonly RazorPageBase _page; + private readonly Type _modelType; + [Obsolete("This constructor is obsolete and will be removed in a future version.")] public RazorPageAdapter(RazorPageBase page) { if (page == null) @@ -28,6 +30,12 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure _page = page; } + public RazorPageAdapter(RazorPageBase page, Type modelType) + { + _page = page ?? throw new ArgumentNullException(nameof(page)); + _modelType = modelType ?? throw new ArgumentNullException(nameof(modelType)); + } + public ViewContext ViewContext { get { return _page.ViewContext; } @@ -75,5 +83,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { return _page.ExecuteAsync(); } + + Type IModelTypeProvider.GetModelType() => _modelType; } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/ServiceBasedPageModelActivatorProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/ServiceBasedPageModelActivatorProvider.cs new file mode 100644 index 0000000000..262d0cab21 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/ServiceBasedPageModelActivatorProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure +{ + /// + /// that uses type activation to create Razor Page instances. + /// + public class ServiceBasedPageModelActivatorProvider : IPageModelActivatorProvider + { + public Func CreateActivator(CompiledPageActionDescriptor descriptor) + { + if (descriptor == null) + { + throw new ArgumentNullException(nameof(descriptor)); + } + + var modelType = descriptor.ModelTypeInfo?.AsType(); + if (modelType == null) + { + throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull( + nameof(descriptor.ModelTypeInfo), + nameof(descriptor)), + nameof(descriptor)); + } + + return context => + { + return context.HttpContext.RequestServices.GetRequiredService(modelType); + }; + } + + public Action CreateReleaser(CompiledPageActionDescriptor descriptor) + { + return null; + } + } +} + diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/AutoValidateAntiforgeryPageApplicationModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/AutoValidateAntiforgeryPageApplicationModelProvider.cs deleted file mode 100644 index 2103a4606b..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/AutoValidateAntiforgeryPageApplicationModelProvider.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using Microsoft.AspNetCore.Mvc.ApplicationModels; - -namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal -{ - public class AutoValidateAntiforgeryPageApplicationModelProvider : IPageApplicationModelProvider - { - // The order is set to execute after the DefaultPageApplicationModelProvider. - public int Order => -1000 + 10; - - public void OnProvidersExecuted(PageApplicationModelProviderContext context) - { - } - - public void OnProvidersExecuting(PageApplicationModelProviderContext context) - { - if (context == null) - { - throw new ArgumentNullException(nameof(context)); - } - - var pageApplicationModel = context.PageApplicationModel; - - // Always require an antiforgery token on post - pageApplicationModel.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageActionDescriptorBuilder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageActionDescriptorBuilder.cs index 29f7214448..41055bf878 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageActionDescriptorBuilder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageActionDescriptorBuilder.cs @@ -49,6 +49,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal ActionConstraints = actionDescriptor.ActionConstraints, AttributeRouteInfo = actionDescriptor.AttributeRouteInfo, BoundProperties = boundProperties, + EndpointMetadata = actionDescriptor.EndpointMetadata, FilterDescriptors = filters, HandlerMethods = handlerMethods, HandlerTypeInfo = applicationModel.HandlerType, diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageArgumentBinder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageArgumentBinder.cs index 7b3310bbdd..9fd638c10c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageArgumentBinder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageArgumentBinder.cs @@ -30,7 +30,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal ParameterType = type, }; +#pragma warning disable CS0618 // Type or member is obsolete return await _parameterBinder.BindModelAsync(pageContext, valueProvider, parameterDescriptor, value); +#pragma warning restore CS0618 // Type or member is obsolete } private static async Task GetCompositeValueProvider(PageContext pageContext) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageHandlerMethodSelector.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageHandlerMethodSelector.cs index 2369705ed7..e2a619ca32 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageHandlerMethodSelector.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/DefaultPageHandlerMethodSelector.cs @@ -3,9 +3,9 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure { @@ -57,8 +57,10 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure if (ambiguousMatches == null) { - ambiguousMatches = new List(); - ambiguousMatches.Add(bestMatch); + ambiguousMatches = new List + { + bestMatch + }; } ambiguousMatches.Add(handler); @@ -165,13 +167,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure private static string GetHandlerName(PageContext context) { - var handlerName = Convert.ToString(context.RouteData.Values[Handler]); + var handlerName = Convert.ToString(context.RouteData.Values[Handler], CultureInfo.InvariantCulture); if (!string.IsNullOrEmpty(handlerName)) { return handlerName; } - if (context.HttpContext.Request.Query.TryGetValue(Handler, out StringValues queryValues)) + if (context.HttpContext.Request.Query.TryGetValue(Handler, out var queryValues)) { return queryValues[0]; } @@ -192,4 +194,4 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure return null; } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs index c68ee3ed65..fadb391b90 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Linq; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Razor.Language; using Microsoft.Extensions.FileProviders; @@ -18,11 +19,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal private readonly IFileProvider _fileProvider; private readonly string[] _searchPatterns; private readonly string[] _additionalFilesToTrack; + private readonly bool _watchForChanges; public PageActionDescriptorChangeProvider( RazorTemplateEngine templateEngine, IRazorViewEngineFileProviderAccessor fileProviderAccessor, - IOptions razorPagesOptions) + IOptions razorPagesOptions, + IOptions razorViewEngineOptions) { if (templateEngine == null) { @@ -39,6 +42,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal throw new ArgumentNullException(nameof(razorPagesOptions)); } + _watchForChanges = razorViewEngineOptions.Value.AllowRecompilingViewsOnFileChange; + if (!_watchForChanges) + { + // No need to do any additional work if we aren't going to be watching for file changes. + return; + } + _fileProvider = fileProviderAccessor.FileProvider; var rootDirectory = razorPagesOptions.Value.RootDirectory; @@ -84,6 +94,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public IChangeToken GetChangeToken() { + if (!_watchForChanges) + { + return NullChangeToken.Singleton; + } + var changeTokens = new IChangeToken[_additionalFilesToTrack.Length + _searchPatterns.Length]; for (var i = 0; i < _additionalFilesToTrack.Length; i++) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionInvoker.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionInvoker.cs index 9684c711f0..4f91b0346e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionInvoker.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionInvoker.cs @@ -275,7 +275,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Pages have an implicit 'return Page()' even without a handler method. if (_result == null) { + _logger.ExecutingImplicitHandlerMethod(_pageContext); _result = new PageResult(); + _logger.ExecutedImplicitHandlerMethod(_result); } // Ensure ViewData is set on PageResult for backwards compatibility (For example, Identity UI accesses diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageBinderFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageBinderFactory.cs index 6fcc9e08c8..72fd33d754 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageBinderFactory.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageBinderFactory.cs @@ -163,7 +163,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal } } - private struct BinderItem + private readonly struct BinderItem { public BinderItem(IModelBinder modelBinder, ModelMetadata modelMetadata) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageLoggerExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageLoggerExtensions.cs index a5d7c960d4..1ff89f5512 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageLoggerExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageLoggerExtensions.cs @@ -16,7 +16,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public const string PageFilter = "Page Filter"; private static readonly Action _handlerMethodExecuting; + private static readonly Action _implicitHandlerMethodExecuting; private static readonly Action _handlerMethodExecuted; + private static readonly Action _implicitHandlerMethodExecuted; private static readonly Action _pageFilterShortCircuit; private static readonly Action _malformedPageDirective; private static readonly Action _unsupportedAreaPath; @@ -34,10 +36,20 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal "Executing handler method {HandlerName} with arguments ({Arguments}) - ModelState is {ValidationState}"); _handlerMethodExecuted = LoggerMessage.Define( - LogLevel.Debug, + LogLevel.Information, 102, "Executed handler method {HandlerName}, returned result {ActionResult}."); + _implicitHandlerMethodExecuting = LoggerMessage.Define( + LogLevel.Information, + 103, + "Executing an implicit handler method - ModelState is {ValidationState}"); + + _implicitHandlerMethodExecuted = LoggerMessage.Define( + LogLevel.Information, + 104, + "Executed an implicit handler method, returned result {ActionResult}."); + _pageFilterShortCircuit = LoggerMessage.Define( LogLevel.Debug, 3, @@ -73,7 +85,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { if (logger.IsEnabled(LogLevel.Information)) { - var handlerName = handler.MethodInfo.Name; + var handlerName = handler.MethodInfo.DeclaringType.FullName + "." + handler.MethodInfo.Name; string[] convertedArguments; if (arguments == null) @@ -95,15 +107,33 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal } } + public static void ExecutingImplicitHandlerMethod(this ILogger logger, PageContext context) + { + if (logger.IsEnabled(LogLevel.Information)) + { + var validationState = context.ModelState.ValidationState; + + _implicitHandlerMethodExecuting(logger, validationState, null); + } + } + public static void ExecutedHandlerMethod(this ILogger logger, PageContext context, HandlerMethodDescriptor handler, IActionResult result) { - if (logger.IsEnabled(LogLevel.Debug)) + if (logger.IsEnabled(LogLevel.Information)) { var handlerName = handler.MethodInfo.Name; _handlerMethodExecuted(logger, handlerName, Convert.ToString(result), null); } } + public static void ExecutedImplicitHandlerMethod(this ILogger logger, IActionResult result) + { + if (logger.IsEnabled(LogLevel.Information)) + { + _implicitHandlerMethodExecuted(logger, Convert.ToString(result), null); + } + } + public static void BeforeExecutingMethodOnFilter(this ILogger logger, string filterType, string methodName, IFilterMetadata filter) { _beforeExecutingMethodOnFilter(logger, filterType, methodName, filter.GetType().ToString(), null); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteMetadata.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteMetadata.cs new file mode 100644 index 0000000000..ec5390ea98 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteMetadata.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.AspNetCore.Mvc.RazorPages.Internal +{ + // This is used to store the uncombined parts of the final page route + internal class PageRouteMetadata + { + public PageRouteMetadata(string pageRoute, string routeTemplate) + { + PageRoute = pageRoute; + RouteTemplate = routeTemplate; + } + + public string PageRoute { get; } + public string RouteTemplate { get; } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs index e3d2d6289a..d6699015be 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs @@ -67,7 +67,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal if (!AttributeRouteModel.IsOverridePattern(routeTemplate) && string.Equals(IndexFileName, fileName, StringComparison.OrdinalIgnoreCase)) { - // For pages without an override route, and ending in /Index.cshtml, we want to allow + // For pages without an override route, and ending in /Index.cshtml, we want to allow // incoming routing, but force outgoing routes to match to the path sans /Index. selectorModel.AttributeRouteModel.SuppressLinkGeneration = true; @@ -159,6 +159,10 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal AttributeRouteModel = new AttributeRouteModel { Template = AttributeRouteModel.CombineTemplates(prefix, routeTemplate), + }, + EndpointMetadata = + { + new PageRouteMetadata(prefix, routeTemplate) } }; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageSaveTempDataPropertyFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageSaveTempDataPropertyFilter.cs index 73633bf59c..e3f3355fde 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageSaveTempDataPropertyFilter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageSaveTempDataPropertyFilter.cs @@ -23,7 +23,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages Subject = context.HandlerInstance; var tempData = _tempDataFactory.GetTempData(context.HttpContext); - SetPropertyVaules(tempData); + SetPropertyValues(tempData); } public void OnPageHandlerExecuted(PageHandlerExecutedContext context) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorProjectPageRouteModelProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorProjectPageRouteModelProvider.cs index 81cf7b6a42..23384fa9f7 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorProjectPageRouteModelProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorProjectPageRouteModelProvider.cs @@ -41,7 +41,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnProvidersExecuting(PageRouteModelProviderContext context) { - // When RootDirectory and AreaRootDirectory overlap, e.g. RootDirectory = /, AreaRootDirectoryy = /Areas; + // When RootDirectory and AreaRootDirectory overlap, e.g. RootDirectory = /, AreaRootDirectory = /Areas; // we need to ensure that the page is only route-able via the area route. By adding area routes first, // we'll ensure non area routes get skipped when it encounters an IsAlreadyRegistered check. @@ -57,11 +57,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { foreach (var item in _razorFileSystem.EnumerateItems(_pagesOptions.RootDirectory)) { - if (!IsRouteable(item)) - { - continue; - } - var relativePath = item.CombinedPath; if (context.RouteModels.Any(m => string.Equals(relativePath, m.RelativePath, StringComparison.OrdinalIgnoreCase))) { @@ -99,11 +94,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { foreach (var item in _razorFileSystem.EnumerateItems(AreaRootDirectory)) { - if (!IsRouteable(item)) - { - continue; - } - var relativePath = item.CombinedPath; if (context.RouteModels.Any(m => string.Equals(relativePath, m.RelativePath, StringComparison.OrdinalIgnoreCase))) { @@ -125,11 +115,5 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal } } } - - private static bool IsRouteable(RazorProjectItem item) - { - // Pages like _ViewImports should not be routable. - return !item.FileName.StartsWith("_", StringComparison.OrdinalIgnoreCase); - } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Microsoft.AspNetCore.Mvc.RazorPages.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Microsoft.AspNetCore.Mvc.RazorPages.csproj index f0be433ea5..1655f9da77 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Microsoft.AspNetCore.Mvc.RazorPages.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Microsoft.AspNetCore.Mvc.RazorPages.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC Razor Pages. @@ -10,16 +10,6 @@ - - - - - - - - - - diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs index bf4780d165..eaa15ff9ef 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs @@ -81,6 +81,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages } } - private string DebuggerDisplayString() => $"{{ViewEnginePath = {nameof(ViewEnginePath)}, RelativePath = {nameof(RelativePath)}}}"; + private string DebuggerDisplayString => $"{{ViewEnginePath = {nameof(ViewEnginePath)}, RelativePath = {nameof(RelativePath)}}}"; } } \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageBase.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageBase.cs index 12b88777e8..3dda563ebb 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageBase.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageBase.cs @@ -149,7 +149,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages /// Creates a that produces a response. /// /// The created for the response. - [NonAction] public virtual BadRequestResult BadRequest() => new BadRequestResult(); @@ -158,7 +157,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages /// /// An error object to be returned to the client. /// The created for the response. - [NonAction] public virtual BadRequestObjectResult BadRequest(object error) => new BadRequestObjectResult(error); @@ -167,7 +165,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages /// /// The containing errors to be returned to the client. /// The created for the response. - [NonAction] public virtual BadRequestObjectResult BadRequest(ModelStateDictionary modelState) { if (modelState == null) @@ -1217,6 +1214,105 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages public virtual UnauthorizedResult Unauthorized() => new UnauthorizedResult(); + /// + /// Creates a by specifying the name of a partial to render. + /// + /// The partial name. + /// The created object for the response. + public virtual PartialViewResult Partial(string viewName) + { + return Partial(viewName, model: null); + } + + /// + /// Creates a by specifying the name of a partial to render and the model object. + /// + /// The partial name. + /// The model to be passed into the partial. + /// The created object for the response. + public virtual PartialViewResult Partial(string viewName, object model) + { + ViewContext.ViewData.Model = model; + + return new PartialViewResult + { + ViewName = viewName, + ViewData = ViewContext.ViewData + }; + } + + #region ViewComponentResult + /// + /// Creates a by specifying the name of a view component to render. + /// + /// + /// The view component name. Can be a view component + /// or + /// . + /// The created object for the response. + public virtual ViewComponentResult ViewComponent(string componentName) + { + return ViewComponent(componentName, arguments: null); + } + + /// + /// Creates a by specifying the of a view component to + /// render. + /// + /// The view component . + /// The created object for the response. + public virtual ViewComponentResult ViewComponent(Type componentType) + { + return ViewComponent(componentType, arguments: null); + } + + /// + /// Creates a by specifying the name of a view component to render. + /// + /// + /// The view component name. Can be a view component + /// or + /// . + /// + /// An with properties representing arguments to be passed to the invoked view component + /// method. Alternatively, an instance + /// containing the invocation arguments. + /// + /// The created object for the response. + public virtual ViewComponentResult ViewComponent(string componentName, object arguments) + { + return new ViewComponentResult + { + ViewComponentName = componentName, + Arguments = arguments, + ViewData = ViewContext.ViewData, + TempData = TempData + }; + } + + /// + /// Creates a by specifying the of a view component to + /// render. + /// + /// The view component . + /// + /// An with properties representing arguments to be passed to the invoked view component + /// method. Alternatively, an instance + /// containing the invocation arguments. + /// + /// The created object for the response. + public virtual ViewComponentResult ViewComponent(Type componentType, object arguments) + { + return new ViewComponentResult + { + ViewComponentType = componentType, + Arguments = arguments, + ViewData = ViewContext.ViewData, + TempData = TempData + }; + } + #endregion + /// /// Updates the specified instance using values from the 's current /// . diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageModel.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageModel.cs index ce5a76af27..17384ca1ce 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageModel.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/PageModel.cs @@ -141,7 +141,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages } /// - /// Gets or sets used by . + /// Gets the . /// public ViewDataDictionary ViewData => PageContext?.ViewData; @@ -524,7 +524,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages /// Creates a that produces a response. /// /// The created for the response. - [NonAction] public virtual BadRequestResult BadRequest() => new BadRequestResult(); @@ -533,7 +532,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages /// /// An error object to be returned to the client. /// The created for the response. - [NonAction] public virtual BadRequestObjectResult BadRequest(object error) => new BadRequestObjectResult(error); @@ -542,7 +540,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages /// /// The containing errors to be returned to the client. /// The created for the response. - [NonAction] public virtual BadRequestObjectResult BadRequest(ModelStateDictionary modelState) { if (modelState == null) @@ -1617,6 +1614,105 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages public virtual UnauthorizedResult Unauthorized() => new UnauthorizedResult(); + /// + /// Creates a by specifying the name of a partial to render. + /// + /// The partial name. + /// The created object for the response. + public virtual PartialViewResult Partial(string viewName) + { + return Partial(viewName, model: null); + } + + /// + /// Creates a by specifying the name of a partial to render and the model object. + /// + /// The partial name. + /// The model to be passed into the partial. + /// The created object for the response. + public virtual PartialViewResult Partial(string viewName, object model) + { + ViewData.Model = model; + + return new PartialViewResult + { + ViewName = viewName, + ViewData = ViewData + }; + } + + #region ViewComponentResult + /// + /// Creates a by specifying the name of a view component to render. + /// + /// + /// The view component name. Can be a view component + /// or + /// . + /// The created object for the response. + public virtual ViewComponentResult ViewComponent(string componentName) + { + return ViewComponent(componentName, arguments: null); + } + + /// + /// Creates a by specifying the of a view component to + /// render. + /// + /// The view component . + /// The created object for the response. + public virtual ViewComponentResult ViewComponent(Type componentType) + { + return ViewComponent(componentType, arguments: null); + } + + /// + /// Creates a by specifying the name of a view component to render. + /// + /// + /// The view component name. Can be a view component + /// or + /// . + /// + /// An with properties representing arguments to be passed to the invoked view component + /// method. Alternatively, an instance + /// containing the invocation arguments. + /// + /// The created object for the response. + public virtual ViewComponentResult ViewComponent(string componentName, object arguments) + { + return new ViewComponentResult + { + ViewComponentName = componentName, + Arguments = arguments, + ViewData = ViewData, + TempData = TempData + }; + } + + /// + /// Creates a by specifying the of a view component to + /// render. + /// + /// The view component . + /// + /// An with properties representing arguments to be passed to the invoked view component + /// method. Alternatively, an instance + /// containing the invocation arguments. + /// + /// The created object for the response. + public virtual ViewComponentResult ViewComponent(Type componentType, object arguments) + { + return new ViewComponentResult + { + ViewComponentType = componentType, + Arguments = arguments, + ViewData = ViewData, + TempData = TempData + }; + } + #endregion + /// /// Validates the specified instance. /// @@ -1657,7 +1753,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages return ModelState.IsValid; } -#region IAsyncPageFilter \ IPageFilter + #region IAsyncPageFilter \ IPageFilter /// /// Called after a handler method has been selected, but before model binding occurs. /// @@ -1724,6 +1820,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages OnPageHandlerExecuted(await next()); } } -#endregion + #endregion } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Properties/AssemblyInfo.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Properties/AssemblyInfo.cs index 458502d802..5f7daf43f9 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Properties/AssemblyInfo.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/Properties/AssemblyInfo.cs @@ -4,4 +4,5 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.IntegrationTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.RazorPages.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptions.cs index b3b0b15e11..38bd6f7988 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptions.cs @@ -16,6 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages { private readonly CompatibilitySwitch _allowAreas; private readonly CompatibilitySwitch _allowMappingHeadRequestsToGetHandler; + private readonly CompatibilitySwitch _allowsDefaultHandlingForOptionsRequests; private readonly ICompatibilitySwitch[] _switches; private string _root = "/Pages"; @@ -24,11 +25,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages { _allowAreas = new CompatibilitySwitch(nameof(AllowAreas)); _allowMappingHeadRequestsToGetHandler = new CompatibilitySwitch(nameof(AllowMappingHeadRequestsToGetHandler)); + _allowsDefaultHandlingForOptionsRequests = new CompatibilitySwitch(nameof(AllowDefaultHandlingForOptionsRequests)); _switches = new ICompatibilitySwitch[] { _allowAreas, _allowMappingHeadRequestsToGetHandler, + _allowsDefaultHandlingForOptionsRequests, }; } @@ -134,6 +137,45 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages set => _allowMappingHeadRequestsToGetHandler.Value = value; } + /// + /// Gets or sets a value that determines if HTTP requests with the OPTIONS method are handled by default, if + /// no handler is available. + /// + /// + /// The default value is if the version is + /// or later; otherwise. + /// + /// + /// + /// Razor Pages uses the current request's HTTP method to select a handler method. When no handler is available or selected, + /// the page is immediately executed. This may cause runtime errors if the page relies on the handler method to execute + /// and initialize some state. This setting attempts to avoid this class of error for HTTP OPTIONS requests by + /// returning a 200 OK response. + /// + /// + /// 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 of the value 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 then + /// this setting will have value true unless explicitly configured. + /// + /// + /// If the application's compatibility version is set to or + /// lower then this setting will have value true unless explicitly configured. + /// + /// + public bool AllowDefaultHandlingForOptionsRequests + { + get => _allowsDefaultHandlingForOptionsRequests.Value; + set => _allowsDefaultHandlingForOptionsRequests.Value = value; + } + IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable)_switches).GetEnumerator(); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptionsConfigureCompatibilityOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptionsConfigureCompatibilityOptions.cs index 8f5d21d8bc..49596d55b2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptionsConfigureCompatibilityOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptionsConfigureCompatibilityOptions.cs @@ -29,6 +29,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages values[nameof(RazorPagesOptions.AllowMappingHeadRequestsToGetHandler)] = true; } + if (Version >= CompatibilityVersion.Version_2_2) + { + values[nameof(RazorPagesOptions.AllowDefaultHandlingForOptionsRequests)] = true; + } + return values; } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/AnchorTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/AnchorTagHelper.cs index 175523d2fb..5eefa99543 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/AnchorTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/AnchorTagHelper.cs @@ -76,7 +76,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// The name of the area. /// /// - /// Must be null if or is non-null. + /// Must be null if is non-null. /// [HtmlAttributeName(AreaAttributeName)] public string Area { get; set; } @@ -86,7 +86,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// /// /// Must be null if or , - /// or is non-null. + /// is non-null. /// [HtmlAttributeName(PageAttributeName)] public string Page { get; set; } @@ -280,4 +280,4 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers output.MergeAttributes(tagBuilder); } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs index f1d6730ae0..bf327f30ca 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs @@ -3,9 +3,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Internal; @@ -20,10 +21,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache public class CacheTagKey : IEquatable { private static readonly char[] AttributeSeparator = new[] { ',' }; - private static readonly Func CookieAcccessor = (c, key) => c[key]; + private static readonly Func CookieAccessor = (c, key) => c[key]; private static readonly Func HeaderAccessor = (c, key) => c[key]; private static readonly Func QueryAccessor = (c, key) => c[key]; - private static readonly Func RouteValueAccessor = (c, key) => c[key]?.ToString(); + private static readonly Func RouteValueAccessor = (c, key) => + Convert.ToString(c[key], CultureInfo.InvariantCulture); private const string CacheKeyTokenSeparator = "||"; private const string VaryByName = "VaryBy"; @@ -32,6 +34,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache private const string VaryByRouteName = "VaryByRoute"; private const string VaryByCookieName = "VaryByCookie"; private const string VaryByUserName = "VaryByUser"; + private const string VaryByCulture = "VaryByCulture"; private readonly string _prefix; private readonly string _varyBy; @@ -43,7 +46,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache private readonly IList> _routeValues; private readonly IList> _cookies; private readonly bool _varyByUser; + private readonly bool _varyByCulture; private readonly string _username; + private readonly CultureInfo _requestCulture; + private readonly CultureInfo _requestUICulture; private string _generatedKey; private int? _hashcode; @@ -82,16 +88,26 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache _expiresOn = tagHelper.ExpiresOn; _expiresSliding = tagHelper.ExpiresSliding; _varyBy = tagHelper.VaryBy; - _cookies = ExtractCollection(tagHelper.VaryByCookie, request.Cookies, CookieAcccessor); + _cookies = ExtractCollection(tagHelper.VaryByCookie, request.Cookies, CookieAccessor); _headers = ExtractCollection(tagHelper.VaryByHeader, request.Headers, HeaderAccessor); _queries = ExtractCollection(tagHelper.VaryByQuery, request.Query, QueryAccessor); - _routeValues = ExtractCollection(tagHelper.VaryByRoute, tagHelper.ViewContext.RouteData.Values, RouteValueAccessor); + _routeValues = ExtractCollection( + tagHelper.VaryByRoute, + tagHelper.ViewContext.RouteData.Values, + RouteValueAccessor); _varyByUser = tagHelper.VaryByUser; + _varyByCulture = tagHelper.VaryByCulture; if (_varyByUser) { _username = httpContext.User?.Identity?.Name; } + + if (_varyByCulture) + { + _requestCulture = CultureInfo.CurrentCulture; + _requestUICulture = CultureInfo.CurrentUICulture; + } } // Internal for unit testing. @@ -137,6 +153,17 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache .Append(_username); } + if (_varyByCulture) + { + builder + .Append(CacheKeyTokenSeparator) + .Append(VaryByCulture) + .Append(CacheKeyTokenSeparator) + .Append(_requestCulture) + .Append(CacheKeyTokenSeparator) + .Append(_requestUICulture); + } + _generatedKey = builder.ToString(); return _generatedKey; @@ -164,13 +191,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache /// public override bool Equals(object obj) { - var other = obj as CacheTagKey; - if (other == null) + if (obj is CacheTagKey other) { - return false; + return Equals(other); } - return Equals(other); + return false; } /// @@ -185,8 +211,26 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache AreSame(_headers, other._headers) && AreSame(_queries, other._queries) && AreSame(_routeValues, other._routeValues) && - _varyByUser == other._varyByUser && - (!_varyByUser || string.Equals(other._username, _username, StringComparison.Ordinal)); + (_varyByUser == other._varyByUser && + (!_varyByUser || string.Equals(other._username, _username, StringComparison.Ordinal))) && + CultureEquals(); + + bool CultureEquals() + { + if (_varyByCulture != other._varyByCulture) + { + return false; + } + + if (!_varyByCulture) + { + // Neither has culture set. + return true; + } + + return _requestCulture.Equals(other._requestCulture) && + _requestUICulture.Equals(other._requestUICulture); + } } /// @@ -211,6 +255,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache hashCodeCombiner.Add(_expiresSliding); hashCodeCombiner.Add(_varyBy, StringComparer.Ordinal); hashCodeCombiner.Add(_username, StringComparer.Ordinal); + hashCodeCombiner.Add(_requestCulture); + hashCodeCombiner.Add(_requestUICulture); CombineCollectionHashCode(hashCodeCombiner, VaryByCookieName, _cookies); CombineCollectionHashCode(hashCodeCombiner, VaryByHeaderName, _headers); @@ -222,7 +268,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache return _hashcode.Value; } - private static IList> ExtractCollection(string keys, TSourceCollection collection, Func accessor) + private static IList> ExtractCollection( + string keys, + TSourceCollection collection, + Func accessor) { if (string.IsNullOrEmpty(keys)) { @@ -323,4 +372,4 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache return true; } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs index 34629a2664..7db418f9b4 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs @@ -88,7 +88,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache // There is a small race condition here between TryGetValue and TryAdd that might cause the // content to be computed more than once. We don't care about this race as the probability of // happening is very small and the impact is not critical. - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(creationOptions: TaskCreationOptions.RunContinuationsAsynchronously); _workers.TryAdd(key, tcs.Task); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs index 4a7bb989b7..de3fc45194 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs @@ -43,7 +43,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// The factory containing the private instance /// used by the . /// The to use. - public CacheTagHelper(CacheTagHelperMemoryCacheFactory factory, HtmlEncoder htmlEncoder) : base(htmlEncoder) + public CacheTagHelper( +#pragma warning disable PUB0001 // Pubternal type in public API + CacheTagHelperMemoryCacheFactory factory, +#pragma warning restore PUB0001 + HtmlEncoder htmlEncoder) + : base(htmlEncoder) { MemoryCache = factory.Cache; } @@ -103,7 +108,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var options = GetMemoryCacheEntryOptions(); options.AddExpirationToken(new CancellationChangeToken(tokenSource.Token)); options.SetSize(PlaceholderSize); - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(creationOptions: TaskCreationOptions.RunContinuationsAsynchronously); // The returned value is ignored, we only do this so that // the compiler doesn't complain about the returned task @@ -136,7 +141,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // can't be put inside a using block. entry.Dispose(); - // Set the result on the TCS once we've commited the entry to the cache since commiting to the cache + // Set the result on the TCS once we've committed the entry to the cache since commiting to the cache // may throw. tcs.SetResult(content); return content; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs index ca7dfdcb70..9ebd8a7717 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Globalization; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; @@ -20,6 +21,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers private const string VaryByRouteAttributeName = "vary-by-route"; private const string VaryByCookieAttributeName = "vary-by-cookie"; private const string VaryByUserAttributeName = "vary-by-user"; + private const string VaryByCultureAttributeName = "vary-by-culture"; private const string ExpiresOnAttributeName = "expires-on"; private const string ExpiresAfterAttributeName = "expires-after"; private const string ExpiresSlidingAttributeName = "expires-sliding"; @@ -93,6 +95,16 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(VaryByUserAttributeName)] public bool VaryByUser { get; set; } + /// + /// Gets or sets a value that determines if the cached result is to be varied by request culture. + /// + /// Setting this to true would result in the result to be varied by + /// and . + /// + /// + [HtmlAttributeName(VaryByCultureAttributeName)] + public bool VaryByCulture { get; set; } + /// /// Gets or sets the exact the cache entry should be evicted. /// @@ -117,4 +129,4 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(EnabledAttributeName)] public bool Enabled { get; set; } = true; } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/FormActionTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/FormActionTagHelper.cs index 18c89f37e4..66d22ea10c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/FormActionTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/FormActionTagHelper.cs @@ -167,9 +167,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } /// - /// Does nothing if user provides an formaction attribute. + /// Does nothing if user provides an FormAction attribute. /// - /// Thrown if formaction attribute is provided and , , + /// Thrown if FormAction attribute is provided and , , /// or are non-null or if the user provided asp-route-* attributes. /// Also thrown if and one or both of and /// are non-null @@ -186,7 +186,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers throw new ArgumentNullException(nameof(output)); } - // If "formaction" is already set, it means the user is attempting to use a normal button or input element. + // If "FormAction" is already set, it means the user is attempting to use a normal button or input element. if (output.Attributes.ContainsName(FormAction)) { if (Action != null || @@ -198,7 +198,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Route != null || (_routeValues != null && _routeValues.Count > 0)) { - // User specified a formaction and one of the bound attributes; can't override that formaction + // User specified a FormAction and one of the bound attributes; can't override that FormAction // attribute. throw new InvalidOperationException( Resources.FormatFormActionTagHelper_CannotOverrideFormAction( diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ImageTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ImageTagHelper.cs index 587dfc3567..97989b7754 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ImageTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ImageTagHelper.cs @@ -4,11 +4,13 @@ using System; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc.TagHelpers { @@ -27,8 +29,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers private const string AppendVersionAttributeName = "asp-append-version"; private const string SrcAttributeName = "src"; - private FileVersionProvider _fileVersionProvider; - /// /// Creates a new . /// @@ -36,6 +36,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// The . /// The to use. /// The . + [Obsolete("This constructor is obsolete and will be removed in a future version.")] public ImageTagHelper( IHostingEnvironment hostingEnvironment, IMemoryCache cache, @@ -47,6 +48,30 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Cache = cache; } + /// + /// Creates a new . + /// + /// The . + /// The . + /// The . + /// The to use. + /// The . + // Decorated with ActivatorUtilitiesConstructor since we want to influence tag helper activation + // to use this constructor in the default case. + [ActivatorUtilitiesConstructor] + public ImageTagHelper( + IHostingEnvironment hostingEnvironment, + TagHelperMemoryCacheProvider cacheProvider, + IFileVersionProvider fileVersionProvider, + HtmlEncoder htmlEncoder, + IUrlHelperFactory urlHelperFactory) + : base(urlHelperFactory, htmlEncoder) + { + HostingEnvironment = hostingEnvironment; + Cache = cacheProvider.Cache; + FileVersionProvider = fileVersionProvider; + } + /// public override int Order => -1000; @@ -68,9 +93,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(AppendVersionAttributeName)] public bool AppendVersion { get; set; } - protected IHostingEnvironment HostingEnvironment { get; } + protected internal IHostingEnvironment HostingEnvironment { get; } - protected IMemoryCache Cache { get; } + protected internal IMemoryCache Cache { get; } + + internal IFileVersionProvider FileVersionProvider { get; private set; } /// public override void Process(TagHelperContext context, TagHelperOutput output) @@ -97,18 +124,15 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // not function properly. Src = output.Attributes[SrcAttributeName].Value as string; - output.Attributes.SetAttribute(SrcAttributeName, _fileVersionProvider.AddFileVersionToPath(Src)); + output.Attributes.SetAttribute(SrcAttributeName, FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, Src)); } } private void EnsureFileVersionProvider() { - if (_fileVersionProvider == null) + if (FileVersionProvider == null) { - _fileVersionProvider = new FileVersionProvider( - HostingEnvironment.WebRootFileProvider, - Cache, - ViewContext.HttpContext.Request.PathBase); + FileVersionProvider = ViewContext.HttpContext.RequestServices.GetRequiredService(); } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/FileVersionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/FileVersionProvider.cs deleted file mode 100644 index 19bdf34d09..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/FileVersionProvider.cs +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.FileProviders; - -namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal -{ - /// - /// Provides version hash for a specified file. - /// - public class FileVersionProvider - { - private const string VersionKey = "v"; - private static readonly char[] QueryStringAndFragmentTokens = new [] { '?', '#' }; - private readonly IFileProvider _fileProvider; - private readonly IMemoryCache _cache; - private readonly PathString _requestPathBase; - - /// - /// Creates a new instance of . - /// - /// The file provider to get and watch files. - /// where versioned urls of files are cached. - /// The base path for the current HTTP request. - public FileVersionProvider( - IFileProvider fileProvider, - IMemoryCache cache, - PathString requestPathBase) - { - if (fileProvider == null) - { - throw new ArgumentNullException(nameof(fileProvider)); - } - - if (cache == null) - { - throw new ArgumentNullException(nameof(cache)); - } - - _fileProvider = fileProvider; - _cache = cache; - _requestPathBase = requestPathBase; - } - - /// - /// Adds version query parameter to the specified file path. - /// - /// The path of the file to which version should be added. - /// Path containing the version query string. - /// - /// The version query string is appended with the key "v". - /// - public string AddFileVersionToPath(string path) - { - if (path == null) - { - throw new ArgumentNullException(nameof(path)); - } - - var resolvedPath = path; - - var queryStringOrFragmentStartIndex = path.IndexOfAny(QueryStringAndFragmentTokens); - if (queryStringOrFragmentStartIndex != -1) - { - resolvedPath = path.Substring(0, queryStringOrFragmentStartIndex); - } - - Uri uri; - if (Uri.TryCreate(resolvedPath, UriKind.Absolute, out uri) && !uri.IsFile) - { - // Don't append version if the path is absolute. - return path; - } - - string value; - if (!_cache.TryGetValue(path, out value)) - { - var cacheEntryOptions = new MemoryCacheEntryOptions(); - cacheEntryOptions.AddExpirationToken(_fileProvider.Watch(resolvedPath)); - var fileInfo = _fileProvider.GetFileInfo(resolvedPath); - - if (!fileInfo.Exists && - _requestPathBase.HasValue && - resolvedPath.StartsWith(_requestPathBase.Value, StringComparison.OrdinalIgnoreCase)) - { - var requestPathBaseRelativePath = resolvedPath.Substring(_requestPathBase.Value.Length); - cacheEntryOptions.AddExpirationToken(_fileProvider.Watch(requestPathBaseRelativePath)); - fileInfo = _fileProvider.GetFileInfo(requestPathBaseRelativePath); - } - - if (fileInfo.Exists) - { - value = QueryHelpers.AddQueryString(path, VersionKey, GetHashForFile(fileInfo)); - } - else - { - // if the file is not in the current server. - value = path; - } - - value = _cache.Set(path, value, cacheEntryOptions); - } - - return value; - } - - private static string GetHashForFile(IFileInfo fileInfo) - { - using (var sha256 = CryptographyAlgorithms.CreateSHA256()) - { - using (var readStream = fileInfo.CreateReadStream()) - { - var hash = sha256.ComputeHash(readStream); - return WebEncoders.Base64UrlEncode(hash); - } - } - } - } -} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs index 8464d0bc90..ce891ad84e 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs @@ -113,8 +113,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal } var cacheKey = new GlobbingUrlKey(include, exclude); - List files; - if (Cache.TryGetValue(cacheKey, out files)) + if (Cache.TryGetValue(cacheKey, out List files)) { return files; } @@ -148,16 +147,21 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal matcher.AddExcludePatterns(trimmedExcludePatterns); } + var (matchedUrls, sizeInBytes) = FindFiles(matcher); + options.SetSize(sizeInBytes); + return Cache.Set( cacheKey, - FindFiles(matcher), + matchedUrls, options); } - private List FindFiles(Matcher matcher) + private (List matchedUrls, long sizeInBytes) FindFiles(Matcher matcher) { var matches = matcher.Execute(_baseGlobbingDirectory); var matchedUrls = new List(); + var sizeInBytes = 0L; + foreach (var matchedPath in matches.Files) { // Resolve the path to site root @@ -168,10 +172,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal { // Item doesn't already exist. Insert it. matchedUrls.Insert(~index, matchedUrl); + sizeInBytes += matchedUrl.Length * sizeof(char); } } - return matchedUrls; + return (matchedUrls, sizeInBytes); } private class PathComparer : IComparer @@ -231,9 +236,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal var result = 0; var xEnumerator = new StringTokenizer(xNoExt, PathSeparator).GetEnumerator(); var yEnumerator = new StringTokenizer(yNoExt, PathSeparator).GetEnumerator(); - StringSegment xSegment; StringSegment ySegment; - while (TryGetNextSegment(ref xEnumerator, out xSegment)) + while (TryGetNextSegment(ref xEnumerator, out var xSegment)) { if (!TryGetNextSegment(ref yEnumerator, out ySegment)) { @@ -348,7 +352,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal return new StringSegment(value.Buffer, offset, trimmedEnd - offset + 1); } - private struct GlobbingUrlKey : IEquatable + private readonly struct GlobbingUrlKey : IEquatable { public GlobbingUrlKey(string include, string exclude) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/LinkTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/LinkTagHelper.cs index 84655b04be..d082db1ef6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/LinkTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/LinkTagHelper.cs @@ -4,15 +4,19 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc.TagHelpers { @@ -33,13 +37,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlTargetElement("link", Attributes = AppendVersionAttributeName, TagStructure = TagStructure.WithoutEndTag)] public class LinkTagHelper : UrlResolutionTagHelper { - private static readonly string FallbackJavaScriptResourceName = typeof(LinkTagHelper).Namespace + ".compiler.resources.LinkTagHelper_FallbackJavaScript.js"; private const string HrefIncludeAttributeName = "asp-href-include"; private const string HrefExcludeAttributeName = "asp-href-exclude"; private const string FallbackHrefAttributeName = "asp-fallback-href"; + private const string SuppressFallbackIntegrityAttributeName = "asp-suppress-fallback-integrity"; private const string FallbackHrefIncludeAttributeName = "asp-fallback-href-include"; private const string FallbackHrefExcludeAttributeName = "asp-fallback-href-exclude"; private const string FallbackTestClassAttributeName = "asp-fallback-test-class"; @@ -48,10 +52,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers private const string AppendVersionAttributeName = "asp-append-version"; private const string HrefAttributeName = "href"; private const string RelAttributeName = "rel"; + private const string IntegrityAttributeName = "integrity"; private static readonly Func Compare = (a, b) => a - b; - private FileVersionProvider _fileVersionProvider; - private static readonly ModeAttributes[] ModeDetails = new[] { // Regular src with file version alone new ModeAttributes(Mode.AppendVersion, new[] { AppendVersionAttributeName }), @@ -101,6 +104,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// The . /// The . /// The . + [Obsolete("This constructor is obsolete and will be removed in a future version.")] public LinkTagHelper( IHostingEnvironment hostingEnvironment, IMemoryCache cache, @@ -110,8 +114,35 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers : base(urlHelperFactory, htmlEncoder) { HostingEnvironment = hostingEnvironment; - Cache = cache; JavaScriptEncoder = javaScriptEncoder; + Cache = cache; + } + + /// + /// Creates a new . + /// + /// The . + /// + /// The . + /// The . + /// The . + /// The . + // Decorated with ActivatorUtilitiesConstructor since we want to influence tag helper activation + // to use this constructor in the default case. + [ActivatorUtilitiesConstructor] + public LinkTagHelper( + IHostingEnvironment hostingEnvironment, + TagHelperMemoryCacheProvider cacheProvider, + IFileVersionProvider fileVersionProvider, + HtmlEncoder htmlEncoder, + JavaScriptEncoder javaScriptEncoder, + IUrlHelperFactory urlHelperFactory) + : base(urlHelperFactory, htmlEncoder) + { + HostingEnvironment = hostingEnvironment; + JavaScriptEncoder = javaScriptEncoder; + Cache = cacheProvider.Cache; + FileVersionProvider = fileVersionProvider; } /// @@ -147,6 +178,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(FallbackHrefAttributeName)] public string FallbackHref { get; set; } + /// + /// Boolean value that determines if an integrity hash will be compared with value. + /// + [HtmlAttributeName(SuppressFallbackIntegrityAttributeName)] + public bool SuppressFallbackIntegrity { get; set; } + /// /// Value indicating if file version should be appended to the href urls. /// @@ -197,14 +234,18 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(FallbackTestValueAttributeName)] public string FallbackTestValue { get; set; } - protected IHostingEnvironment HostingEnvironment { get; } + protected internal IHostingEnvironment HostingEnvironment { get; } - protected IMemoryCache Cache { get; } + protected internal IMemoryCache Cache { get; } protected JavaScriptEncoder JavaScriptEncoder { get; } // Internal for ease of use when testing. +#pragma warning disable PUB0001 // Pubternal type in public API protected internal GlobbingUrlBuilder GlobbingUrlBuilder { get; set; } +#pragma warning restore PUB0001 + + internal IFileVersionProvider FileVersionProvider { get; private set; } // Shared writer for determining the string content of a TagHelperAttribute's Value. private StringWriter StringWriter @@ -247,8 +288,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // not function properly. Href = output.Attributes[HrefAttributeName]?.Value as string; - Mode mode; - if (!AttributeMatcher.TryDetermineMode(context, ModeDetails, Compare, out mode)) + if (!AttributeMatcher.TryDetermineMode(context, ModeDetails, Compare, out var mode)) { // No attributes matched so we have nothing to do return; @@ -264,7 +304,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var existingAttribute = output.Attributes[index]; output.Attributes[index] = new TagHelperAttribute( existingAttribute.Name, - _fileVersionProvider.AddFileVersionToPath(Href), + FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, Href), existingAttribute.ValueStyle); } } @@ -285,8 +325,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers if (mode == Mode.Fallback && HasStyleSheetLinkType(output.Attributes)) { - string resolvedUrl; - if (TryResolveUrl(FallbackHref, resolvedUrl: out resolvedUrl)) + if (TryResolveUrl(FallbackHref, resolvedUrl: out string resolvedUrl)) { FallbackHref = resolvedUrl; } @@ -365,6 +404,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers continue; } + if (SuppressFallbackIntegrity && string.Equals(attribute.Name, IntegrityAttributeName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + attribute.WriteTo(StringWriter, HtmlEncoder); StringWriter.Write(' '); } @@ -380,17 +424,15 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers private bool HasStyleSheetLinkType(TagHelperAttributeList attributes) { - TagHelperAttribute relAttribute; - if (!attributes.TryGetAttribute(RelAttributeName, out relAttribute) || + if (!attributes.TryGetAttribute(RelAttributeName, out var relAttribute) || relAttribute.Value == null) { return false; } var attributeValue = relAttribute.Value; - var contentValue = attributeValue as IHtmlContent; var stringValue = attributeValue as string; - if (contentValue != null) + if (attributeValue is IHtmlContent contentValue) { contentValue.WriteTo(StringWriter, HtmlEncoder); stringValue = StringWriter.ToString(); @@ -400,7 +442,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } else if (stringValue == null) { - stringValue = attributeValue.ToString(); + stringValue = Convert.ToString(attributeValue, CultureInfo.InvariantCulture); } var hasRelStylesheet = string.Equals("stylesheet", stringValue, StringComparison.Ordinal); @@ -431,7 +473,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var valueToWrite = fallbackHrefs[i]; if (AppendVersion == true) { - valueToWrite = _fileVersionProvider.AddFileVersionToPath(fallbackHrefs[i]); + valueToWrite = FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, fallbackHrefs[i]); } // Must HTML-encode the href attribute value to ensure the written element is valid. Must also @@ -458,12 +500,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers private void EnsureFileVersionProvider() { - if (_fileVersionProvider == null) + if (FileVersionProvider == null) { - _fileVersionProvider = new FileVersionProvider( - HostingEnvironment.WebRootFileProvider, - Cache, - ViewContext.HttpContext.Request.PathBase); + FileVersionProvider = ViewContext.HttpContext.RequestServices.GetRequiredService(); } } @@ -503,7 +542,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { if (AppendVersion == true) { - hrefValue = _fileVersionProvider.AddFileVersionToPath(hrefValue); + hrefValue = FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, hrefValue); } builder @@ -532,4 +571,4 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Fallback = 2, } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Microsoft.AspNetCore.Mvc.TagHelpers.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Microsoft.AspNetCore.Mvc.TagHelpers.csproj index 75d40d73c2..5bd518d2f0 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Microsoft.AspNetCore.Mvc.TagHelpers.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Microsoft.AspNetCore.Mvc.TagHelpers.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC default tag helpers. Contains tag helpers for anchor tags, HTML input elements, caching, scripts, links (for CSS), and more. @@ -19,8 +19,6 @@ - - diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs index b8451f663e..617fa5d3a2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs @@ -22,6 +22,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { private const string ForAttributeName = "for"; private const string ModelAttributeName = "model"; + private const string FallbackAttributeName = "fallback-name"; + private const string OptionalAttributeName = "optional"; private object _model; private bool _hasModel; private bool _hasFor; @@ -32,7 +34,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers public PartialTagHelper( ICompositeViewEngine viewEngine, - IViewBufferScope viewBufferScope) +#pragma warning disable PUB0001 // Pubternal type in public API + IViewBufferScope viewBufferScope +#pragma warning restore PUB0001 + ) { _viewEngine = viewEngine ?? throw new ArgumentNullException(nameof(viewEngine)); _viewBufferScope = viewBufferScope ?? throw new ArgumentNullException(nameof(viewBufferScope)); @@ -71,6 +76,19 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } } + /// + /// When optional, executing the tag helper will no-op if the view cannot be located. + /// Otherwise will throw stating the view could not be found. + /// + [HtmlAttributeName(OptionalAttributeName)] + public bool Optional { get; set; } + + /// + /// View to lookup if the view specified by cannot be located. + /// + [HtmlAttributeName(FallbackAttributeName)] + public string FallbackName { get; set; } + /// /// A to pass into the partial view. /// @@ -93,14 +111,44 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers throw new ArgumentNullException(nameof(context)); } + // Reset the TagName. We don't want `partial` to render. + output.TagName = null; + + var result = FindView(Name); + var viewSearchedLocations = result.SearchedLocations; + var fallBackViewSearchedLocations = Enumerable.Empty(); + + if (!result.Success && !string.IsNullOrEmpty(FallbackName)) + { + result = FindView(FallbackName); + fallBackViewSearchedLocations = result.SearchedLocations; + } + + if (!result.Success) + { + if (Optional) + { + // Could not find the view or fallback view, but the partial is marked as optional. + return; + } + + var locations = Environment.NewLine + string.Join(Environment.NewLine, viewSearchedLocations); + var errorMessage = Resources.FormatViewEngine_PartialViewNotFound(Name, locations); + + if (!string.IsNullOrEmpty(FallbackName)) + { + locations = Environment.NewLine + string.Join(Environment.NewLine, result.SearchedLocations); + errorMessage += Environment.NewLine + Resources.FormatViewEngine_FallbackViewNotFound(FallbackName, locations); + } + + throw new InvalidOperationException(errorMessage); + } + var model = ResolveModel(); - var viewBuffer = new ViewBuffer(_viewBufferScope, Name, ViewBuffer.PartialViewPageSize); + var viewBuffer = new ViewBuffer(_viewBufferScope, result.ViewName, ViewBuffer.PartialViewPageSize); using (var writer = new ViewBufferTextWriter(viewBuffer, Encoding.UTF8)) { - await RenderPartialViewAsync(writer, model); - - // Reset the TagName. We don't want `partial` to render. - output.TagName = null; + await RenderPartialViewAsync(writer, model, result.View); output.Content.SetHtmlContent(viewBuffer); } } @@ -136,29 +184,26 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return ViewContext.ViewData.Model; } - private async Task RenderPartialViewAsync(TextWriter writer, object model) + private ViewEngineResult FindView(string partialName) { - var viewEngineResult = _viewEngine.GetView(ViewContext.ExecutingFilePath, Name, isMainPage: false); + var viewEngineResult = _viewEngine.GetView(ViewContext.ExecutingFilePath, partialName, isMainPage: false); var getViewLocations = viewEngineResult.SearchedLocations; if (!viewEngineResult.Success) { - viewEngineResult = _viewEngine.FindView(ViewContext, Name, isMainPage: false); + viewEngineResult = _viewEngine.FindView(ViewContext, partialName, isMainPage: false); } if (!viewEngineResult.Success) { var searchedLocations = Enumerable.Concat(getViewLocations, viewEngineResult.SearchedLocations); - var locations = string.Empty; - if (searchedLocations.Any()) - { - locations += Environment.NewLine + string.Join(Environment.NewLine, searchedLocations); - } - - throw new InvalidOperationException( - Resources.FormatViewEngine_PartialViewNotFound(Name, locations)); + return ViewEngineResult.NotFound(partialName, searchedLocations); } - var view = viewEngineResult.View; + return viewEngineResult; + } + + private async Task RenderPartialViewAsync(TextWriter writer, object model, IView view) + { // Determine which ViewData we should use to construct a new ViewData var baseViewData = ViewData ?? ViewContext.ViewData; var newViewData = new ViewDataDictionary(baseViewData, model); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs index 93ac7fa60e..f5b5d8c690 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs @@ -206,6 +206,20 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers internal static string FormatPartialTagHelper_InvalidModelAttributes(object p0, object p1, object p2) => string.Format(CultureInfo.CurrentCulture, GetString("PartialTagHelper_InvalidModelAttributes"), p0, p1, p2); + /// + /// The fallback partial view '{0}' was not found. The following locations were searched:{1} + /// + internal static string ViewEngine_FallbackViewNotFound + { + get => GetString("ViewEngine_FallbackViewNotFound"); + } + + /// + /// The fallback partial view '{0}' was not found. The following locations were searched:{1} + /// + internal static string FormatViewEngine_FallbackViewNotFound(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ViewEngine_FallbackViewNotFound"), p0, p1); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/RenderAtEndOfFormTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/RenderAtEndOfFormTagHelper.cs index d1e2537044..c819a38c98 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/RenderAtEndOfFormTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/RenderAtEndOfFormTagHelper.cs @@ -19,7 +19,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers public class RenderAtEndOfFormTagHelper : TagHelper { // This TagHelper's order must be greater than the FormTagHelper's. I.e it must be executed after - // FormTaghelper does. + // FormTagHelper does. /// public override int Order => -900; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx index 1d6166e955..96c56dff7f 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx @@ -159,4 +159,7 @@ Cannot use '{0}' with both '{1}' and '{2}' attributes. + + The fallback partial view '{0}' was not found. The following locations were searched:{1} + \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ScriptTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ScriptTagHelper.cs index 8faf17807f..c9b217c1da 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ScriptTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ScriptTagHelper.cs @@ -7,11 +7,14 @@ using System.IO; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc.TagHelpers { @@ -34,12 +37,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers private const string SrcExcludeAttributeName = "asp-src-exclude"; private const string FallbackSrcAttributeName = "asp-fallback-src"; private const string FallbackSrcIncludeAttributeName = "asp-fallback-src-include"; + private const string SuppressFallbackIntegrityAttributeName = "asp-suppress-fallback-integrity"; private const string FallbackSrcExcludeAttributeName = "asp-fallback-src-exclude"; private const string FallbackTestExpressionAttributeName = "asp-fallback-test"; private const string SrcAttributeName = "src"; + private const string IntegrityAttributeName = "integrity"; private const string AppendVersionAttributeName = "asp-append-version"; private static readonly Func Compare = (a, b) => a - b; - private FileVersionProvider _fileVersionProvider; private StringWriter _stringWriter; private static readonly ModeAttributes[] ModeDetails = new[] { @@ -83,6 +87,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// The . /// The . /// The . + [Obsolete("This constructor is obsolete and will be removed in a future version.")] public ScriptTagHelper( IHostingEnvironment hostingEnvironment, IMemoryCache cache, @@ -96,6 +101,34 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers JavaScriptEncoder = javaScriptEncoder; } + /// + /// Creates a new . + /// + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + // Decorated with ActivatorUtilitiesConstructor since we want to influence tag helper activation + // to use this constructor in the default case. + [ActivatorUtilitiesConstructor] + public ScriptTagHelper( + IHostingEnvironment hostingEnvironment, + TagHelperMemoryCacheProvider cacheProvider, + IFileVersionProvider fileVersionProvider, + HtmlEncoder htmlEncoder, + JavaScriptEncoder javaScriptEncoder, + IUrlHelperFactory urlHelperFactory) + : base(urlHelperFactory, htmlEncoder) + { + HostingEnvironment = hostingEnvironment; + Cache = cacheProvider.Cache; + JavaScriptEncoder = javaScriptEncoder; + + FileVersionProvider = fileVersionProvider; + } + /// public override int Order => -1000; @@ -129,6 +162,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(FallbackSrcAttributeName)] public string FallbackSrc { get; set; } + /// + /// Boolean value that determines if an integrity hash will be compared with value. + /// + [HtmlAttributeName(SuppressFallbackIntegrityAttributeName)] + public bool SuppressFallbackIntegrity { get; set; } + /// /// Value indicating if file version should be appended to src urls. /// @@ -161,14 +200,18 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(FallbackTestExpressionAttributeName)] public string FallbackTestExpression { get; set; } - protected IHostingEnvironment HostingEnvironment { get; } + protected internal IHostingEnvironment HostingEnvironment { get; } - protected IMemoryCache Cache { get; } + protected internal IMemoryCache Cache { get; private set; } + + internal IFileVersionProvider FileVersionProvider { get; private set; } protected JavaScriptEncoder JavaScriptEncoder { get; } // Internal for ease of use when testing. +#pragma warning disable PUB0001 // Pubternal type in public API protected internal GlobbingUrlBuilder GlobbingUrlBuilder { get; set; } +#pragma warning restore PUB0001 // Shared writer for determining the string content of a TagHelperAttribute's Value. private StringWriter StringWriter @@ -211,8 +254,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // not function properly. Src = output.Attributes[SrcAttributeName]?.Value as string; - Mode mode; - if (!AttributeMatcher.TryDetermineMode(context, ModeDetails, Compare, out mode)) + if (!AttributeMatcher.TryDetermineMode(context, ModeDetails, Compare, out var mode)) { // No attributes matched so we have nothing to do return; @@ -228,7 +270,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var existingAttribute = output.Attributes[index]; output.Attributes[index] = new TagHelperAttribute( existingAttribute.Name, - _fileVersionProvider.AddFileVersionToPath(Src), + FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, Src), existingAttribute.ValueStyle); } } @@ -249,8 +291,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers if (mode == Mode.Fallback) { - string resolvedUrl; - if (TryResolveUrl(FallbackSrc, resolvedUrl: out resolvedUrl)) + if (TryResolveUrl(FallbackSrc, resolvedUrl: out string resolvedUrl)) { FallbackSrc = resolvedUrl; } @@ -310,6 +351,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var attribute = attributes[i]; if (!attribute.Name.Equals(SrcAttributeName, StringComparison.OrdinalIgnoreCase)) { + if (SuppressFallbackIntegrity && string.Equals(IntegrityAttributeName, attribute.Name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + StringWriter.Write(' '); attribute.WriteTo(StringWriter, HtmlEncoder); } @@ -342,7 +388,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { if (AppendVersion == true) { - srcValue = _fileVersionProvider.AddFileVersionToPath(srcValue); + srcValue = FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, srcValue); } return srcValue; @@ -387,12 +433,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers private void EnsureFileVersionProvider() { - if (_fileVersionProvider == null) + if (FileVersionProvider == null) { - _fileVersionProvider = new FileVersionProvider( - HostingEnvironment.WebRootFileProvider, - Cache, - ViewContext.HttpContext.Request.PathBase); + FileVersionProvider = ViewContext.HttpContext.RequestServices.GetRequiredService(); } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs index 8075b5bf0e..91be1e59de 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs @@ -64,7 +64,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var copiedAttribute = false; // We iterate context.AllAttributes backwards since we prioritize TagHelperOutput values occurring - // before the current context.AllAttribtes[i]. + // before the current context.AllAttributes[i]. for (var i = context.AllAttributes.Count - 1; i >= 0; i--) { // We look for the original attribute so we can restore the exact attribute name the user typed in diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ValidationMessageTagHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ValidationMessageTagHelper.cs index 8858623013..85d8d07837 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ValidationMessageTagHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.TagHelpers/ValidationMessageTagHelper.cs @@ -71,11 +71,24 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers }; } + string message = null; + if (!output.IsContentModified) + { + var tagHelperContent = await output.GetChildContentAsync(); + + // We check for whitespace to detect scenarios such as: + // + // + if (!tagHelperContent.IsEmptyOrWhiteSpace) + { + message = tagHelperContent.GetContent(); + } + } var tagBuilder = Generator.GenerateValidationMessage( ViewContext, For.ModelExplorer, For.Name, - message: null, + message: message, tag: null, htmlAttributes: htmlAttributes); @@ -84,27 +97,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers output.MergeAttributes(tagBuilder); // Do not update the content if another tag helper targeting this element has already done so. - if (!output.IsContentModified) + if (!output.IsContentModified && tagBuilder.HasInnerHtml) { - // We check for whitespace to detect scenarios such as: - // - // - var childContent = await output.GetChildContentAsync(); - if (childContent.IsEmptyOrWhiteSpace) - { - // Provide default message text (if any) since there was nothing useful in the Razor source. - if (tagBuilder.HasInnerHtml) - { - output.Content.SetHtmlContent(tagBuilder.InnerHtml); - } - } - else - { - output.Content.SetHtmlContent(childContent); - } + output.Content.SetHtmlContent(tagBuilder.InnerHtml); } } } } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/CookieContainerHandler.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/CookieContainerHandler.cs index bde4250b50..dedcf35f86 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/CookieContainerHandler.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/RedirectHandler.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/RedirectHandler.cs index 63158713d3..9addd609ec 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/RedirectHandler.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Handlers/RedirectHandler.cs @@ -1,10 +1,11 @@ -// 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; using System.IO; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; @@ -28,7 +29,7 @@ namespace Microsoft.AspNetCore.Mvc.Testing.Handlers /// /// Creates a new instance of . /// - /// The maximun number of redirect responses to follow. It must be + /// The maximum number of redirect responses to follow. It must be /// equal or greater than 0. public RedirectHandler(int maxRedirects) { @@ -49,13 +50,14 @@ namespace Microsoft.AspNetCore.Mvc.Testing.Handlers protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var remainingRedirects = MaxRedirects; - + var redirectRequest = new HttpRequestMessage(); var originalRequestContent = HasBody(request) ? await DuplicateRequestContent(request) : null; + CopyRequestHeaders(request.Headers, redirectRequest.Headers); var response = await base.SendAsync(request, cancellationToken); while (IsRedirect(response) && remainingRedirects >= 0) { remainingRedirects--; - var redirectRequest = GetRedirectRequest(response, originalRequestContent); + UpdateRedirectRequest(response, redirectRequest, originalRequestContent); originalRequestContent = HasBody(redirectRequest) ? await DuplicateRequestContent(redirectRequest) : null; response = await base.SendAsync(redirectRequest, cancellationToken); } @@ -95,6 +97,16 @@ namespace Microsoft.AspNetCore.Mvc.Testing.Handlers } } + private static void CopyRequestHeaders( + HttpRequestHeaders originalRequestHeaders, + HttpRequestHeaders newRequestHeaders) + { + foreach (var header in originalRequestHeaders) + { + newRequestHeaders.Add(header.Key, header.Value); + } + } + private static async Task<(Stream originalBody, Stream copy)> CopyBody(HttpRequestMessage request) { var originalBody = await request.Content.ReadAsStreamAsync(); @@ -116,8 +128,9 @@ namespace Microsoft.AspNetCore.Mvc.Testing.Handlers return (originalBody, bodyCopy); } - private static HttpRequestMessage GetRedirectRequest( + private static void UpdateRedirectRequest( HttpResponseMessage response, + HttpRequestMessage redirect, HttpContent originalContent) { var location = response.Headers.Location; @@ -128,34 +141,31 @@ namespace Microsoft.AspNetCore.Mvc.Testing.Handlers location); } - var redirect = !ShouldKeepVerb(response) ? - new HttpRequestMessage(HttpMethod.Get, location) : - new HttpRequestMessage(response.RequestMessage.Method, location) - { - Content = originalContent - }; - - foreach (var header in response.RequestMessage.Headers) + redirect.RequestUri = location; + if (!ShouldKeepVerb(response)) { - redirect.Headers.Add(header.Key, header.Value); + redirect.Method = HttpMethod.Get; + } + else + { + redirect.Method = response.RequestMessage.Method; + redirect.Content = originalContent; } foreach (var property in response.RequestMessage.Properties) { redirect.Properties.Add(property.Key, property.Value); } - - return redirect; } private static bool ShouldKeepVerb(HttpResponseMessage response) => response.StatusCode == HttpStatusCode.RedirectKeepVerb || (int)response.StatusCode == 308; - private bool IsRedirect(HttpResponseMessage response) => + private bool IsRedirect(HttpResponseMessage response) => response.StatusCode == HttpStatusCode.MovedPermanently || response.StatusCode == HttpStatusCode.Redirect || response.StatusCode == HttpStatusCode.RedirectKeepVerb || (int)response.StatusCode == 308; } -} +} \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Microsoft.AspNetCore.Mvc.Testing.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Microsoft.AspNetCore.Mvc.Testing.csproj index cf0a443e19..058ec2af63 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Microsoft.AspNetCore.Mvc.Testing.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Microsoft.AspNetCore.Mvc.Testing.csproj @@ -1,4 +1,4 @@ - + Support for writing functional tests for MVC applications. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Properties/Resources.Designer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Properties/Resources.Designer.cs index cd0ce4b518..aa246881eb 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Properties/Resources.Designer.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Properties/Resources.Designer.cs @@ -10,6 +10,20 @@ namespace Microsoft.AspNetCore.Mvc.Testing private static readonly ResourceManager _resourceManager = new ResourceManager("Microsoft.AspNetCore.Mvc.Testing.Resources", typeof(Resources).GetTypeInfo().Assembly); + /// + /// The provided Type '{0}' does not belong to an assembly with an entry point. A common cause for this error is providing a Type from a class library. + /// + internal static string InvalidAssemblyEntryPoint + { + get => GetString("InvalidAssemblyEntryPoint"); + } + + /// + /// The provided Type '{0}' does not belong to an assembly with an entry point. A common cause for this error is providing a Type from a class library. + /// + internal static string FormatInvalidAssemblyEntryPoint(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("InvalidAssemblyEntryPoint"), p0); + /// /// No method 'public static {0} CreateWebHostBuilder(string[] args)' found on '{1}'. Alternatively, {2} can be extended and 'protected virtual {0} {3}()' can be overridden to provide your own {0} instance. /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Resources.resx b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Resources.resx index 8174cf1f2d..adf4045ebe 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Resources.resx +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/Resources.resx @@ -117,6 +117,9 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + The provided Type '{0}' does not belong to an assembly with an entry point. A common cause for this error is providing a Type from a class library. + No method 'public static {0} CreateWebHostBuilder(string[] args)' found on '{1}'. Alternatively, {2} can be extended and 'protected virtual {0} {3}()' can be overridden to provide your own {0} instance. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactory.cs index 3e31e9b5aa..8356180bd9 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactory.cs +++ b/src/Mvc/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; @@ -41,7 +41,7 @@ namespace Microsoft.AspNetCore.Mvc.Testing /// on the assembly containing the functional tests with /// a key equal to the assembly . /// In case an attribute with the right key can't be found, - /// will fall back to searching for a solution file (*.sln) and then appending asembly name + /// will fall back to searching for a solution file (*.sln) and then appending assembly name /// to the solution directory. The application root directory will be used to discover views and content files. /// /// @@ -128,6 +128,11 @@ namespace Microsoft.AspNetCore.Mvc.Testing private void SetContentRoot(IWebHostBuilder builder) { + if (SetContentRootFromSetting(builder)) + { + return; + } + var metadataAttributes = GetContentRootMetadataAttributes( typeof(TEntryPoint).Assembly.FullName, typeof(TEntryPoint).Assembly.GetName().Name); @@ -161,6 +166,24 @@ namespace Microsoft.AspNetCore.Mvc.Testing } } + private static bool SetContentRootFromSetting(IWebHostBuilder builder) + { + // Attempt to look for TEST_CONTENTROOT_APPNAME in settings. This should result in looking for + // ASPNETCORE_TEST_CONTENTROOT_APPNAME environment variable. + var assemblyName = typeof(TEntryPoint).Assembly.GetName().Name; + var settingSuffix = assemblyName.ToUpperInvariant().Replace(".", "_"); + var settingName = $"TEST_CONTENTROOT_{settingSuffix}"; + + var settingValue = builder.GetSetting(settingName); + if (settingValue == null) + { + return false; + } + + builder.UseContentRoot(settingValue); + return true; + } + private WebApplicationFactoryContentRootAttribute[] GetContentRootMetadataAttributes( string tEntryPointAssemblyFullName, string tEntryPointAssemblyName) @@ -228,6 +251,11 @@ namespace Microsoft.AspNetCore.Mvc.Testing private void EnsureDepsFile() { + if (typeof(TEntryPoint).Assembly.EntryPoint == null) + { + throw new InvalidOperationException(Resources.FormatInvalidAssemblyEntryPoint(typeof(TEntryPoint).Name)); + } + var depsFileName = $"{typeof(TEntryPoint).Assembly.GetName().Name}.deps.json"; var depsFile = new FileInfo(Path.Combine(AppContext.BaseDirectory, depsFileName)); if (!depsFile.Exists) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryClientOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryClientOptions.cs index c934b2fd56..cfc0244603 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryClientOptions.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryContentRootAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryContentRootAttribute.cs index a2ea31cb45..51832bd7a8 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/WebApplicationFactoryContentRootAttribute.cs +++ b/src/Mvc/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/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/build/netstandard2.0/Microsoft.AspNetCore.Mvc.Testing.targets b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/build/netstandard2.0/Microsoft.AspNetCore.Mvc.Testing.targets index 6cd039b00b..452fc9909d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/build/netstandard2.0/Microsoft.AspNetCore.Mvc.Testing.targets +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.Testing/build/netstandard2.0/Microsoft.AspNetCore.Mvc.Testing.targets @@ -53,7 +53,7 @@ Include="$([System.IO.Path]::ChangeExtension('%(_ContentRootProjectReferences.ResolvedFrom)', '.deps.json'))" /> - + \ No newline at end of file diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Controller.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Controller.cs index 5c680ffaaa..c0aaa0e3b3 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Controller.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Controller.cs @@ -25,7 +25,7 @@ namespace Microsoft.AspNetCore.Mvc /// Gets or sets used by and . /// /// - /// By default, this property is intiailized when activates + /// By default, this property is initialized when activates /// controllers. /// /// This property can be accessed after the controller has been activated, for example, in a controller action diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/CachedExpressionCompiler.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/CachedExpressionCompiler.cs deleted file mode 100644 index aa35a7592f..0000000000 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/CachedExpressionCompiler.cs +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Linq.Expressions; -using System.Reflection; - -namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal -{ - public static class CachedExpressionCompiler - { - // This is the entry point to the cached expression compilation system. The system - // will try to turn the expression into an actual delegate as quickly as possible, - // relying on cache lookups and other techniques to save time if appropriate. - // If the provided expression is particularly obscure and the system doesn't know - // how to handle it, we'll just compile the expression as normal. - public static Func Process( - Expression> expression) - { - if (expression == null) - { - throw new ArgumentNullException(nameof(expression)); - } - - return Compiler.Compile(expression); - } - - private static class Compiler - { - private static Func _identityFunc; - - private static readonly ConcurrentDictionary> _simpleMemberAccessCache = - new ConcurrentDictionary>(); - - private static readonly ConcurrentDictionary> _constMemberAccessCache = - new ConcurrentDictionary>(); - - public static Func Compile(Expression> expression) - { - if (expression == null) - { - throw new ArgumentNullException(nameof(expression)); - } - - return CompileFromIdentityFunc(expression) - ?? CompileFromConstLookup(expression) - ?? CompileFromMemberAccess(expression) - ?? CompileSlow(expression); - } - - private static Func CompileFromConstLookup( - Expression> expression) - { - if (expression == null) - { - throw new ArgumentNullException(nameof(expression)); - } - - var constantExpression = expression.Body as ConstantExpression; - if (constantExpression != null) - { - // model => {const} - - var constantValue = (TResult)constantExpression.Value; - return _ => constantValue; - } - - return null; - } - - private static Func CompileFromIdentityFunc( - Expression> expression) - { - if (expression == null) - { - throw new ArgumentNullException(nameof(expression)); - } - - if (expression.Body == expression.Parameters[0]) - { - // model => model - - // Don't need to lock, as all identity funcs are identical. - if (_identityFunc == null) - { - _identityFunc = expression.Compile(); - } - - return _identityFunc; - } - - return null; - } - - private static Func CompileFromMemberAccess( - Expression> expression) - { - if (expression == null) - { - throw new ArgumentNullException(nameof(expression)); - } - - // Performance tests show that on the x64 platform, special-casing static member and - // captured local variable accesses is faster than letting the fingerprinting system - // handle them. On the x86 platform, the fingerprinting system is faster, but only - // by around one microsecond, so it's not worth it to complicate the logic here with - // an architecture check. - - var memberExpression = expression.Body as MemberExpression; - if (memberExpression != null) - { - if (memberExpression.Expression == expression.Parameters[0] || memberExpression.Expression == null) - { - // model => model.Member or model => StaticMember - return _simpleMemberAccessCache.GetOrAdd(memberExpression.Member, _ => expression.Compile()); - } - - var constantExpression = memberExpression.Expression as ConstantExpression; - if (constantExpression != null) - { - // model => {const}.Member (captured local variable) - var compiledExpression = _constMemberAccessCache.GetOrAdd(memberExpression.Member, _ => - { - // rewrite as capturedLocal => ((TDeclaringType)capturedLocal).Member - var parameterExpression = Expression.Parameter(typeof(object), "capturedLocal"); - var castExpression = - Expression.Convert(parameterExpression, memberExpression.Member.DeclaringType); - var replacementMemberExpression = memberExpression.Update(castExpression); - var replacementExpression = Expression.Lambda>( - replacementMemberExpression, - parameterExpression); - - return replacementExpression.Compile(); - }); - - var capturedLocal = constantExpression.Value; - return _ => compiledExpression(capturedLocal); - } - } - - return null; - } - - private static Func CompileSlow(Expression> expression) - { - if (expression == null) - { - throw new ArgumentNullException(nameof(expression)); - } - - // fallback compilation system - just compile the expression directly - return expression.Compile(); - } - } - } -} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ControllerSaveTempDataPropertyFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ControllerSaveTempDataPropertyFilter.cs index 96228d5b7b..e375ae94bc 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ControllerSaveTempDataPropertyFilter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ControllerSaveTempDataPropertyFilter.cs @@ -23,7 +23,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures Subject = context.Controller; var tempData = _tempDataFactory.GetTempData(context.HttpContext); - SetPropertyVaules(tempData); + SetPropertyValues(tempData); } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ExpressionHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ExpressionHelper.cs index b2c47f3892..4de9146bb2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ExpressionHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ExpressionHelper.cs @@ -31,9 +31,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal throw new ArgumentNullException(nameof(expression)); } - string expressionText; if (expressionTextCache != null && - expressionTextCache.Entries.TryGetValue(expression, out expressionText)) + expressionTextCache.Entries.TryGetValue(expression, out var expressionText)) { return expressionText; } @@ -243,7 +242,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal try { - func = CachedExpressionCompiler.Process(lambda); + func = CachedExpressionCompiler.Process(lambda) ?? lambda.Compile(); } catch (InvalidOperationException ex) { @@ -260,8 +259,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal public static bool IsSingleArgumentIndexer(Expression expression) { - var methodExpression = expression as MethodCallExpression; - if (methodExpression == null || methodExpression.Arguments.Count != 1) + if (!(expression is MethodCallExpression methodExpression) || methodExpression.Arguments.Count != 1) { return false; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ExpressionMetadataProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ExpressionMetadataProvider.cs index 38d89f80bc..0f8b11eacb 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ExpressionMetadataProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ExpressionMetadataProvider.cs @@ -80,17 +80,25 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal throw new InvalidOperationException(Resources.TemplateHelpers_TemplateLimitations); } - Func modelAccessor = (container) => + object modelAccessor(object container) { + var model = (TModel)container; + var cachedFunc = CachedExpressionCompiler.Process(expression); + if (cachedFunc != null) + { + return cachedFunc(model); + } + + var func = expression.Compile(); try { - return CachedExpressionCompiler.Process(expression)((TModel)container); + return func(model); } catch (NullReferenceException) { return null; } - }; + } ModelMetadata metadata = null; if (containerType != null && propertyName != null) diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/PagedBufferedTextWriter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/PagedBufferedTextWriter.cs index 5dce67153b..4c8b1937c9 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/PagedBufferedTextWriter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/PagedBufferedTextWriter.cs @@ -5,6 +5,7 @@ using System; using System.Buffers; using System.Diagnostics; using System.IO; +using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; @@ -28,16 +29,32 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal // Don't do anything. We'll call FlushAsync. } - public override async Task FlushAsync() + public override Task FlushAsync() => FlushAsyncCore(); + + // private non-virtual for internal calling. + // It first does a fast check to see if async is necessary, we inline this check. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Task FlushAsyncCore() { var length = _charBuffer.Length; if (length == 0) { - return; + // If nothing sync buffered return CompletedTask, + // so we can fast-path skip async state-machine creation + return Task.CompletedTask; } + return FlushAsyncAwaited(); + } + + private async Task FlushAsyncAwaited() + { + var length = _charBuffer.Length; + Debug.Assert(length > 0); + var pages = _charBuffer.Pages; - for (var i = 0; i < pages.Count; i++) + var count = pages.Count; + for (var i = 0; i < count; i++) { var page = pages[i]; var pageLength = Math.Min(length, page.Length); @@ -88,21 +105,54 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal _charBuffer.Append(value); } - public override async Task WriteAsync(char value) + public override Task WriteAsync(char value) { - await FlushAsync(); + var flushTask = FlushAsyncCore(); + + // FlushAsyncCore will return CompletedTask if nothing sync buffered + // Fast-path and skip async state-machine if only a single async operation + return ReferenceEquals(flushTask, Task.CompletedTask) ? + _inner.WriteAsync(value) : + WriteAsyncAwaited(flushTask, value); + } + + private async Task WriteAsyncAwaited(Task flushTask, char value) + { + await flushTask; await _inner.WriteAsync(value); } - public override async Task WriteAsync(char[] buffer, int index, int count) + public override Task WriteAsync(char[] buffer, int index, int count) { - await FlushAsync(); + var flushTask = FlushAsyncCore(); + + // FlushAsyncCore will return CompletedTask if nothing sync buffered + // Fast-path and skip async state-machine if only a single async operation + return ReferenceEquals(flushTask, Task.CompletedTask) ? + _inner.WriteAsync(buffer, index, count) : + WriteAsyncAwaited(flushTask, buffer, index, count); + } + + private async Task WriteAsyncAwaited(Task flushTask, char[] buffer, int index, int count) + { + await flushTask; await _inner.WriteAsync(buffer, index, count); } - public override async Task WriteAsync(string value) + public override Task WriteAsync(string value) { - await FlushAsync(); + var flushTask = FlushAsyncCore(); + + // FlushAsyncCore will return CompletedTask if nothing sync buffered + // Fast-path and skip async state-machine if only a single async operation + return ReferenceEquals(flushTask, Task.CompletedTask) ? + _inner.WriteAsync(value) : + WriteAsyncAwaited(flushTask, value); + } + + private async Task WriteAsyncAwaited(Task flushTask, string value) + { + await flushTask; await _inner.WriteAsync(value); } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/PagedCharBuffer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/PagedCharBuffer.cs index f086daf67b..0c4931c7b9 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/PagedCharBuffer.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/PagedCharBuffer.cs @@ -19,16 +19,19 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal public ICharBufferSource BufferSource { get; } - public IList Pages { get; } = new List(); + // Strongly typed rather than IList for performance + public List Pages { get; } = new List(); public int Length { get { var length = _charIndex; - for (var i = 0; i < Pages.Count - 1; i++) + var pages = Pages; + var fullPages = pages.Count - 1; + for (var i = 0; i < fullPages; i++) { - length += Pages[i].Length; + length += pages[i].Length; } return length; @@ -100,13 +103,14 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// public void Clear() { - for (var i = Pages.Count - 1; i > 0; i--) + var pages = Pages; + for (var i = pages.Count - 1; i > 0; i--) { - var page = Pages[i]; + var page = pages[i]; try { - Pages.RemoveAt(i); + pages.RemoveAt(i); } finally { @@ -115,7 +119,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal } _charIndex = 0; - CurrentPage = Pages.Count > 0 ? Pages[0] : null; + CurrentPage = pages.Count > 0 ? pages[0] : null; } private char[] GetCurrentPage() @@ -148,12 +152,14 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal public void Dispose() { - for (var i = 0; i < Pages.Count; i++) + var pages = Pages; + var count = pages.Count; + for (var i = 0; i < count; i++) { - BufferSource.Return(Pages[i]); + BufferSource.Return(pages[i]); } - Pages.Clear(); + pages.Clear(); } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/SaveTempDataFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/SaveTempDataFilter.cs index 2ecf4cbbef..52e98e0677 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/SaveTempDataFilter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/SaveTempDataFilter.cs @@ -78,7 +78,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// public void OnResourceExecuted(ResourceExecutedContext context) { - // If there is an unhandled exception, we would like to avoid setting tempdata as + // If there is an unhandled exception, we would like to avoid setting tempdata as // the end user is going to see an error page anyway and also it helps us in avoiding // accessing resources like Session too late in the request lifecyle where SessionFeature might // not be available. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/SaveTempDataPropertyFilterBase.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/SaveTempDataPropertyFilterBase.cs index c560584798..ab4c79dff8 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/SaveTempDataPropertyFilterBase.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/SaveTempDataPropertyFilterBase.cs @@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// Sets the values of the properties of from . /// /// The . - protected void SetPropertyVaules(ITempDataDictionary tempData) + protected void SetPropertyValues(ITempDataDictionary tempData) { if (Properties == null) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ValidateAntiforgeryTokenAuthorizationFilter.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ValidateAntiforgeryTokenAuthorizationFilter.cs index 7225eb76c1..901ea36cf8 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ValidateAntiforgeryTokenAuthorizationFilter.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ValidateAntiforgeryTokenAuthorizationFilter.cs @@ -47,7 +47,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal catch (AntiforgeryValidationException exception) { _logger.AntiforgeryTokenInvalid(exception.Message, exception); - context.Result = new BadRequestResult(); + context.Result = new AntiforgeryValidationFailedResult(); } } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBuffer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBuffer.cs index 5143e515d4..dfe23dce7c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBuffer.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBuffer.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Runtime.CompilerServices; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Html; @@ -90,55 +91,73 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal } /// + // Very common trivial method; nudge it to inline https://github.com/aspnet/Mvc/pull/8339 + [MethodImpl(MethodImplOptions.AggressiveInlining)] public IHtmlContentBuilder Append(string unencoded) { - if (unencoded == null) + if (unencoded != null) { - return this; + // Text that needs encoding is the uncommon case in views, which is why it + // creates a wrapper and pre-encoded text does not. + AppendValue(new ViewBufferValue(new EncodingWrapper(unencoded))); } - // Text that needs encoding is the uncommon case in views, which is why it - // creates a wrapper and pre-encoded text does not. - AppendValue(new ViewBufferValue(new EncodingWrapper(unencoded))); return this; } /// + // Very common trivial method; nudge it to inline https://github.com/aspnet/Mvc/pull/8339 + [MethodImpl(MethodImplOptions.AggressiveInlining)] public IHtmlContentBuilder AppendHtml(IHtmlContent content) { - if (content == null) + if (content != null) { - return this; + AppendValue(new ViewBufferValue(content)); } - AppendValue(new ViewBufferValue(content)); return this; } /// + // Very common trivial method; nudge it to inline https://github.com/aspnet/Mvc/pull/8339 + [MethodImpl(MethodImplOptions.AggressiveInlining)] public IHtmlContentBuilder AppendHtml(string encoded) { - if (encoded == null) + if (encoded != null) { - return this; + AppendValue(new ViewBufferValue(encoded)); } - AppendValue(new ViewBufferValue(encoded)); return this; } + // Very common trivial method; nudge it to inline https://github.com/aspnet/Mvc/pull/8339 + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void AppendValue(ViewBufferValue value) { var page = GetCurrentPage(); page.Append(value); } + // Very common trivial method; nudge it to inline https://github.com/aspnet/Mvc/pull/8339 + [MethodImpl(MethodImplOptions.AggressiveInlining)] private ViewBufferPage GetCurrentPage() { - if (_currentPage == null || _currentPage.IsFull) + var currentPage = _currentPage; + if (currentPage == null || currentPage.IsFull) { - AddPage(new ViewBufferPage(_bufferScope.GetPage(_pageSize))); + // Uncommon slow-path + return AppendNewPage(); } + + return currentPage; + } + + // Slow path for above, don't inline + [MethodImpl(MethodImplOptions.NoInlining)] + private ViewBufferPage AppendNewPage() + { + AddPage(new ViewBufferPage(_bufferScope.GetPage(_pageSize))); return _currentPage; } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferPage.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferPage.cs index b561e71825..dfc47673ec 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferPage.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferPage.cs @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Runtime.CompilerServices; + namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal { public class ViewBufferPage @@ -18,6 +20,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal public bool IsFull => Count == Capacity; + // Very common trivial method; nudge it to inline https://github.com/aspnet/Mvc/pull/8339 + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Append(ViewBufferValue value) => Buffer[Count++] = value; } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferValue.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferValue.cs index 89c9581848..68691a9868 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferValue.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferValue.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// Encapsulates a string or value. /// [DebuggerDisplay("{DebuggerToString()}")] - public struct ViewBufferValue + public readonly struct ViewBufferValue { /// /// Initializes a new instance of with a string value. @@ -41,15 +41,13 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal { using (var writer = new StringWriter()) { - var valueAsString = Value as string; - if (valueAsString != null) + if (Value is string valueAsString) { writer.Write(valueAsString); return writer.ToString(); } - var valueAsContent = Value as IHtmlContent; - if (valueAsContent != null) + if (Value is IHtmlContent valueAsContent) { valueAsContent.WriteTo(writer, HtmlEncoder.Default); return writer.ToString(); diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj index 27d839320d..878ac41bd4 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC view rendering features. Contains common types used in most MVC applications as well as view rendering features such as view engines, views, view components, and HTML helpers. @@ -19,16 +19,8 @@ Microsoft.AspNetCore.Mvc.ViewComponent - - - - - - - - diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/MvcViewOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/MvcViewOptions.cs index 9d0627da96..237777da23 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/MvcViewOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/MvcViewOptions.cs @@ -4,6 +4,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Mvc.ViewEngines; @@ -17,15 +18,18 @@ namespace Microsoft.AspNetCore.Mvc public class MvcViewOptions : IEnumerable { private readonly CompatibilitySwitch _suppressTempDataAttributePrefix; + private readonly CompatibilitySwitch _allowRenderingMaxLengthAttribute; private readonly ICompatibilitySwitch[] _switches; private HtmlHelperOptions _htmlHelperOptions = new HtmlHelperOptions(); public MvcViewOptions() { _suppressTempDataAttributePrefix = new CompatibilitySwitch(nameof(SuppressTempDataAttributePrefix)); + _allowRenderingMaxLengthAttribute = new CompatibilitySwitch(nameof(AllowRenderingMaxLengthAttribute)); _switches = new[] { _suppressTempDataAttributePrefix, + _allowRenderingMaxLengthAttribute }; } @@ -87,6 +91,18 @@ namespace Microsoft.AspNetCore.Mvc set => _suppressTempDataAttributePrefix.Value = value; } + /// + /// Gets or sets a value that indicates whether the maxlength attribute should be rendered for compatible HTML elements, + /// when they're bound to models marked with either + /// or attributes. + /// + /// If both attributes are specified, the one with the smaller value will be used for the rendered `maxlength` attribute. + public bool AllowRenderingMaxLengthAttribute + { + get => _allowRenderingMaxLengthAttribute.Value; + set => _allowRenderingMaxLengthAttribute.Value = value; + } + /// /// Gets a list s used by this application. /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/PartialViewResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/PartialViewResult.cs index e9f2763057..56b0f80139 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/PartialViewResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/PartialViewResult.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// Represents an that renders a partial view to the response. /// - public class PartialViewResult : ActionResult + public class PartialViewResult : ActionResult, IStatusCodeActionResult { /// /// Gets or sets the HTTP status code. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponentResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponentResult.cs index 898f676067..cbdb85574c 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponentResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponentResult.cs @@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// An which renders a view component to the response. /// - public class ViewComponentResult : ActionResult + public class ViewComponentResult : ActionResult, IStatusCodeActionResult { /// /// Gets or sets the arguments provided to the view component. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentActivator.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentActivator.cs index b321a3c44a..ee04bc53b6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentActivator.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentActivator.cs @@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewComponents /// /// The can provide the current instance of /// to a public property of a view component marked - /// with . + /// with . /// public class DefaultViewComponentActivator : IViewComponentActivator { @@ -25,7 +25,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewComponents /// /// The used to create new view component instances. /// +#pragma warning disable PUB0001 // Pubternal type in public API public DefaultViewComponentActivator(ITypeActivatorCache typeActivatorCache) +#pragma warning restore PUB0001 { if (typeActivatorCache == null) { @@ -44,7 +46,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewComponents } var componentType = context.ViewComponentDescriptor.TypeInfo; - + if (componentType == null) { throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull( diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentHelper.cs index 0743508566..ad979c9ae0 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentHelper.cs @@ -41,7 +41,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewComponents HtmlEncoder htmlEncoder, IViewComponentSelector selector, IViewComponentInvokerFactory invokerFactory, - IViewBufferScope viewBufferScope) +#pragma warning disable PUB0001 // Pubternal type in public API + IViewBufferScope viewBufferScope +#pragma warning restore PUB0001 + ) { if (descriptorProvider == null) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentInvoker.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentInvoker.cs index d2a8614bda..9111ccef3b 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentInvoker.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentInvoker.cs @@ -32,7 +32,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewComponents /// The . public DefaultViewComponentInvoker( IViewComponentFactory viewComponentFactory, +#pragma warning disable PUB0001 // Pubternal type in public API ViewComponentInvokerCache viewComponentInvokerCache, +#pragma warning restore PUB0001 DiagnosticSource diagnosticSource, ILogger logger) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentInvokerFactory.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentInvokerFactory.cs index 1dd5d172e6..3d67017743 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentInvokerFactory.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewComponents/DefaultViewComponentInvokerFactory.cs @@ -17,7 +17,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewComponents public DefaultViewComponentInvokerFactory( IViewComponentFactory viewComponentFactory, +#pragma warning disable PUB0001 // Pubternal type in public API ViewComponentInvokerCache viewComponentInvokerCache, +#pragma warning restore PUB0001 DiagnosticSource diagnosticSource, ILoggerFactory loggerFactory) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/CachedExpressionCompiler.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/CachedExpressionCompiler.cs new file mode 100644 index 0000000000..03c0b31da9 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/CachedExpressionCompiler.cs @@ -0,0 +1,266 @@ +// 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.Concurrent; +using System.Diagnostics; +using System.Linq.Expressions; +using System.Reflection; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + internal static class CachedExpressionCompiler + { + private static readonly Expression NullExpression = Expression.Constant(value: null); + + /// + /// This is the entry point to the expression compilation system. The system + /// a) Will rewrite the expression to avoid null refs when any part of the expression tree is evaluated to null + /// b) Attempt to cache the result, or an intermediate part of the result. + /// If the provided expression is particularly obscure and the system doesn't know how to handle it, it will + /// return null. + /// + public static Func Process( + Expression> expression) + { + if (expression == null) + { + throw new ArgumentNullException(nameof(expression)); + } + + return Compiler.Compile(expression); + } + + private static class Compiler + { + private static Func _identityFunc; + + private static readonly ConcurrentDictionary> _simpleMemberAccessCache = + new ConcurrentDictionary>(); + + private static readonly ConcurrentDictionary> _chainedMemberAccessCache = + new ConcurrentDictionary>(MemberExpressionCacheKeyComparer.Instance); + + private static readonly ConcurrentDictionary> _constMemberAccessCache = + new ConcurrentDictionary>(); + + public static Func Compile(Expression> expression) + { + Debug.Assert(expression != null); + + switch (expression.Body) + { + // model => model + case var body when body == expression.Parameters[0]: + return CompileFromIdentityFunc(expression); + + // model => (object){const} + case ConstantExpression constantExpression: + return CompileFromConstLookup(constantExpression); + + // model => CapturedConstant + case MemberExpression memberExpression when memberExpression.Expression is ConstantExpression constantExpression: + return CompileCapturedConstant(memberExpression, constantExpression); + + // model => ModelType.StaticMember + case MemberExpression memberExpression when memberExpression.Expression == null: + return CompileFromStaticMemberAccess(expression, memberExpression); + + // model => model.Member + case MemberExpression memberExpression when memberExpression.Expression == expression.Parameters[0]: + return CompileFromSimpleMemberAccess(expression, memberExpression); + + // model => model.Member1.Member2 + case MemberExpression memberExpression when IsChainedPropertyAccessor(memberExpression): + return CompileForChainedMemberAccess(expression, memberExpression); + + default: + return null; + } + + bool IsChainedPropertyAccessor(MemberExpression memberExpression) + { + while (memberExpression.Expression != null) + { + if (memberExpression.Expression is MemberExpression leftExpression) + { + memberExpression = leftExpression; + continue; + } + else if (memberExpression.Expression == expression.Parameters[0]) + { + return true; + } + + break; + } + + return false; + } + } + + private static Func CompileFromConstLookup( + ConstantExpression constantExpression) + { + // model => {const} + var constantValue = constantExpression.Value; + return _ => constantValue; + } + + private static Func CompileFromIdentityFunc( + Expression> expression) + { + // model => model + // Don't need to lock, as all identity funcs are identical. + if (_identityFunc == null) + { + var identityFuncCore = expression.Compile(); + _identityFunc = model => identityFuncCore(model); + } + + return _identityFunc; + } + + private static Func CompileFromStaticMemberAccess( + Expression> expression, + MemberExpression memberExpression) + { + // model => ModelType.StaticMember + if (_simpleMemberAccessCache.TryGetValue(memberExpression.Member, out var result)) + { + return result; + } + + var func = expression.Compile(); + result = model => func(model); + result = _simpleMemberAccessCache.GetOrAdd(memberExpression.Member, result); + + return result; + } + + private static Func CompileFromSimpleMemberAccess( + Expression> expression, + MemberExpression memberExpression) + { + // Input: () => m.Member + // Output: () => (m == null) ? null : m.Member + if (_simpleMemberAccessCache.TryGetValue(memberExpression.Member, out var result)) + { + return result; + } + + result = _simpleMemberAccessCache.GetOrAdd(memberExpression.Member, Rewrite(expression, memberExpression)); + return result; + } + + private static Func CompileForChainedMemberAccess( + Expression> expression, + MemberExpression memberExpression) + { + // Input: () => m.Member1.Member2 + // Output: () => (m == null || m.Member1 == null) ? null : m.Member1.Member2 + var key = new MemberExpressionCacheKey(typeof(TModel), memberExpression); + if (_chainedMemberAccessCache.TryGetValue(key, out var result)) + { + return result; + } + + var cacheableKey = key.MakeCacheable(); + result = _chainedMemberAccessCache.GetOrAdd(cacheableKey, Rewrite(expression, memberExpression)); + return result; + } + + private static Func CompileCapturedConstant(MemberExpression memberExpression, ConstantExpression constantExpression) + { + // model => {const} (captured local variable) + if (!_constMemberAccessCache.TryGetValue(memberExpression.Member, out var result)) + { + // rewrite as capturedLocal => ((TDeclaringType)capturedLocal) + var parameterExpression = Expression.Parameter(typeof(object), "capturedLocal"); + var castExpression = + Expression.Convert(parameterExpression, memberExpression.Member.DeclaringType); + var replacementMemberExpression = memberExpression.Update(castExpression); + var replacementExpression = Expression.Lambda>( + replacementMemberExpression, + parameterExpression); + + result = replacementExpression.Compile(); + result = _constMemberAccessCache.GetOrAdd(memberExpression.Member, result); + } + + var capturedLocal = constantExpression.Value; + return _ => result(capturedLocal); + } + + private static Func Rewrite( + Expression> expression, + MemberExpression memberExpression) + { + Expression combinedNullTest = null; + var currentExpression = memberExpression; + + while (currentExpression != null) + { + AddNullCheck(currentExpression.Expression, ref combinedNullTest); + + if (currentExpression.Expression is MemberExpression leftExpression) + { + currentExpression = leftExpression; + } + else + { + break; + } + } + + var body = expression.Body; + + // Cast the entire expression to object in case Member is a value type. This is required for us to be able to + // express the null conditional statement m == null ? null : (object)m.IntValue + if (body.Type.IsValueType) + { + body = Expression.Convert(body, typeof(object)); + } + + if (combinedNullTest != null) + { + Debug.Assert(combinedNullTest.Type == typeof(bool)); + body = Expression.Condition( + combinedNullTest, + Expression.Constant(value: null, body.Type), + body); + } + + var rewrittenExpression = Expression.Lambda>(body, expression.Parameters); + return rewrittenExpression.Compile(); + } + + private static void AddNullCheck(Expression invokingExpression, ref Expression combinedNullTest) + { + var type = invokingExpression.Type; + var isNullableValueType = type.IsValueType && Nullable.GetUnderlyingType(type) != null; + if (type.IsValueType && !isNullableValueType) + { + // struct.Member where struct is not nullable. Do nothing. + return; + } + + // NullableStruct.Member or Class.Member + // type is Nullable ? (value == null) : object.ReferenceEquals(value, null) + var nullTest = isNullableValueType ? + Expression.Equal(invokingExpression, NullExpression) : + Expression.ReferenceEqual(invokingExpression, NullExpression); + + if (combinedNullTest == null) + { + combinedNullTest = nullTest; + } + else + { + // m == null || m.Member == null + combinedNullTest = Expression.OrElse(nullTest, combinedNullTest); + } + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs index e502341da4..d0121241b6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs @@ -4,6 +4,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Globalization; using System.Linq; @@ -30,6 +31,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures private static readonly string[] _placeholderInputTypes = new[] { "text", "search", "url", "tel", "email", "password", "number" }; + // See: (http://www.w3.org/TR/html5/sec-forms.html#apply) + private static readonly string[] _maxLengthInputTypes = + new[] { "text", "search", "url", "tel", "email", "password" }; + private readonly IAntiforgery _antiforgery; private readonly IModelMetadataProvider _metadataProvider; private readonly IUrlHelperFactory _urlHelperFactory; @@ -92,8 +97,18 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures // Underscores are fine characters in id's. IdAttributeDotReplacement = optionsAccessor.Value.HtmlHelperOptions.IdAttributeDotReplacement; + + AllowRenderingMaxLengthAttribute = optionsAccessor.Value.AllowRenderingMaxLengthAttribute; } + /// + /// Gets or sets a value that indicates whether the maxlength attribute should be rendered for compatible HTML input elements, + /// when they're bound to models marked with either + /// or attributes. + /// + /// If both attributes are specified, the one with the smaller value will be used for the rendered `maxlength` attribute. + protected bool AllowRenderingMaxLengthAttribute { get; } + /// public string IdAttributeDotReplacement { get; } @@ -724,6 +739,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures } AddPlaceholderAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression); + if (AllowRenderingMaxLengthAttribute) + { + AddMaxLengthAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression); + } + AddValidationAttributes(viewContext, tagBuilder, modelExplorer, expression); // If there are any errors for a named field, we add this CSS attribute. @@ -1239,6 +1259,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures AddPlaceholderAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression); } + if (AllowRenderingMaxLengthAttribute && _maxLengthInputTypes.Contains(suppliedTypeString)) + { + AddMaxLengthAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression); + } + var valueParameter = FormatValue(value, format); var usedModelState = false; switch (inputType) @@ -1373,6 +1398,43 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures } } + /// + /// Adds a maxlength attribute to the . + /// + /// A instance for the current scope. + /// A instance. + /// The for the . + /// Expression name, relative to the current model. + protected virtual void AddMaxLengthAttribute( + ViewDataDictionary viewData, + TagBuilder tagBuilder, + ModelExplorer modelExplorer, + string expression) + { + modelExplorer = modelExplorer ?? ExpressionMetadataProvider.FromStringExpression( + expression, + viewData, + _metadataProvider); + + int? maxLengthValue = null; + foreach (var attribute in modelExplorer.Metadata.ValidatorMetadata) + { + if (attribute is MaxLengthAttribute maxLengthAttribute && (!maxLengthValue.HasValue || maxLengthValue.Value > maxLengthAttribute.Length)) + { + maxLengthValue = maxLengthAttribute.Length; + } + else if (attribute is StringLengthAttribute stringLengthAttribute && (!maxLengthValue.HasValue || maxLengthValue.Value > stringLengthAttribute.MaximumLength)) + { + maxLengthValue = stringLengthAttribute.MaximumLength; + } + } + + if (maxLengthValue.HasValue) + { + tagBuilder.MergeAttribute("maxlength", maxLengthValue.Value.ToString()); + } + } + /// /// Adds validation attributes to the if client validation /// is enabled. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultValidationHtmlAttributeProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultValidationHtmlAttributeProvider.cs index 7fe4a5bc70..02b04d8e53 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultValidationHtmlAttributeProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultValidationHtmlAttributeProvider.cs @@ -30,7 +30,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures public DefaultValidationHtmlAttributeProvider( IOptions optionsAccessor, IModelMetadataProvider metadataProvider, - ClientValidatorCache clientValidatorCache) +#pragma warning disable PUB0001 // Pubternal type in public API + ClientValidatorCache clientValidatorCache +#pragma warning restore PUB0001 + ) { if (optionsAccessor == null) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs index 8abb93d274..108ac5d7bf 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs @@ -43,7 +43,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures IHtmlGenerator htmlGenerator, ICompositeViewEngine viewEngine, IModelMetadataProvider metadataProvider, +#pragma warning disable PUB0001 // Pubternal type in public API IViewBufferScope bufferScope, +#pragma warning restore PUB0001 HtmlEncoder htmlEncoder, UrlEncoder urlEncoder) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOfT.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOfT.cs index 833a271740..f4bb418c20 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOfT.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOfT.cs @@ -24,10 +24,15 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures IHtmlGenerator htmlGenerator, ICompositeViewEngine viewEngine, IModelMetadataProvider metadataProvider, +#pragma warning disable PUB0001 // Pubternal type in public API IViewBufferScope bufferScope, +#pragma warning restore PUB0001 HtmlEncoder htmlEncoder, UrlEncoder urlEncoder, - ExpressionTextCache expressionTextCache) +#pragma warning disable PUB0001 // Pubternal type in public API + ExpressionTextCache expressionTextCache +#pragma warning restore PUB0001 + ) : base( htmlGenerator, viewEngine, diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IFileVersionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IFileVersionProvider.cs new file mode 100644 index 0000000000..1aa918c30f --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IFileVersionProvider.cs @@ -0,0 +1,21 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + /// + /// Provides version hash for a specified file. + /// + public interface IFileVersionProvider + { + /// + /// Adds version query parameter to the specified file path. + /// + /// The base path for the current HTTP request. + /// The path of the file to which version should be added. + /// Path containing the version query string. + string AddFileVersionToPath(PathString requestPathBase, string path); + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MemberExpressionCacheKey.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MemberExpressionCacheKey.cs new file mode 100644 index 0000000000..8f8b667983 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MemberExpressionCacheKey.cs @@ -0,0 +1,90 @@ +// 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.Expressions; +using System.Reflection; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + internal readonly struct MemberExpressionCacheKey + { + public MemberExpressionCacheKey(Type modelType, MemberExpression memberExpression) + { + ModelType = modelType; + MemberExpression = memberExpression; + Members = null; + } + + public MemberExpressionCacheKey(Type modelType, MemberInfo[] members) + { + ModelType = modelType; + Members = members; + MemberExpression = null; + } + + // We want to avoid caching a MemberExpression since it has references to other instances in the expression tree. + // We instead store it as a series of MemberInfo items that comprise of the MemberExpression going from right-most + // expression to left. + public MemberExpressionCacheKey MakeCacheable() + { + var members = new List(); + foreach (var member in this) + { + members.Add(member); + } + + return new MemberExpressionCacheKey(ModelType, members.ToArray()); + } + + public MemberExpression MemberExpression { get; } + + public Type ModelType { get; } + + public MemberInfo[] Members { get; } + + public Enumerator GetEnumerator() => new Enumerator(this); + + public struct Enumerator + { + private readonly MemberInfo[] _members; + private int _index; + private MemberExpression _memberExpression; + + public Enumerator(in MemberExpressionCacheKey key) + { + Current = null; + _members = key.Members; + _memberExpression = key.MemberExpression; + _index = -1; + } + + public MemberInfo Current { get; private set; } + + public bool MoveNext() + { + if (_members != null) + { + _index++; + if (_index >= _members.Length) + { + return false; + } + + Current = _members[_index]; + return true; + } + + if (_memberExpression == null) + { + return false; + } + + Current = _memberExpression.Member; + _memberExpression = _memberExpression.Expression as MemberExpression; + return true; + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MemberExpressionCacheKeyComparer.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MemberExpressionCacheKeyComparer.cs new file mode 100644 index 0000000000..5911611c89 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MemberExpressionCacheKeyComparer.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.Collections.Generic; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + internal class MemberExpressionCacheKeyComparer : IEqualityComparer + { + public static readonly MemberExpressionCacheKeyComparer Instance = new MemberExpressionCacheKeyComparer(); + + public bool Equals(MemberExpressionCacheKey x, MemberExpressionCacheKey y) + { + if (x.ModelType != y.ModelType) + { + return false; + } + + var xEnumerator = x.GetEnumerator(); + var yEnumerator = y.GetEnumerator(); + + while (xEnumerator.MoveNext()) + { + if (!yEnumerator.MoveNext()) + { + return false; + } + + // Current is a MemberInfo instance which has a good comparer. + if (xEnumerator.Current != yEnumerator.Current) + { + return false; + } + } + + return !yEnumerator.MoveNext(); + } + + public int GetHashCode(MemberExpressionCacheKey obj) + { + var hashCodeCombiner = new HashCodeCombiner(); + hashCodeCombiner.Add(obj.ModelType); + + foreach (var member in obj) + { + hashCodeCombiner.Add(member); + } + + return hashCodeCombiner.CombinedHash; + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ModelExpressionProvider.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ModelExpressionProvider.cs index 46c8830068..f6880a66f7 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ModelExpressionProvider.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ModelExpressionProvider.cs @@ -23,7 +23,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures /// The . public ModelExpressionProvider( IModelMetadataProvider modelMetadataProvider, - ExpressionTextCache expressionTextCache) +#pragma warning disable PUB0001 // Pubternal type in public API + ExpressionTextCache expressionTextCache +#pragma warning restore PUB0001 + ) { if (modelMetadataProvider == null) { diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MvcViewOptionsConfigureCompatibilityOptions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MvcViewOptionsConfigureCompatibilityOptions.cs index 81de59fe12..b17210f7de 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MvcViewOptionsConfigureCompatibilityOptions.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MvcViewOptionsConfigureCompatibilityOptions.cs @@ -28,6 +28,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures values[nameof(MvcViewOptions.SuppressTempDataAttributePrefix)] = true; } + if (Version >= CompatibilityVersion.Version_2_2) + { + values[nameof(MvcViewOptions.AllowRenderingMaxLengthAttribute)] = true; + } + return values; } } diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/PartialViewResultExecutor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/PartialViewResultExecutor.cs index 18ecbebe3d..2644558bc6 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/PartialViewResultExecutor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/PartialViewResultExecutor.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -199,22 +200,20 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures throw new ArgumentNullException(nameof(context)); } - object routeValue; - if (!context.RouteData.Values.TryGetValue(ActionNameKey, out routeValue)) + if (!context.RouteData.Values.TryGetValue(ActionNameKey, out var routeValue)) { return null; } var actionDescriptor = context.ActionDescriptor; string normalizedValue = null; - string value; - if (actionDescriptor.RouteValues.TryGetValue(ActionNameKey, out value) && + if (actionDescriptor.RouteValues.TryGetValue(ActionNameKey, out var value) && !string.IsNullOrEmpty(value)) { normalizedValue = value; } - var stringRouteValue = routeValue?.ToString(); + var stringRouteValue = Convert.ToString(routeValue, CultureInfo.InvariantCulture); if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase)) { return normalizedValue; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/SaveTempDataAttribute.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/SaveTempDataAttribute.cs index 908afc2fca..1c9af6ca28 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/SaveTempDataAttribute.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/SaveTempDataAttribute.cs @@ -16,13 +16,13 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures { public SaveTempDataAttribute() { - // Since SaveTempDataFilter registers for a response's OnStarting callback, we want this filter to run - // as early as possible to get the opportunity to register the call back before any other result filter + // Since SaveTempDataFilter registers for a response's OnStarting callback, we want this filter to run + // as early as possible to get the opportunity to register the call back before any other result filter // starts writing to the response stream. Order = int.MinValue + 100; } - // + /// public int Order { get; set; } /// diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ViewResultExecutor.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ViewResultExecutor.cs index af284f0908..4a2ca98b6d 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ViewResultExecutor.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ViewResultExecutor.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -115,10 +116,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures "Microsoft.AspNetCore.Mvc.ViewFound", new { - actionContext = actionContext, + actionContext, isMainPage = true, result = viewResult, - viewName = viewName, + viewName, view = result.View, }); } @@ -133,10 +134,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures "Microsoft.AspNetCore.Mvc.ViewNotFound", new { - actionContext = actionContext, + actionContext, isMainPage = true, result = viewResult, - viewName = viewName, + viewName, searchedLocations = result.SearchedLocations }); } @@ -199,7 +200,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures normalizedValue = value; } - var stringRouteValue = routeValue?.ToString(); + var stringRouteValue = Convert.ToString(routeValue, CultureInfo.InvariantCulture); if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase)) { return normalizedValue; diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewResult.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewResult.cs index c98562fc9e..5534ec8666 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewResult.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewResult.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// Represents an that renders a view to the response. /// - public class ViewResult : ActionResult + public class ViewResult : ActionResult, IStatusCodeActionResult { /// /// Gets or sets the HTTP status code. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.WebApiCompatShim/Microsoft.AspNetCore.Mvc.WebApiCompatShim.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.WebApiCompatShim/Microsoft.AspNetCore.Mvc.WebApiCompatShim.csproj index ab1d11a599..72cb71f8c2 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.WebApiCompatShim/Microsoft.AspNetCore.Mvc.WebApiCompatShim.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.WebApiCompatShim/Microsoft.AspNetCore.Mvc.WebApiCompatShim.csproj @@ -1,4 +1,4 @@ - + Provides compatibility in ASP.NET Core MVC with ASP.NET Web API 2 to simplify migration of existing Web API implementations. diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.WebApiCompatShim/OverloadActionConstraint.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.WebApiCompatShim/OverloadActionConstraint.cs index 67e5df033c..2ae5b527d0 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.WebApiCompatShim/OverloadActionConstraint.cs +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.WebApiCompatShim/OverloadActionConstraint.cs @@ -97,8 +97,7 @@ namespace Microsoft.AspNetCore.Mvc.WebApiCompatShim } var parameters = new List(); - object optionalParametersObject; - candidate.Action.Properties.TryGetValue("OptionalParameters", out optionalParametersObject); + candidate.Action.Properties.TryGetValue("OptionalParameters", out var optionalParametersObject); var optionalParameters = (HashSet)optionalParametersObject; foreach (var parameter in candidate.Action.Parameters) { @@ -191,4 +190,4 @@ namespace Microsoft.AspNetCore.Mvc.WebApiCompatShim public string Prefix { get; set; } } } -} \ No newline at end of file +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc/Microsoft.AspNetCore.Mvc.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc/Microsoft.AspNetCore.Mvc.csproj index 76c448b86b..9b21543c20 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc/Microsoft.AspNetCore.Mvc.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc/Microsoft.AspNetCore.Mvc.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC is a web framework that gives you a powerful, patterns-based way to build dynamic websites and web APIs. ASP.NET Core MVC enables a clean separation of concerns and gives you full control over markup. @@ -9,7 +9,7 @@ - + diff --git a/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/DownloadFile.cs b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/DownloadFile.cs new file mode 100644 index 0000000000..177f405ea1 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/GetCurrentItems.cs b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/GetCurrentItems.cs new file mode 100644 index 0000000000..97ea236f2c --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/GetFileReferenceMetadata.cs b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/GetFileReferenceMetadata.cs new file mode 100644 index 0000000000..70ac4d847f --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/GetProjectReferenceMetadata.cs b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/GetProjectReferenceMetadata.cs new file mode 100644 index 0000000000..a4ad42abe7 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/GetUriReferenceMetadata.cs b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/GetUriReferenceMetadata.cs new file mode 100644 index 0000000000..922359cb36 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/MetadataSerializer.cs b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/MetadataSerializer.cs new file mode 100644 index 0000000000..331bac617a --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.csproj b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.csproj new file mode 100644 index 0000000000..7809b19970 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.nuspec b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/Microsoft.Extensions.ApiDescription.Design.nuspec new file mode 100644 index 0000000000..e4b8130e5b --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/Properties/Resources.Designer.cs b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..b72e347e21 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/Resources.resx b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/Resources.resx new file mode 100644 index 0000000000..68fa954f09 --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.props b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.props new file mode 100644 index 0000000000..88a40992af --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.targets b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/build/Microsoft.Extensions.ApiDescription.Design.targets new file mode 100644 index 0000000000..de1655454c --- /dev/null +++ b/src/Mvc/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/Mvc/src/Microsoft.Extensions.ApiDescription.Design/buildMultiTargeting/Microsoft.Extensions.ApiDescription.Design.targets b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/buildMultiTargeting/Microsoft.Extensions.ApiDescription.Design.targets new file mode 100644 index 0000000000..af5d08a6bb --- /dev/null +++ b/src/Mvc/src/Microsoft.Extensions.ApiDescription.Design/buildMultiTargeting/Microsoft.Extensions.ApiDescription.Design.targets @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/Mvc/src/dotnet-getdocument/Commands/InvokeCommand.cs b/src/Mvc/src/dotnet-getdocument/Commands/InvokeCommand.cs new file mode 100644 index 0000000000..4d24919c08 --- /dev/null +++ b/src/Mvc/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/Mvc/src/dotnet-getdocument/Exe.cs b/src/Mvc/src/dotnet-getdocument/Exe.cs new file mode 100644 index 0000000000..08fc4217ba --- /dev/null +++ b/src/Mvc/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/Mvc/src/dotnet-getdocument/Program.cs b/src/Mvc/src/dotnet-getdocument/Program.cs new file mode 100644 index 0000000000..94958f2840 --- /dev/null +++ b/src/Mvc/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/Mvc/src/dotnet-getdocument/Project.cs b/src/Mvc/src/dotnet-getdocument/Project.cs new file mode 100644 index 0000000000..dc4a06ea36 --- /dev/null +++ b/src/Mvc/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/Mvc/src/dotnet-getdocument/ProjectOptions.cs b/src/Mvc/src/dotnet-getdocument/ProjectOptions.cs new file mode 100644 index 0000000000..c235c2191a --- /dev/null +++ b/src/Mvc/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/Mvc/src/dotnet-getdocument/Properties/Resources.Designer.cs b/src/Mvc/src/dotnet-getdocument/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..28501e7880 --- /dev/null +++ b/src/Mvc/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/Mvc/src/dotnet-getdocument/Resources.resx b/src/Mvc/src/dotnet-getdocument/Resources.resx new file mode 100644 index 0000000000..d87157fff2 --- /dev/null +++ b/src/Mvc/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/Mvc/src/dotnet-getdocument/ServiceProjectReferenceMetadata.targets b/src/Mvc/src/dotnet-getdocument/ServiceProjectReferenceMetadata.targets new file mode 100644 index 0000000000..177d950c9e --- /dev/null +++ b/src/Mvc/src/dotnet-getdocument/ServiceProjectReferenceMetadata.targets @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mvc/src/dotnet-getdocument/dotnet-getdocument.csproj b/src/Mvc/src/dotnet-getdocument/dotnet-getdocument.csproj new file mode 100644 index 0000000000..ebda3eb047 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/Directory.Build.props b/src/Mvc/test/Directory.Build.props index ece617ffe1..ff13641a43 100644 --- a/src/Mvc/test/Directory.Build.props +++ b/src/Mvc/test/Directory.Build.props @@ -2,20 +2,18 @@ - netcoreapp2.1 + netcoreapp2.2 $(DeveloperBuildTestTfms) - netcoreapp2.1 + $(StandardTestTfms);net461 false - xUnit1026:$(WarningsNotAsErrors) $(MSBuildThisFileDirectory)MvcTests.ruleset - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/Microsoft.AspNetCore.Mvc.Abstractions.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/Microsoft.AspNetCore.Mvc.Abstractions.Test.csproj index 1ca6c1dee7..a02016bd2e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/Microsoft.AspNetCore.Mvc.Abstractions.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/Microsoft.AspNetCore.Mvc.Abstractions.Test.csproj @@ -1,12 +1,11 @@ - + $(StandardTestTfms) - - + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ActionsMustNotBeAsyncVoidFacts.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ActionsMustNotBeAsyncVoidFacts.cs deleted file mode 100644 index 1d536be95a..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ActionsMustNotBeAsyncVoidFacts.cs +++ /dev/null @@ -1,174 +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.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.Diagnostics; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public class ActionsMustNotBeAsyncVoidFacts : AnalyzerTestBase - { - protected override DiagnosticAnalyzer DiagnosticAnalyzer { get; } - = new ActionsMustNotBeAsyncVoidAnalyzer(); - - protected override CodeFixProvider CodeFixProvider { get; } - = new ActionsMustNotBeAsyncVoidFixProvider(); - - [Fact] - public async Task NoDiagnosticsAreReturned_FoEmptyScenarios() - { - // Arrange - var test = @""; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenMethodIsNotAControllerAction() - { - // Arrange - var test = -@" -using System.Threading.Tasks; - -public class UserViewModel -{ - public async void Index() => await Task.Delay(10); -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task DiagnosticsAreReturned_WhenMethodIsAControllerAction() - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7003", - Message = "Controller actions must not have async void signature.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 7, 18) } - }; - var test = -@" -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -public class HomeController : Controller -{ - public async void Index() - { - await Response.Body.FlushAsync(); - } -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -public class HomeController : Controller -{ - public async Task Index() - { - await Response.Body.FlushAsync(); - } -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task DiagnosticsAreReturned_WhenActionMethodIsExpressionBodied() - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7003", - Message = "Controller actions must not have async void signature.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 7, 18) } - }; - var test = -@" -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -public class HomeController : Controller -{ - public async void Index() => await Response.Body.FlushAsync(); -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -public class HomeController : Controller -{ - public async Task Index() => await Response.Body.FlushAsync(); -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task CodeFix_ProducesFullyQualifiedNamespaces() - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7003", - Message = "Controller actions must not have async void signature.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 6, 18) } - }; - var test = -@" -using Microsoft.AspNetCore.Mvc; - -public class HomeController : Controller -{ - public async void Index() => await Response.Body.FlushAsync(); -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; - -public class HomeController : Controller -{ - public async System.Threading.Tasks.Task Index() => await Response.Body.FlushAsync(); -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - } -} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApActionsDoNotRequireExplicitModelValidationCheckFacts.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApActionsDoNotRequireExplicitModelValidationCheckFacts.cs deleted file mode 100644 index d74c584349..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApActionsDoNotRequireExplicitModelValidationCheckFacts.cs +++ /dev/null @@ -1,361 +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.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.Diagnostics; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public class ApiActionsDoNotRequireExplicitModelValidationCheckFacts : AnalyzerTestBase - { - protected override DiagnosticAnalyzer DiagnosticAnalyzer { get; } - = new ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer(); - - protected override CodeFixProvider CodeFixProvider { get; } - = new ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProvider(); - - [Fact] - public async Task NoDiagnosticsAreReturned_FoEmptyScenarios() - { - // Arrange - var test = @""; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenTypeIsNotApiController() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -public class HomeController : Controller -{ - public IActionResult Index() => View(); -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenActionDoesNotHaveModelStateCheck() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : Controller -{ - public IActionResult GetPetId() - { - return Ok(new object()); - } -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenAActionsUseExpressionBodies() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : Controller -{ - public IActionResult GetPetId() => ModelState.IsVald ? OK() : BadResult(); -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_ForNonActions() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - private int GetPetIdPrivate() => 0; - protected int GetPetIdProtected() => 0; - public static IActionResult FindPetByStatus(int status) => null; - [NonAction] - public object Reset(int state) => null; -}"; - - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public Task DiagnosticsAndCodeFixes_WhenActionHasModelStateIsValidCheck() - { - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - public IActionResult GetPetId() - { - if (!ModelState.IsValid) - { - return BadRequest(); - } - - return Ok(); - } -}"; - - // Act & Assert - return VerifyAsync(test); - } - - - [Fact] - public Task DiagnosticsAndCodeFixes_WhenActionHasModelStateIsValidCheck_UsingComparisonToFalse() - { - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - public IActionResult GetPetId() - { - if (ModelState.IsValid == false) - { - return BadRequest(); - } - - return Ok(); - } -}"; - - // Act & Assert - return VerifyAsync(test); - } - - [Fact] - public Task DiagnosticsAndCodeFixes_WhenActionHasModelStateIsValidCheck_WithoutBraces() - { - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - public IActionResult GetPetId() - { - if (!ModelState.IsValid) - return BadRequest(); - - return Ok(); - } -}"; - return VerifyAsync(test); - } - - private async Task VerifyAsync(string test) - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7001", - Message = "Actions on types annotated with ApiControllerAttribute do not require explicit ModelState validity check.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 9, 9) } - }; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - public IActionResult GetPetId() - { - return Ok(); - } -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task DiagnosticsAndCodeFixes_WhenModelStateIsInElseIf() - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7001", - Message = "Actions on types annotated with ApiControllerAttribute do not require explicit ModelState validity check.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 13, 9) } - }; - - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - public IActionResult GetPetId() - { - if (User == null) - { - return Unauthorized(); - } - else if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - return Ok(); - } -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - public IActionResult GetPetId() - { - if (User == null) - { - return Unauthorized(); - } - - return Ok(); - } -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task DiagnosticsAndCodeFixes_WhenModelStateIsInNestedBlock() - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7001", - Message = "Actions on types annotated with ApiControllerAttribute do not require explicit ModelState validity check.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 15, 13) } - }; - - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - public IActionResult GetPetId() - { - if (User == null) - { - return Unauthorized(); - } - else - { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - Debug.Assert(ModelState.Count == 0); - } - - return Ok(); - } -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : ControllerBase -{ - public IActionResult GetPetId() - { - if (User == null) - { - return Unauthorized(); - } - else - { - Debug.Assert(ModelState.Count == 0); - } - - return Ok(); - } -}"; - - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - } -} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApiActionsAreAttributeRoutedFacts.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApiActionsAreAttributeRoutedFacts.cs deleted file mode 100644 index c9225692aa..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApiActionsAreAttributeRoutedFacts.cs +++ /dev/null @@ -1,294 +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.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.Diagnostics; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public class ApiActionsAreAttributeRoutedFacts : AnalyzerTestBase - { - protected override DiagnosticAnalyzer DiagnosticAnalyzer { get; } - = new ApiActionsAreAttributeRoutedAnalyzer(); - - protected override CodeFixProvider CodeFixProvider { get; } - = new ApiActionsAreAttributeRoutedFixProvider(); - - [Fact] - public async Task NoDiagnosticsAreReturned_FoEmptyScenarios() - { - // Arrange - var test = @""; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenTypeIsNotApiController() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -public class HomeController : Controller -{ - public IActionResult Index() => View(); -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenApiControllerActionHasAttribute() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : Controller -{ - [HttpGet] - public int GetPetId() => 0; -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_ForConstructors() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : Controller -{ - public PetController(){ } -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_ForNonActions() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController : Controller -{ - private int GetPetIdPrivate() => 0; - protected int GetPetIdProtected() => 0; - public static IActionResult FindPetByStatus(int status) => null; - [NonAction] - public object Reset(int state) => null; -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task DiagnosticsAndCodeFixes_WhenApiControllerActionDoesNotHaveAttribute() - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7000", - Message = "Actions on types annotated with ApiControllerAttribute must be attribute routed.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 8, 16) } - }; - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -[Route] -public class PetController : Controller -{ - public int GetPetId() => 0; -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -[Route] -public class PetController : Controller -{ - [HttpGet] - public int GetPetId() => 0; -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task CodeFixes_ApplyFullyQualifiedNames() - { - // Arrange - var test = -@" -[Microsoft.AspNetCore.Mvc.ApiController] -[Microsoft.AspNetCore.Mvc.Route] -public class PetController -{ - public object GetPet() => null; -}"; - var expectedFix = -@" -[Microsoft.AspNetCore.Mvc.ApiController] -[Microsoft.AspNetCore.Mvc.Route] -public class PetController -{ - [Microsoft.AspNetCore.Mvc.HttpGet] - public object GetPet() => null; -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Theory] - [InlineData("id")] - [InlineData("petId")] - public async Task CodeFixes_WithIdParameter(string idParameter) - { - // Arrange - var test = -$@" -using Microsoft.AspNetCore.Mvc; -[ApiController] -[Route] -public class PetController -{{ - public IActionResult Post(string notid, int {idParameter}) => null; -}}"; - var expectedFix = -$@" -using Microsoft.AspNetCore.Mvc; -[ApiController] -[Route] -public class PetController -{{ - [HttpPost(""{{{idParameter}}}"")] - public IActionResult Post(string notid, int {idParameter}) => null; -}}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task CodeFixes_WithRouteParameter() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; -[ApiController] -[Route] -public class PetController -{ - public IActionResult DeletePetByStatus([FromRoute] Status status, [FromRoute] Category category) => null; -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; -[ApiController] -[Route] -public class PetController -{ - [HttpDelete(""{status}/{category}"")] - public IActionResult DeletePetByStatus([FromRoute] Status status, [FromRoute] Category category) => null; -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task CodeFixes_WhenAttributeCannotBeInferred() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; -[ApiController] -[Route] -public class PetController -{ - public IActionResult ModifyPet() => null; -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; -[ApiController] -[Route] -public class PetController -{ - [HttpPut] - public IActionResult ModifyPet() => null; -}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - // There isn't a good way to test all fixes simultaneously. We'll pick the last one to verify when we - // expect to have 4 fixes. - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics, codeFixIndex: 3); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - } -} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApiActionsShouldUseActionResultOfTFacts.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApiActionsShouldUseActionResultOfTFacts.cs deleted file mode 100644 index 5dc16ac2fe..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/ApiActionsShouldUseActionResultOfTFacts.cs +++ /dev/null @@ -1,257 +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.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.Diagnostics; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public class ApiActionsShouldUseActionResultOfTFacts : AnalyzerTestBase - { - protected override DiagnosticAnalyzer DiagnosticAnalyzer { get; } - = new ApiActionsShouldUseActionResultOfTAnalyzer(); - - protected override CodeFixProvider CodeFixProvider { get; } - = new ApiActionsShouldUseActionResultOfTCodeFixProvider(); - - [Fact] - public async Task NoDiagnosticsAreReturned_FoEmptyScenarios() - { - // Arrange - var test = @""; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenTypeIsNotApiController() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -public class HomeController: ControllerBase -{ - public IActionResult Index() => View(); -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_ForNonActions() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController: ControllerBaseBase -{ - private int GetPetIdPrivate() => 0; - protected int GetPetIdProtected() => 0; - public static IActionResult FindPetByStatus(int status) => null; - [NonAction] - public object Reset(int state) => null; -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenActionAreExpressionBodiedMembers() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -[ApiController] -public class PetController: ControllerBase -{ - public IActionResult GetPetId() => ModelState.IsValid ? OK(new object()) : BadResult(); -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Theory] - [InlineData("Pet")] - [InlineData("List")] - [InlineData("System.Threading.Task")] - public async Task NoDiagnosticsAreReturned_WhenTypeReturnsNonObjectResult(string returnType) - { - // Arrange - var test = -$@" -using Microsoft.AspNetCore.Mvc; - -public class Pet {{ }} - -[ApiController] -public class PetController: ControllerBase -{{ - public {returnType} GetPetId() => null; -}}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_WhenTypeReturnsActionResultOfT() - { - // Arrange - var test = -@" -using Microsoft.AspNetCore.Mvc; - -public class Pet { } - -[ApiController] -public class PetController: ControllerBase -{ - public ActionResult GetPetId() => null; -}"; - var project = CreateProject(test); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task DiagnosticsAreReturned_WhenActionsReturnIActionResult() - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7002", - Message = "Actions on types annotated with ApiControllerAttribute should return ActionResult.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 9, 12) } - }; - var test = -@" -using Microsoft.AspNetCore.Mvc; - -public class Pet {} - -[ApiController] -public class PetController: ControllerBase -{ - public IActionResult GetPet() - { - return Ok(new Pet()); - } -}"; - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; - -public class Pet {} - -[ApiController] -public class PetController: ControllerBase -{ - public ActionResult GetPet() - { - return Ok(new Pet()); - } -}"; - var project = CreateProject(test); - - // Act - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task DiagnosticsAreReturned_WhenActionReturnsAsyncIActionResult() - { - // Arrange - var expectedDiagnostic = new DiagnosticResult - { - Id = "MVC7002", - Message = "Actions on types annotated with ApiControllerAttribute should return ActionResult.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test.cs", 8, 18) } - }; - - var test = -@" -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -[ApiController] -public class PetController: ControllerBase -{ - public async Task GetPet() - { - await Task.Delay(0); - return Ok(new Pet()); - } -} -public class Pet {}"; - - var expectedFix = -@" -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -[ApiController] -public class PetController: ControllerBase -{ - public async Task> GetPet() - { - await Task.Delay(0); - return Ok(new Pet()); - } -} -public class Pet {}"; - var project = CreateProject(test); - - // Act & Assert - var actualDiagnostics = await GetDiagnosticAsync(project); - Assert.DiagnosticsEqual(new[] { expectedDiagnostic }, actualDiagnostics); - - var actualFix = await ApplyCodeFixAsync(project, actualDiagnostics); - Assert.Equal(expectedFix, actualFix, ignoreLineEndingDifferences: true); - } - } -} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test.csproj deleted file mode 100644 index b5d9571c75..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - $(StandardTestTfms) - true - - - - - - - - - - - - - - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/AvoidHtmlPartialAnalyzerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/AvoidHtmlPartialAnalyzerTest.cs deleted file mode 100644 index 1dc47a4110..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/AvoidHtmlPartialAnalyzerTest.cs +++ /dev/null @@ -1,202 +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.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - public class AvoidHtmlPartialAnalyzerTest : AnalyzerTestBase - { - private static DiagnosticDescriptor DiagnosticDescriptor = DiagnosticDescriptors.MVC1000_HtmlHelperPartialShouldBeAvoided; - - protected override DiagnosticAnalyzer DiagnosticAnalyzer { get; } = new AvoidHtmlPartialAnalyzer(); - - [Fact] - public async Task NoDiagnosticsAreReturned_FoEmptyScenarios() - { - // Arrange - var project = CreateProject(source: string.Empty); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_ForNonUseOfHtmlPartial() - { - // Arrange - var project = CreateProjectFromFile(); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_ForUseOfHtmlPartialAsync() - { - // Arrange - var project = CreateProjectFromFile(); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task DiagnosticsAreReturned_ForUseOfHtmlPartial() - { - // Arrange - var project = CreateProjectFromFile(); - var expectedLocation = DefaultMarkerLocation.Value; - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Collection( - result, - diagnostic => - { - - Assert.Equal(DiagnosticDescriptor.Id, diagnostic.Id); - Assert.Same(DiagnosticDescriptor, diagnostic.Descriptor); - Assert.DiagnosticLocation(expectedLocation, diagnostic.Location); - }); - } - - [Fact] - public async Task DiagnosticsAreReturned_ForUseOfHtmlPartial_WithAdditionalParameters() - { - // Arrange - var project = CreateProjectFromFile(); - var expectedLocation = DefaultMarkerLocation.Value; - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Collection( - result, - diagnostic => - { - - Assert.Equal(DiagnosticDescriptor.Id, diagnostic.Id); - Assert.Same(DiagnosticDescriptor, diagnostic.Descriptor); - Assert.DiagnosticLocation(expectedLocation, diagnostic.Location); - }); - } - - [Fact] - public async Task DiagnosticsAreReturned_ForUseOfHtmlPartial_InSections() - { - // Arrange - var project = CreateProjectFromFile(); - var expectedLocation = DefaultMarkerLocation.Value; - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Collection( - result, - diagnostic => - { - - Assert.Equal(DiagnosticDescriptor.Id, diagnostic.Id); - Assert.Same(DiagnosticDescriptor, diagnostic.Descriptor); - Assert.DiagnosticLocation(expectedLocation, diagnostic.Location); - }); - } - - [Fact] - public async Task NoDiagnosticsAreReturned_ForUseOfRenderPartialAsync() - { - // Arrange - var project = CreateProjectFromFile(); - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task DiagnosticsAreReturned_ForUseOfRenderPartial() - { - // Arrange - var project = CreateProjectFromFile(); - var expectedLocation = DefaultMarkerLocation.Value; - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Collection( - result, - diagnostic => - { - - Assert.Equal(DiagnosticDescriptor.Id, diagnostic.Id); - Assert.Same(DiagnosticDescriptor, diagnostic.Descriptor); - Assert.DiagnosticLocation(expectedLocation, diagnostic.Location); - }); - } - - [Fact] - public async Task DiagnosticsAreReturned_ForUseOfRenderPartial_WithAdditionalParameters() - { - // Arrange - var project = CreateProjectFromFile(); - var expectedLocation = DefaultMarkerLocation.Value; - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Collection( - result, - diagnostic => - { - - Assert.Equal(DiagnosticDescriptor.Id, diagnostic.Id); - Assert.Same(DiagnosticDescriptor, diagnostic.Descriptor); - Assert.DiagnosticLocation(expectedLocation, diagnostic.Location); - }); - } - - [Fact] - public async Task DiagnosticsAreReturned_ForUseOfRenderPartial_InSections() - { - // Arrange - var project = CreateProjectFromFile(); - var expectedLocation = DefaultMarkerLocation.Value; - - // Act - var result = await GetDiagnosticAsync(project); - - // Assert - Assert.Collection( - result, - diagnostic => - { - - Assert.Equal(DiagnosticDescriptor.Id, diagnostic.Id); - Assert.Same(DiagnosticDescriptor, diagnostic.Descriptor); - Assert.DiagnosticLocation(expectedLocation, diagnostic.Location); - }); - } - } -} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/AnalyzerTestBase.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/AnalyzerTestBase.cs deleted file mode 100644 index 73f2cae3f6..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/AnalyzerTestBase.cs +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Testing; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.DependencyModel; - -namespace Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure -{ - public abstract class AnalyzerTestBase : IDisposable - { - private static readonly object WorkspaceLock = new object(); - - public Workspace Workspace { get; private set; } - - protected abstract DiagnosticAnalyzer DiagnosticAnalyzer { get; } - - protected virtual CodeFixProvider CodeFixProvider { get; } - - public IDictionary MarkerLocations { get; } = new Dictionary(); - - public DiagnosticResultLocation? DefaultMarkerLocation { get; private set; } - - protected Project CreateProjectFromFile([CallerMemberName] string fileName = "") - { - var solutionDirectory = TestPathUtilities.GetSolutionRootDirectory("Mvc"); - var projectDirectory = Path.Combine(solutionDirectory, "test", GetType().Assembly.GetName().Name); - - var filePath = Path.Combine(projectDirectory, "TestFiles", fileName + ".cs"); - if (!File.Exists(filePath)) - { - throw new FileNotFoundException($"TestFile {fileName} could not be found at {filePath}.", filePath); - } - - const string MarkerStart = "/*MM"; - const string MarkerEnd = "*/"; - - var lines = File.ReadAllLines(filePath); - for (var i = 0; i < lines.Length; i++) - { - var line = lines[i]; - var markerStartIndex = line.IndexOf(MarkerStart, StringComparison.Ordinal); - if (markerStartIndex != -1) - { - var markerEndIndex = line.IndexOf(MarkerEnd, markerStartIndex, StringComparison.Ordinal); - var markerName = line.Substring(markerStartIndex + 2, markerEndIndex - markerStartIndex - 2); - var resultLocation = new DiagnosticResultLocation(i + 1, markerStartIndex + 1); ; - - if (DefaultMarkerLocation == null) - { - DefaultMarkerLocation = resultLocation; - } - - MarkerLocations[markerName] = resultLocation; - line = line.Substring(0, markerStartIndex) + line.Substring(markerEndIndex + MarkerEnd.Length); - } - - lines[i] = line; - } - - var inputSource = string.Join(Environment.NewLine, lines); - return CreateProject(inputSource); - } - - protected Project CreateProject(string source) - { - var projectId = ProjectId.CreateNewId(debugName: "TestProject"); - var newFileName = "Test.cs"; - var documentId = DocumentId.CreateNewId(projectId, debugName: newFileName); - var metadataReferences = DependencyContext.Load(GetType().Assembly) - .CompileLibraries - .SelectMany(c => c.ResolveReferencePaths()) - .Select(path => MetadataReference.CreateFromFile(path)) - .Cast() - .ToList(); - - lock (WorkspaceLock) - { - if (Workspace == null) - { - Workspace = new AdhocWorkspace(); - } - } - - var solution = Workspace - .CurrentSolution - .AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp) - .AddMetadataReferences(projectId, metadataReferences) - .AddDocument(documentId, newFileName, SourceText.From(source)); - - return solution.GetProject(projectId); - } - - protected async Task GetDiagnosticAsync(Project project) - { - var compilation = await project.GetCompilationAsync(); - var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(DiagnosticAnalyzer)); - var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); - return diagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray(); - } - - protected Task ApplyCodeFixAsync( - Project project, - Diagnostic[] analyzerDiagnostic, - int codeFixIndex = 0) - { - var diagnostic = analyzerDiagnostic.Single(); - return ApplyCodeFixAsync(project, diagnostic, codeFixIndex); - } - - protected async Task ApplyCodeFixAsync( - Project project, - Diagnostic analyzerDiagnostic, - int codeFixIndex = 0) - { - if (CodeFixProvider == null) - { - throw new InvalidOperationException($"{nameof(CodeFixProvider)} has not been assigned."); - } - - var document = project.Documents.Single(); - var actions = new List(); - var context = new CodeFixContext(document, analyzerDiagnostic, (a, d) => actions.Add(a), CancellationToken.None); - await CodeFixProvider.RegisterCodeFixesAsync(context); - - if (actions.Count == 0) - { - throw new InvalidOperationException("CodeFix produced no actions to apply."); - } - - var updatedSolution = await ApplyFixAsync(actions[codeFixIndex]); - // Todo: figure out why this doesn't work. - // var updatedProject = updatedSolution.GetProject(project.Id); - // await EnsureCompilable(updatedProject); - - var updatedDocument = updatedSolution.GetDocument(document.Id); - var sourceText = await updatedDocument.GetTextAsync(); - return sourceText.ToString(); - } - - private static async Task EnsureCompilable(Project project) - { - var compilation = await project - .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) - .GetCompilationAsync(); - var diagnostics = compilation.GetDiagnostics(); - if (diagnostics.Length != 0) - { - var message = string.Join( - Environment.NewLine, - diagnostics.Select(d => CSharpDiagnosticFormatter.Instance.Format(d))); - throw new InvalidOperationException($"Compilation failed:{Environment.NewLine}{message}"); - } - } - - private static async Task ApplyFixAsync(CodeAction codeAction) - { - var operations = await codeAction.GetOperationsAsync(CancellationToken.None); - return Assert.Single(operations.OfType()).ChangedSolution; - } - - public void Dispose() - { - Workspace?.Dispose(); - } - } -} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/Assert.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/Assert.cs deleted file mode 100644 index ea57f2f5b8..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/Assert.cs +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Formatting; -using Microsoft.CodeAnalysis.Simplification; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - internal class Assert : Xunit.Assert - { - public static void DiagnosticsEqual(IEnumerable expected, IEnumerable actual) - { - var expectedCount = expected.Count(); - var actualCount = actual.Count(); - - if (expectedCount != actualCount) - { - throw new DiagnosticsAssertException( - expected, - actual, - $"Mismatch between number of diagnostics returned, expected \"{expectedCount}\" actual \"{actualCount}."); - } - - foreach (var (expectedItem, actualItem) in expected.Zip(actual, (a, b) => (a, b))) - { - if (expectedItem.Line == -1 && expectedItem.Column == -1) - { - if (actualItem.Location != Location.None) - { - throw new DiagnosticAssertException( - expectedItem, - actualItem, - $"Expected: A project diagnostic with no location. Actual {actualItem.Location}."); - } - } - else - { - VerifyLocation(expectedItem, actualItem); - } - - if (actualItem.Id != expectedItem.Id) - { - throw new DiagnosticAssertException( - expectedItem, - actualItem, - $"Expected: Expected id: {expectedItem.Id}. Actual id: {actualItem.Id}."); - } - - if (actualItem.Severity != expectedItem.Severity) - { - throw new DiagnosticAssertException( - expectedItem, - actualItem, - $"Expected: Expected severity: {expectedItem.Severity}. Actual severity: {actualItem.Severity}."); - } - - if (actualItem.GetMessage() != expectedItem.Message) - { - throw new DiagnosticAssertException( - expectedItem, - actualItem, - $"Expected: Expected message: {expectedItem.Message}. Actual message: {actualItem.GetMessage()}."); - } - } - } - - private static void VerifyLocation(DiagnosticResult expected, Diagnostic actual) - { - if (expected.Locations.Length == 0) - { - return; - } - - var expectedLocation = expected.Locations[0]; - Assert.DiagnosticLocation(expectedLocation, actual.Location); - - } - - public static void DiagnosticLocation(DiagnosticResultLocation expected, Location actual) - { - var actualSpan = actual.GetLineSpan(); - var actualLinePosition = actualSpan.StartLinePosition; - - // Only check line position if there is an actual line in the real diagnostic - if (actualLinePosition.Line > 0) - { - if (actualLinePosition.Line + 1 != expected.Line) - { - throw new DiagnosticLocationAssertException( - expected, - actual, - $"Expected diagnostic to be on line \"{expected.Line}\" was actually on line \"{actualLinePosition.Line + 1}\""); - } - } - - // Only check column position if there is an actual column position in the real diagnostic - if (actualLinePosition.Character > 0) - { - if (actualLinePosition.Character + 1 != expected.Column) - { - throw new DiagnosticLocationAssertException( - expected, - actual, - $"Expected diagnostic to start at column \"{expected.Column}\" was actually on line \"{actualLinePosition.Character + 1}\""); - } - } - } - - private static string FormatDiagnostics(IEnumerable diagnostics) - { - return string.Join(Environment.NewLine, diagnostics.Select(FormatDiagnostic)); - } - - private static string FormatDiagnostic(Diagnostic diagnostic) - { - var builder = new StringBuilder(); - builder.AppendLine(diagnostic.ToString()); - - var location = diagnostic.Location; - if (location == Location.None) - { - builder.Append($"Location unknown: ({diagnostic.Id})"); - } - else - { - True(location.IsInSource, - $"Test base does not currently handle diagnostics in metadata locations. Diagnostic in metadata: {diagnostic}"); - - var linePosition = location.GetLineSpan().StartLinePosition; - builder.Append($"({(linePosition.Line + 1)}, {(linePosition.Character + 1)}, {diagnostic.Id})"); - } - - return builder.ToString(); - } - - private static async Task GetStringFromDocumentAsync(Document document) - { - var simplifiedDoc = await Simplifier.ReduceAsync(document, Simplifier.Annotation); - var root = await simplifiedDoc.GetSyntaxRootAsync(); - root = Formatter.Format(root, Formatter.Annotation, simplifiedDoc.Project.Solution.Workspace); - return root.GetText().ToString(); - } - - private class DiagnosticsAssertException : Xunit.Sdk.EqualException - { - public DiagnosticsAssertException( - IEnumerable expected, - IEnumerable actual, - string message) - : base(expected, actual) - { - Message = message + Environment.NewLine + FormatDiagnostics(actual); - } - - public override string Message { get; } - } - - private class DiagnosticAssertException : Xunit.Sdk.EqualException - { - public DiagnosticAssertException( - DiagnosticResult expected, - Diagnostic actual, - string message) - : base(expected, actual) - { - Message = message + Environment.NewLine + FormatDiagnostic(actual); - } - - public override string Message { get; } - } - - private class DiagnosticLocationAssertException : Xunit.Sdk.EqualException - { - public DiagnosticLocationAssertException( - DiagnosticResultLocation expected, - Location actual, - string message) - : base(expected, actual) - { - Message = message; - } - - public override string Message { get; } - } - - } -} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/DiagnosticResult.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/DiagnosticResult.cs deleted file mode 100644 index c1c32bd025..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Infrastructure/DiagnosticResult.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using Microsoft.CodeAnalysis; - -namespace Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure -{ - /// - /// Location where the diagnostic appears, as determined by path, line number, and column number. - /// - public struct DiagnosticResultLocation - { - public DiagnosticResultLocation(int line, int column) - : this("Test.cs", line, column) - { - } - - public DiagnosticResultLocation(string path, int line, int column) - { - if (line < -1) - { - throw new ArgumentOutOfRangeException(nameof(line), "line must be >= -1"); - } - - if (column < -1) - { - throw new ArgumentOutOfRangeException(nameof(column), "column must be >= -1"); - } - - Path = path; - Line = line; - Column = column; - } - - public string Path { get; } - public int Line { get; } - public int Column { get; } - } - - /// - /// Struct that stores information about a Diagnostic appearing in a source - /// - public struct DiagnosticResult - { - private DiagnosticResultLocation[] _locations; - - public DiagnosticResultLocation[] Locations - { - get - { - if (_locations == null) - { - _locations = new DiagnosticResultLocation[] { }; - } - - return _locations; - } - - set => _locations = value; - } - - public DiagnosticSeverity Severity { get; set; } - - public string Id { get; set; } - - public string Message { get; set; } - - public string Path => Locations.Length > 0 ? Locations[0].Path : ""; - - public int Line => Locations.Length > 0 ? Locations[0].Line : -1; - - public int Column => Locations.Length > 0 ? Locations[0].Column : -1; - } -} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs new file mode 100644 index 0000000000..d990cdd2ff --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs @@ -0,0 +1,769 @@ +// 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 System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + public class ApiResponseTypeProviderTest + { + [Fact] + public void GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresent() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController.Get)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new[] + { + new ProducesResponseTypeAttribute(201), + new ProducesResponseTypeAttribute(404), + }); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => + { + Assert.Equal("application/json", format.MediaType); + Assert.IsType(format.Formatter); + }); + }, + responseType => + { + Assert.Equal(301, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + + [ApiConventionType(typeof(DefaultApiConventions))] + public class GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController : ControllerBase + { + [Produces(typeof(BaseModel))] + [ProducesResponseType(301)] + [ProducesResponseType(404)] + public Task> Get(int id) => null; + } + + [Fact] + public void GetApiResponseTypes_CombinesFilters() + { + // Arrange + var filterDescriptors = new[] + { + new FilterDescriptor(new ProducesResponseTypeAttribute(400), FilterScope.Global), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(object), 201), FilterScope.Controller), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(ProblemDetails), 400), FilterScope.Controller), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(BaseModel), 201), FilterScope.Action), + new FilterDescriptor(new ProducesResponseTypeAttribute(404), FilterScope.Action), + }; + + var actionDescriptor = new ControllerActionDescriptor + { + FilterDescriptors = filterDescriptors, + MethodInfo = typeof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController).GetMethod(nameof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController.Get)), + }; + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => + { + Assert.Equal("application/json", format.MediaType); + Assert.IsType(format.Formatter); + }); + }, + responseType => + { + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => + { + Assert.Equal("application/json", format.MediaType); + Assert.IsType(format.Formatter); + }); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + + [Fact] + public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new[] + { + new ProducesResponseTypeAttribute(200), + new ProducesResponseTypeAttribute(400), + new ProducesResponseTypeAttribute(404), + }); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => + { + Assert.Equal("application/json", format.MediaType); + Assert.IsType(format.Formatter); + }); + }, + responseType => + { + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + + [ApiConventionType(typeof(DefaultApiConventions))] + public class GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController : ControllerBase + { + public Task> DeleteBase(int id) => null; + } + + [Fact] + public void GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatch() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatchController), + nameof(GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatchController.PostModel)); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }); + } + + [ApiConventionType(typeof(DefaultApiConventions))] + public class GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatchController : ControllerBase + { + public Task> PostModel(int id, BaseModel model) => null; + } + + [Fact] + public void GetApiResponseTypes_ReturnsDefaultProblemResponse() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(201), + new ProducesResponseTypeAttribute(404), + new ProducesDefaultResponseTypeAttribute(typeof(SerializableError)), + }); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + Assert.Equal(typeof(SerializableError), responseType.Type); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + + public class GetApiResponseTypes_WithApiConventionMethodAndProducesResponseType : ControllerBase + { + [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))] + [ProducesResponseType(201)] + [ProducesResponseType(404)] + public Task> Put(int id, BaseModel model) => null; + } + + [Fact] + public void GetApiResponseTypes_ReturnsValuesFromProducesResponseType_IfApiConventionMethodAndAttributesAreSpecified() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_WithApiConventionMethodAndProducesResponseType), + nameof(GetApiResponseTypes_WithApiConventionMethodAndProducesResponseType.Put)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(200), + new ProducesResponseTypeAttribute(404), + new ProducesDefaultResponseTypeAttribute(), + }); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + + [Fact] + public void GetApiResponseTypes_UsesErrorType_ForClientErrors() + { + // Arrange + var errorType = typeof(InvalidTimeZoneException); + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(200), + new ProducesResponseTypeAttribute(404), + new ProducesResponseTypeAttribute(415), + }); + + actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(errorType); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(errorType, responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(415, responseType.StatusCode); + Assert.Equal(errorType, responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }); + } + + [Fact] + public void GetApiResponseTypes_UsesErrorType_ForDefaultResponse() + { + // Arrange + var errorType = typeof(ProblemDetails); + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(200), + new ProducesDefaultResponseTypeAttribute(), + }); + + actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(errorType); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(errorType, responseType.Type); + Assert.True(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }); + } + + [Fact] + public void GetApiResponseTypes_DoesNotUseErrorType_IfSpecified() + { + // Arrange + var errorType = typeof(InvalidTimeZoneException); + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(200), + new ProducesResponseTypeAttribute(typeof(DivideByZeroException), 415), + new ProducesDefaultResponseTypeAttribute(typeof(DivideByZeroException)), + }); + + actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(errorType); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(DivideByZeroException), responseType.Type); + Assert.True(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(415, responseType.StatusCode); + Assert.Equal(typeof(DivideByZeroException), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }); + } + + [Fact] + public void GetApiResponseTypes_DoesNotUseErrorType_ForNonClientErrors() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(201), + new ProducesResponseTypeAttribute(300), + new ProducesResponseTypeAttribute(500), + }); + + actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(typeof(InvalidTimeZoneException)); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(300, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.Empty(responseType.ApiResponseFormats); + }, + responseType => + { + Assert.Equal(500, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + + [Fact] + public void GetApiResponseTypes_AllowsUsingVoid() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(typeof(InvalidCastException), 400), + new ProducesResponseTypeAttribute(415), + new ProducesDefaultResponseTypeAttribute(), + }); + + actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(typeof(void)); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + Assert.Equal(typeof(void), responseType.Type); + Assert.Empty(responseType.ApiResponseFormats); + }, + responseType => + { + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(typeof(InvalidCastException), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(415, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + + [Fact] + public void GetApiResponseTypes_CombinesProducesAttributeAndConventions() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.PutModel)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute("application/json"), FilterScope.Controller)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(200), + new ProducesResponseTypeAttribute(400), + new ProducesDefaultResponseTypeAttribute(), + }); + actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(typeof(ProblemDetails)); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(DerivedModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }); + } + + [Fact] + public void GetApiResponseTypes_DoesNotCombineProducesAttributeThatSpecifiesType() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.PutModel)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute("application/json") { Type = typeof(string) }, FilterScope.Controller)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(200), + new ProducesResponseTypeAttribute(400), + new ProducesDefaultResponseTypeAttribute(), + }); + actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(typeof(ProblemDetails)); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(string), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }); + } + + [Fact] + public void GetApiResponseTypes_DoesNotCombineProducesResponseTypeAttributeThatSpecifiesStatusCode() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.PutModel)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(200), + }); + actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(typeof(ProblemDetails)); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(DerivedModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }); + } + + [Fact] + public void GetApiResponseTypes_UsesContentTypeWithoutWildCard_WhenNoFormatterSupportsIt() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetUser)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute("application/pdf"), FilterScope.Action)); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(DerivedModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => + { + Assert.Equal("application/pdf", format.MediaType); + Assert.Null(format.Formatter); + }); + }); + } + + private static ApiResponseTypeProvider GetProvider() + { + var mvcOptions = new MvcOptions + { + OutputFormatters = { new TestOutputFormatter() }, + }; + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), mvcOptions); + return provider; + } + + private static ControllerActionDescriptor GetControllerActionDescriptor(Type type, string name) + { + var method = type.GetMethod(name); + var actionDescriptor = new ControllerActionDescriptor + { + MethodInfo = method, + FilterDescriptors = new List(), + }; + + foreach (var filterAttribute in method.GetCustomAttributes().OfType()) + { + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(filterAttribute, FilterScope.Action)); + } + + return actionDescriptor; + } + + public class BaseModel { } + + public class DerivedModel : BaseModel { } + + public class TestController + { + public ActionResult GetUser(int id) => null; + + public ActionResult GetUserLocation(int a, int b) => null; + + public ActionResult PutModel(string userId, DerivedModel model) => null; + } + + private class TestOutputFormatter : OutputFormatter + { + public TestOutputFormatter() + { + SupportedMediaTypes.Add(new Net.Http.Headers.MediaTypeHeaderValue("application/json")); + } + + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context) => Task.CompletedTask; + } + + public static class SearchApiConventions + { + [ProducesResponseType(206)] + [ProducesResponseType(406)] + public static void Search(object searchTerm, int page) { } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs index c55d427d9d..81f17cb58b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs @@ -22,6 +22,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Constraints; +using Microsoft.AspNetCore.Routing.Template; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Moq; @@ -386,6 +387,25 @@ namespace Microsoft.AspNetCore.Mvc.Description Assert.Single(description.ParameterDescriptions, p => p.Name == "id5"); } + [Fact] + public void GetApiDescription_ProducesLowerCaseRelativePaths() + { + // Arrange + var action = CreateActionDescriptor(); + action.AttributeRouteInfo = new AttributeRouteInfo + { + Template = "api/Products/UpdateProduct/{productId}" + }; + var routeOptions = new RouteOptions { LowercaseUrls = true }; + + // Act + var descriptions = GetApiDescriptions(action, routeOptions: routeOptions); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal("api/products/updateproduct/{productId}", description.RelativePath); + } + [Fact] public void GetApiDescription_PopulatesResponseType_WithProduct() { @@ -1161,7 +1181,7 @@ namespace Microsoft.AspNetCore.Mvc.Description } [Fact] - public void GetApiDescription_ParameterDescription_IsRequiredNotSet_IfNotValiatingTopLevelNodes() + public void GetApiDescription_ParameterDescription_IsRequiredNotSet_IfNotValidatingTopLevelNodes() { // Arrange var action = CreateActionDescriptor(nameof(RequiredParameter)); @@ -1545,10 +1565,10 @@ namespace Microsoft.AspNetCore.Mvc.Description } [Fact] - public void GetApiDescription_ParameterDescription_RedundentMetadata_NotMergedWithParent() + public void GetApiDescription_ParameterDescription_RedundantMetadata_NotMergedWithParent() { // Arrange - var action = CreateActionDescriptor(nameof(AcceptsRedundentMetadata)); + var action = CreateActionDescriptor(nameof(AcceptsRedundantMetadata)); var parameterDescriptor = action.Parameters.Single(); // Act @@ -1570,7 +1590,7 @@ namespace Microsoft.AspNetCore.Mvc.Description } [Fact] - public void GetApiDescription_ParameterDescription_RedundentMetadata_WithParameterMetadata() + public void GetApiDescription_ParameterDescription_RedundantMetadata_WithParameterMetadata() { // Arrange var action = CreateActionDescriptor(nameof(AcceptsPerson)); @@ -1626,11 +1646,178 @@ namespace Microsoft.AspNetCore.Mvc.Description Assert.Equal(typeof(string), comments.Type); } + [Fact] + public void ProcessIsRequired_SetsTrue_ForFromBodyParameters() + { + // Arrange + var description = new ApiParameterDescription { Source = BindingSource.Body, }; + var context = GetApiParameterContext(description); + + // Act + DefaultApiDescriptionProvider.ProcessIsRequired(context); + + // Assert + Assert.True(description.IsRequired); + } + + [Fact] + public void ProcessIsRequired_SetsTrue_ForParameterDescriptorsWithBindRequired() + { + // Arrange + var description = new ApiParameterDescription + { + Source = BindingSource.Query, + }; + var context = GetApiParameterContext(description); + var modelMetadataProvider = new TestModelMetadataProvider(); + modelMetadataProvider + .ForProperty(nameof(Person.Name)) + .BindingDetails(d => d.IsBindingRequired = true); + description.ModelMetadata = modelMetadataProvider.GetMetadataForProperty(typeof(Person), nameof(Person.Name)); + + // Act + DefaultApiDescriptionProvider.ProcessIsRequired(context); + + // Assert + Assert.True(description.IsRequired); + } + + [Fact] + public void ProcessIsRequired_SetsTrue_ForRequiredRouteParameterDescriptors() + { + // Arrange + var description = new ApiParameterDescription + { + Source = BindingSource.Path, + RouteInfo = new ApiParameterRouteInfo(), + }; + var context = GetApiParameterContext(description); + + // Act + DefaultApiDescriptionProvider.ProcessIsRequired(context); + + // Assert + Assert.True(description.IsRequired); + } + + [Fact] + public void ProcessIsRequired_DoesNotSetToTrue_ByDefault() + { + // Arrange + var description = new ApiParameterDescription(); + var context = GetApiParameterContext(description); + + // Act + DefaultApiDescriptionProvider.ProcessIsRequired(context); + + // Assert + Assert.False(description.IsRequired); + } + + [Fact] + public void ProcessIsRequired_DoesNotSetToTrue_ForParameterDescriptorsWithValidationRequired() + { + // Arrange + var description = new ApiParameterDescription(); + var context = GetApiParameterContext(description); + var modelMetadataProvider = new TestModelMetadataProvider(); + modelMetadataProvider + .ForProperty(nameof(Person.Name)) + .ValidationDetails(d => d.IsRequired = true); + description.ModelMetadata = modelMetadataProvider.GetMetadataForProperty(typeof(Person), nameof(Person.Name)); + + // Act + DefaultApiDescriptionProvider.ProcessIsRequired(context); + + // Assert + Assert.False(description.IsRequired); + } + + [Fact] + public void ProcessDefaultValue_SetsDefaultRouteValue() + { + // Arrange + var methodInfo = GetType().GetMethod(nameof(ParameterDefaultValue), BindingFlags.Instance | BindingFlags.NonPublic); + var parameterInfo = methodInfo.GetParameters()[0]; + + var defaultValue = new object(); + var description = new ApiParameterDescription + { + Source = BindingSource.Path, + RouteInfo = new ApiParameterRouteInfo { DefaultValue = defaultValue }, + ParameterDescriptor = new ControllerParameterDescriptor + { + ParameterInfo = parameterInfo, + }, + }; + var context = GetApiParameterContext(description); + + // Act + DefaultApiDescriptionProvider.ProcessParameterDefaultValue(context); + + // Assert + Assert.Same(defaultValue, description.DefaultValue); + } + + [Fact] + public void ProcessDefaultValue_SetsDefaultValue_FromParameterInfo() + { + // Arrange + var methodInfo = GetType().GetMethod(nameof(ParameterDefaultValue), BindingFlags.Instance | BindingFlags.NonPublic); + var parameterInfo = methodInfo.GetParameters()[0]; + var description = new ApiParameterDescription + { + Source = BindingSource.Query, + ParameterDescriptor = new ControllerParameterDescriptor + { + ParameterInfo = parameterInfo, + }, + }; + var context = GetApiParameterContext(description); + + // Act + DefaultApiDescriptionProvider.ProcessParameterDefaultValue(context); + + // Assert + Assert.Equal(10, description.DefaultValue); + } + + [Fact] + public void ProcessDefaultValue_DoesNotSpecifyDefaultValueForValueTypes_WhenNoValueIsSpecified() + { + // Arrange + var methodInfo = GetType().GetMethod(nameof(AcceptsId_Query), BindingFlags.Instance | BindingFlags.NonPublic); + var parameterInfo = methodInfo.GetParameters()[0]; + var description = new ApiParameterDescription + { + Source = BindingSource.Query, + ParameterDescriptor = new ControllerParameterDescriptor + { + ParameterInfo = parameterInfo, + }, + }; + var context = GetApiParameterContext(description); + + // Act + DefaultApiDescriptionProvider.ProcessParameterDefaultValue(context); + + // Assert + Assert.Null(description.DefaultValue); + } + + private static ApiParameterContext GetApiParameterContext(ApiParameterDescription description) + { + var context = new ApiParameterContext(new EmptyModelMetadataProvider(), new ControllerActionDescriptor(), new TemplatePart[0]); + context.Results.Add(description); + return context; + } + private IReadOnlyList GetApiDescriptions( ActionDescriptor action, List inputFormatters = null, List outputFormatters = null, - bool allowValidatingTopLevelNodes = true) + bool allowValidatingTopLevelNodes = true, + RouteOptions routeOptions = null) { var context = new ApiDescriptionProviderContext(new ActionDescriptor[] { action }); @@ -1660,7 +1847,8 @@ namespace Microsoft.AspNetCore.Mvc.Description optionsAccessor, constraintResolver.Object, modelMetadataProvider, - new ActionResultTypeMapper()); + new ActionResultTypeMapper(), + Options.Create(routeOptions ?? new RouteOptions())); provider.OnProvidersExecuting(context); provider.OnProvidersExecuted(context); @@ -1893,7 +2081,7 @@ namespace Microsoft.AspNetCore.Mvc.Description { } - private void AcceptsRedundentMetadata([FromQuery] RedundentMetadata r) + private void AcceptsRedundantMetadata([FromQuery] RedundantMetadata r) { } @@ -1921,6 +2109,8 @@ namespace Microsoft.AspNetCore.Mvc.Description { } + private void ParameterDefaultValue(int value = 10) { } + private class TestController { [FromRoute] @@ -1954,7 +2144,7 @@ namespace Microsoft.AspNetCore.Mvc.Description { } - public class BaseProducesController : Controller + public class BaseProducesController : ControllerBase { public IActionResult ReturnsActionResult() { @@ -2057,7 +2247,7 @@ namespace Microsoft.AspNetCore.Mvc.Description public string Name { get; set; } } - private class RedundentMetadata + private class RedundantMetadata { [FromQuery] public int Id { get; set; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj index 7a812822b1..b1ca115faa 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj @@ -1,11 +1,13 @@ - + $(StandardTestTfms) - - + + + + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/AcceptedAtActionResultTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/AcceptedAtActionResultTests.cs index d6ab7ad228..242405e9ba 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/AcceptedAtActionResultTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/AcceptedAtActionResultTests.cs @@ -219,7 +219,7 @@ namespace Microsoft.AspNetCore.Mvc } [Fact] - public void OnFormatting_NullUrlHelperContextNoRequestServices_ThrowsArgumentNullExeption() + public void OnFormatting_NullUrlHelperContextNoRequestServices_ThrowsArgumentNullException() { // Arrange var context = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ActionResultOfTTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ActionResultOfTTest.cs index 2445e84677..83e1058b0c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ActionResultOfTTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ActionResultOfTTest.cs @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.IO; using Microsoft.AspNetCore.Mvc.Infrastructure; using Xunit; @@ -8,6 +10,28 @@ namespace Microsoft.AspNetCore.Mvc { public class ActionResultOfTTest { + [Fact] + public void Constructor_WithValue_ThrowsForInvalidType() + { + // Arrange + var input = new FileStreamResult(Stream.Null, "application/json"); + + // Act & Assert + var ex = Assert.Throws(() => new ActionResult(value: input)); + Assert.Equal($"Invalid type parameter '{typeof(FileStreamResult)}' specified for 'ActionResult'.", ex.Message); + } + + [Fact] + public void Constructor_WithActionResult_ThrowsForInvalidType() + { + // Arrange + var actionResult = new OkResult(); + + // Act & Assert + var ex = Assert.Throws(() => new ActionResult(result: actionResult)); + Assert.Equal($"Invalid type parameter '{typeof(FileStreamResult)}' specified for 'ActionResult'.", ex.Message); + } + [Fact] public void Convert_ReturnsResultIfSet() { @@ -44,7 +68,7 @@ namespace Microsoft.AspNetCore.Mvc public void Convert_InfersDeclaredTypeFromActionResultTypeParameter() { // Arrange - var value = new DeriviedItem(); + var value = new DerivedItem(); var actionResultOfT = new ActionResult(value); var convertToActionResult = (IConvertToActionResult)actionResultOfT; @@ -61,7 +85,7 @@ namespace Microsoft.AspNetCore.Mvc { } - private class DeriviedItem : BaseItem + private class DerivedItem : BaseItem { } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionMethodAttributeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionMethodAttributeTest.cs new file mode 100644 index 0000000000..cba34f7b96 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionMethodAttributeTest.cs @@ -0,0 +1,122 @@ +// 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 Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc +{ + public class ApiConventionMethodAttributeTest + { + [Fact] + public void Constructor_ThrowsIfConventionMethodIsAnnotatedWithProducesAttribute() + { + // Arrange + var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get); + var attribute = typeof(ProducesAttribute); + + var expected = GetErrorMessage(methodName, attribute); + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionMethodAttribute(typeof(ConventionWithProducesAttribute), nameof(ConventionWithProducesAttribute.Get)), + "conventionType", + expected); + } + + public static class ConventionWithProducesAttribute + { + [Produces(typeof(void))] + public static void Get() { } + } + + [Fact] + public void Constructor_ThrowsIfTypeIsNotStatic() + { + // Arrange + var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get); + var attribute = typeof(ProducesAttribute); + + var expected = $"API convention type '{typeof(object)}' must be a static type."; + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionMethodAttribute(typeof(object), nameof(object.ToString)), + "conventionType", + expected); + } + + [Fact] + public void Constructor_ThrowsIfMethodCannotBeFound() + { + // Arrange + var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get); + var attribute = typeof(ProducesAttribute); + var type = typeof(TestConventions); + + var expected = $"A method named 'DoesNotExist' was not found on convention type '{type}'."; + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionMethodAttribute(typeof(TestConventions), "DoesNotExist"), + "methodName", + expected); + } + + [Fact] + public void Constructor_ThrowsIfMethodIsNotPublic() + { + // Arrange + var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get); + var attribute = typeof(ProducesAttribute); + var type = typeof(TestConventions); + + var expected = $"A method named 'NotPublic' was not found on convention type '{type}'."; + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionMethodAttribute(typeof(TestConventions), "NotPublic"), + "methodName", + expected); + } + + [Fact] + public void Constructor_ThrowsIfMethodIsAmbiguous() + { + // Arrange + var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get); + var attribute = typeof(ProducesAttribute); + var type = typeof(TestConventions); + + var expected = $"Method name 'Method' is ambiguous for convention type '{type}'. More than one method found with the name 'Method'."; + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionMethodAttribute(typeof(TestConventions), nameof(TestConventions.Method)), + "methodName", + expected); + } + + private static class TestConventions + { + internal static void NotPublic() { } + + public static void Method(int value) { } + + public static void Method(string value) { } + } + + private static string GetErrorMessage(string methodName, params Type[] attributes) + { + return $"Method {methodName} is decorated with the following attributes that are not allowed on an API convention method:" + + Environment.NewLine + + string.Join(Environment.NewLine, attributes.Select(a => a.FullName)) + + Environment.NewLine + + $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ProducesDefaultResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs new file mode 100644 index 0000000000..d6ff2a28f4 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs @@ -0,0 +1,91 @@ +// 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 Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc +{ + public class ApiConventionTypeAttributeTest + { + [Fact] + public void Constructor_ThrowsIfConventionMethodIsAnnotatedWithProducesAttribute() + { + // Arrange + var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get); + var attribute = typeof(ProducesAttribute); + + var expected = GetErrorMessage(methodName, attribute); + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionTypeAttribute(typeof(ConventionWithProducesAttribute)), + "conventionType", + expected); + } + + public static class ConventionWithProducesAttribute + { + [Produces(typeof(void))] + public static void Get() { } + } + + [Fact] + public void Constructor_ThrowsIfConventionMethodHasRouteAttribute() + { + // Arrange + var methodName = typeof(ConventionWithRouteAttribute).FullName + '.' + nameof(ConventionWithRouteAttribute.Get); + var attribute = typeof(HttpGetAttribute); + var expected = GetErrorMessage(methodName, attribute); + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionTypeAttribute(typeof(ConventionWithRouteAttribute)), + "conventionType", + expected); + } + + public static class ConventionWithRouteAttribute + { + [HttpGet("url")] + public static void Get() { } + } + + [Fact] + public void Constructor_ThrowsIfMultipleUnsupportedAttributesArePresentOnConvention() + { + // Arrange + var methodName = typeof(ConventionWitUnsupportedAttributes).FullName + '.' + nameof(ConventionWitUnsupportedAttributes.Get); + var attributes = new[] { typeof(ProducesAttribute), typeof(ServiceFilterAttribute), typeof(AuthorizeAttribute) }; + var expected = GetErrorMessage(methodName, attributes); + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionTypeAttribute(typeof(ConventionWitUnsupportedAttributes)), + "conventionType", + expected); + } + + public static class ConventionWitUnsupportedAttributes + { + [ProducesResponseType(400)] + [Produces(typeof(void))] + [ServiceFilter(typeof(object))] + [Authorize] + public static void Get() { } + } + + private static string GetErrorMessage(string methodName, params Type[] attributes) + { + return $"Method {methodName} is decorated with the following attributes that are not allowed on an API convention method:" + + Environment.NewLine + + string.Join(Environment.NewLine, attributes.Select(a => a.FullName)) + + Environment.NewLine + + $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ProducesDefaultResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionMatcherTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionMatcherTest.cs new file mode 100644 index 0000000000..bbf31785a0 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionMatcherTest.cs @@ -0,0 +1,571 @@ +// 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.Reflection; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + public class ApiConventionMatcherTest + { + [Theory] + [InlineData("Method", "method")] + [InlineData("Method", "ConventionMethod")] + [InlineData("p", "model")] + [InlineData("person", "model")] + public void IsNameMatch_WithAny_AlwaysReturnsTrue(string name, string conventionName) + { + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfNamesDifferInCase() + { + // Arrange + var name = "Name"; + var conventionName = "name"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "Name"; + var conventionName = "Different"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSubString() + { + // Arrange + var name = "RegularName"; + var conventionName = "Regular"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSuperString() + { + // Arrange + var name = "Regular"; + var conventionName = "RegularName"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsTrue_IfExactMatch() + { + // Arrange + var name = "parameterName"; + var conventionName = "parameterName"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsTrue_IfNamesAreExact() + { + // Arrange + var name = "PostPerson"; + var conventionName = "PostPerson"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsTrue_IfNameIsProperPrefix() + { + // Arrange + var name = "PostPerson"; + var conventionName = "Post"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "GetPerson"; + var conventionName = "Post"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesDifferInCase() + { + // Arrange + var name = "GetPerson"; + var conventionName = "post"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsNotProperPrefix() + { + // Arrange + var name = "Postman"; + var conventionName = "Post"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsSuffix() + { + // Arrange + var name = "GoPost"; + var conventionName = "Post"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "name"; + var conventionName = "diff"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnsFalse_IfNameIsNotSuffix() + { + // Arrange + var name = "personId"; + var conventionName = "idx"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsExact() + { + // Arrange + var name = "test"; + var conventionName = "test"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnFalse_IfNameDiffersInCase() + { + // Arrange + var name = "test"; + var conventionName = "Test"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsProperSuffix() + { + // Arrange + var name = "personId"; + var conventionName = "id"; + + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("candid", "id")] + [InlineData("canDid", "id")] + public void IsNameMatch_WithSuffix_ReturnFalse_IfNameIsNotProperSuffix(string name, string conventionName) + { + // Act + var result = ApiConventionMatcher.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(typeof(object), typeof(object))] + [InlineData(typeof(int), typeof(void))] + [InlineData(typeof(string), typeof(DateTime))] + public void IsTypeMatch_WithAny_ReturnsTrue(Type type, Type conventionType) + { + // Act + var result = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssignableFrom_ReturnsTrueForExact() + { + // Arrange + var type = typeof(Base); + var conventionType = typeof(Base); + + // Act + var result = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssignableFrom_ReturnsTrueForDerived() + { + // Arrange + var type = typeof(Derived); + var conventionType = typeof(Base); + + // Act + var result = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssignableFrom_ReturnsFalseForBaseTypes() + { + // Arrange + var type = typeof(Base); + var conventionType = typeof(Derived); + + // Act + var result = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsTypeMatch_WithAssignableFrom_ReturnsFalseForUnrelated() + { + // Arrange + var type = typeof(string); + var conventionType = typeof(Derived); + + // Act + var result = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsFalse_IfMethodNamesDoNotMatch() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Post)); + + // Act + var result = ApiConventionMatcher.IsMatch(method, conventionMethod); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsFalse_IMethodHasMoreParametersThanConvention() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetNoArgs)); + + // Act + var result = ApiConventionMatcher.IsMatch(method, conventionMethod); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsFalse_IfMethodHasFewerParametersThanConvention() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetTwoArgs)); + + // Act + var result = ApiConventionMatcher.IsMatch(method, conventionMethod); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsFalse_IfParametersDoNotMatch() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetParameterNotMatching)); + + // Act + var result = ApiConventionMatcher.IsMatch(method, conventionMethod); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsTrue_IfMethodNameAndParametersMatches() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Get)); + + // Act + var result = ApiConventionMatcher.IsMatch(method, conventionMethod); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsMatch_ReturnsTrue_IfParamsArrayMatchesRemainingArguments() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Search)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Search)); + + // Act + var result = ApiConventionMatcher.IsMatch(method, conventionMethod); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsMatch_WithEmpty_MatchesMethodWithNoParameters() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.SearchEmpty)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.SearchWithParams)); + + // Act + var result = ApiConventionMatcher.IsMatch(method, conventionMethod); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsExact_WhenNoAttributesArePresent() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Exact; + var attributes = new object[0]; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsExact_WhenNoNameMatchBehaviorAttributeIsSpecified() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Exact; + var attributes = new object[] { new CLSCompliantAttribute(false), new ProducesResponseTypeAttribute(200) }; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Prefix; + var attributes = new object[] + { + new CLSCompliantAttribute(false), + new ApiConventionNameMatchAttribute(expected), + new ProducesResponseTypeAttribute(200) } + ; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoAttributesArePresent() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.AssignableFrom; + var attributes = new object[0]; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoMatchingAttributesArePresent() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.AssignableFrom; + var attributes = new object[] { new CLSCompliantAttribute(false), new ProducesResponseTypeAttribute(200) }; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.Any; + var attributes = new object[] + { + new CLSCompliantAttribute(false), + new ApiConventionTypeMatchAttribute(expected), + new ProducesResponseTypeAttribute(200) } + ; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + public class Base { } + + public class Derived : Base { } + + public class TestController + { + public IActionResult Get(int id) => null; + + public IActionResult Search(string searchTerm, bool sortDescending, int page) => null; + + public IActionResult SearchEmpty() => null; + } + + public static class TestConvention + { + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Get(int id) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void GetNoArgs() { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void GetTwoArgs(int id, string name) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Post(Derived model) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void GetParameterNotMatching([ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.AssignableFrom)] Derived model) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void Search( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Exact)] + string searchTerm, + params object[] others) + { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void SearchWithParams(params object[] others) { } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs new file mode 100644 index 0000000000..a1b1bd5bcc --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs @@ -0,0 +1,224 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + public class ApiConventionResultTest + { + [Fact] + public void GetApiConvention_ReturnsNull_IfNoConventionMatches() + { + // Arrange + var method = typeof(GetApiConvention_ReturnsNull_IfNoConventionMatchesController).GetMethod(nameof(GetApiConvention_ReturnsNull_IfNoConventionMatchesController.NoMatch)); + var attribute = new ApiConventionTypeAttribute(typeof(DefaultApiConventions)); + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, new[] { attribute }, out var conventionResult); + + // Assert + Assert.False(result); + Assert.Null(conventionResult); + } + + public class GetApiConvention_ReturnsNull_IfNoConventionMatchesController + { + public IActionResult NoMatch(int id) => null; + } + + [Fact] + public void GetApiConvention_ReturnsResultFromConvention() + { + // Arrange + var method = typeof(GetApiConvention_ReturnsResultFromConventionController) + .GetMethod(nameof(GetApiConvention_ReturnsResultFromConventionController.Match)); + var attribute = new ApiConventionTypeAttribute(typeof(GetApiConvention_ReturnsResultFromConventionType)); + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, new[] { attribute }, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.Equal(201, r.StatusCode), + r => Assert.Equal(403, r.StatusCode)); + } + + public class GetApiConvention_ReturnsResultFromConventionController + { + public IActionResult Match(int id) => null; + } + + public static class GetApiConvention_ReturnsResultFromConventionType + { + [ProducesResponseType(200)] + [ProducesResponseType(202)] + [ProducesResponseType(404)] + public static void Get(int id) { } + + [ProducesResponseType(201)] + [ProducesResponseType(403)] + public static void Match(int id) { } + } + + [Fact] + public void GetApiConvention_ReturnsResultFromFirstMatchingConvention() + { + // Arrange + var method = typeof(GetApiConvention_ReturnsResultFromFirstMatchingConventionController) + .GetMethod(nameof(GetApiConvention_ReturnsResultFromFirstMatchingConventionController.Get)); + var attributes = new[] + { + new ApiConventionTypeAttribute(typeof(GetApiConvention_ReturnsResultFromConventionType)), + new ApiConventionTypeAttribute(typeof(DefaultApiConventions)), + }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, attributes, result: out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.Equal(200, r.StatusCode), + r => Assert.Equal(202, r.StatusCode), + r => Assert.Equal(404, r.StatusCode)); + } + + public class GetApiConvention_ReturnsResultFromFirstMatchingConventionController + { + public IActionResult Get(int id) => null; + } + + [Fact] + public void GetApiConvention_GetAction_MatchesDefaultConvention() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.GetUser)); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, attributes, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), + r => Assert.Equal(200, r.StatusCode), + r => Assert.Equal(404, r.StatusCode)); + } + + [Fact] + public void GetApiConvention_PostAction_MatchesDefaultConvention() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.PostUser)); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, attributes, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), + r => Assert.Equal(201, r.StatusCode), + r => Assert.Equal(400, r.StatusCode)); + } + + [Fact] + public void GetApiConvention_PutAction_MatchesDefaultConvention() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.PutUser)); + var conventions = new[] + { + new ApiConventionTypeAttribute(typeof(DefaultApiConventions)), + }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, conventions, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), + r => Assert.Equal(204, r.StatusCode), + r => Assert.Equal(400, r.StatusCode), + r => Assert.Equal(404, r.StatusCode)); + } + + [Fact] + public void GetApiConvention_DeleteAction_MatchesDefaultConvention() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.Delete)); + var conventions = new[] + { + new ApiConventionTypeAttribute(typeof(DefaultApiConventions)), + }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, conventions, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), + r => Assert.Equal(200, r.StatusCode), + r => Assert.Equal(400, r.StatusCode), + r => Assert.Equal(404, r.StatusCode)); + } + + [Fact] + public void GetApiConvention_UsesApiConventionMethod() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.EditUser)); + var conventions = new[] + { + new ApiConventionTypeAttribute(typeof(DefaultApiConventions)), + }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, conventions, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), + r => Assert.Equal(201, r.StatusCode), + r => Assert.Equal(400, r.StatusCode)); + } + + public class DefaultConventionController + { + public IActionResult GetUser(Guid id) => null; + + public IActionResult PostUser(User user) => null; + + public IActionResult PutUser(Guid userId, User user) => null; + + public IActionResult Delete(Guid userId) => null; + + [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Post))] + public IActionResult EditUser(int id, User user) => null; + } + + public class User { } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/ActionModelTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ActionModelTest.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/ActionModelTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ActionModelTest.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs new file mode 100644 index 0000000000..12c4984cf5 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs @@ -0,0 +1,191 @@ +// 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 System.Reflection; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + public class ApiBehaviorApplicationModelProviderTest + { + [Fact] + public void OnProvidersExecuting_ThrowsIfControllerWithAttribute_HasActionsWithoutAttributeRouting() + { + // Arrange + var actionName = $"{typeof(TestApiController).FullName}.{nameof(TestApiController.TestAction)} ({typeof(TestApiController).Assembly.GetName().Name})"; + var expected = $"Action '{actionName}' does not have an attribute route. Action methods on controllers annotated with ApiControllerAttribute must be attribute routed."; + + var controllerModel = new ControllerModel(typeof(TestApiController).GetTypeInfo(), new[] { new ApiControllerAttribute() }); + var method = typeof(TestApiController).GetMethod(nameof(TestApiController.TestAction)); + var actionModel = new ActionModel(method, Array.Empty()) + { + Controller = controllerModel, + }; + controllerModel.Actions.Add(actionModel); + + var context = new ApplicationModelProviderContext(new[] { controllerModel.ControllerType }); + context.Result.Controllers.Add(controllerModel); + + var provider = GetProvider(); + + // Act & Assert + var ex = Assert.Throws(() => provider.OnProvidersExecuting(context)); + Assert.Equal(expected, ex.Message); + } + + [Fact] + public void OnProvidersExecuting_AppliesConventions() + { + // Arrange + var controllerModel = new ControllerModel(typeof(TestApiController).GetTypeInfo(), new[] { new ApiControllerAttribute() }) + { + Selectors = { new SelectorModel { AttributeRouteModel = new AttributeRouteModel() } }, + }; + + var method = typeof(TestApiController).GetMethod(nameof(TestApiController.TestAction)); + + var actionModel = new ActionModel(method, Array.Empty()) + { + Controller = controllerModel, + }; + controllerModel.Actions.Add(actionModel); + + var parameter = method.GetParameters()[0]; + var parameterModel = new ParameterModel(parameter, Array.Empty()) + { + Action = actionModel, + }; + actionModel.Parameters.Add(parameterModel); + + var context = new ApplicationModelProviderContext(new[] { controllerModel.ControllerType }); + context.Result.Controllers.Add(controllerModel); + + var provider = GetProvider(); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + // Verify some of the side-effects of executing API behavior conventions. + Assert.True(actionModel.ApiExplorer.IsVisible); + Assert.NotEmpty(actionModel.Filters.OfType()); + Assert.NotEmpty(actionModel.Filters.OfType()); + Assert.Equal(BindingSource.Body, parameterModel.BindingInfo.BindingSource); + } + + [Fact] + public void Constructor_SetsUpConventions() + { + // Arrange + var provider = GetProvider(); + + // Act & Assert + Assert.Collection( + provider.ActionModelConventions, + c => Assert.IsType(c), + c => Assert.IsType(c), + c => Assert.IsType(c), + c => Assert.IsType(c), + c => + { + var convention = Assert.IsType(c); + Assert.Equal(typeof(ProblemDetails), convention.DefaultErrorResponseType.Type); + }, + c => Assert.IsType(c)); + } + + [Fact] + public void Constructor_DoesNotAddClientErrorResultFilterConvention_IfSuppressMapClientErrorsIsSet() + { + // Arrange + var provider = GetProvider(new ApiBehaviorOptions { SuppressMapClientErrors = true }); + + // Act & Assert + Assert.Empty(provider.ActionModelConventions.OfType()); + } + + [Fact] + public void Constructor_DoesNotAddInvalidModelStateFilterConvention_IfSuppressModelStateInvalidFilterIsSet() + { + // Arrange + var provider = GetProvider(new ApiBehaviorOptions { SuppressModelStateInvalidFilter = true }); + + // Act & Assert + Assert.Empty(provider.ActionModelConventions.OfType()); + } + + [Fact] + public void Constructor_DoesNotAddConsumesConstraintForFormFileParameterConvention_IfSuppressConsumesConstraintForFormFileParametersIsSet() + { + // Arrange + var provider = GetProvider(new ApiBehaviorOptions { SuppressConsumesConstraintForFormFileParameters = true }); + + // Act & Assert + Assert.Empty(provider.ActionModelConventions.OfType()); + } + + [Fact] + public void Constructor_DoesNotAddInferParameterBindingInfoConvention_IfSuppressInferBindingSourcesForParametersIsSet() + { + // Arrange + var provider = GetProvider(new ApiBehaviorOptions { SuppressInferBindingSourcesForParameters = true }); + + // Act & Assert + Assert.Empty(provider.ActionModelConventions.OfType()); + } + + [Fact] + public void Constructor_DoesNotSpecifyDefaultErrorType_IfSuppressMapClientErrorsIsSet() + { + // Arrange + var provider = GetProvider(new ApiBehaviorOptions { SuppressMapClientErrors = true }); + + // Act & Assert + var convention = Assert.Single(provider.ActionModelConventions.OfType()); + Assert.Equal(typeof(void), convention.DefaultErrorResponseType.Type); + } + + private static ApiBehaviorApplicationModelProvider GetProvider( + ApiBehaviorOptions options = null) + { + options = options ?? new ApiBehaviorOptions + { + InvalidModelStateResponseFactory = _ => null, + }; + var optionsAccessor = Options.Create(options); + + var loggerFactory = NullLoggerFactory.Instance; + return new ApiBehaviorApplicationModelProvider( + optionsAccessor, + new EmptyModelMetadataProvider(), + Mock.Of(), + loggerFactory); + } + + private static ApplicationModelProviderContext GetContext( + Type type, + IModelMetadataProvider modelMetadataProvider = null) + { + var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() }); + var mvcOptions = Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = true }); + modelMetadataProvider = modelMetadataProvider ?? new EmptyModelMetadataProvider(); + var provider = new DefaultApplicationModelProvider(mvcOptions, modelMetadataProvider); + provider.OnProvidersExecuting(context); + + return context; + } + + private class TestApiController : ControllerBase + { + public IActionResult TestAction(object value) => null; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiConventionApplicationModelConventionTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiConventionApplicationModelConventionTest.cs new file mode 100644 index 0000000000..d658c7dc73 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiConventionApplicationModelConventionTest.cs @@ -0,0 +1,189 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Authorization; +using Xunit; + +[assembly: ProducesErrorResponseType(typeof(InvalidEnumArgumentException))] + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + public class ApiConventionApplicationModelConventionTest + { + [Fact] + public void Apply_DoesNotAddConventionItem_IfNoConventionMatches() + { + // Arrange + var actionModel = GetActionModel(nameof(TestController.NoMatch)); + var convention = GetConvention(); + + // Act + convention.Apply(actionModel); + + // Assert + Assert.DoesNotContain(typeof(ApiConventionResult), actionModel.Properties.Keys); + } + + [Fact] + public void Apply_AddsConventionItem_IfConventionMatches() + { + // Arrange + var actionModel = GetActionModel(nameof(TestController.Delete)); + var convention = GetConvention(); + + // Act + convention.Apply(actionModel); + + // Assert + var value = actionModel.Properties[typeof(ApiConventionResult)]; + Assert.NotNull(value); + } + + [Fact] + public void Apply_AddsConventionItem_IfActionHasNonConventionBasedFilters() + { + // Arrange + var actionModel = GetActionModel(nameof(TestController.Delete)); + actionModel.Filters.Add(new AuthorizeFilter()); + var convention = GetConvention(); + + // Act + convention.Apply(actionModel); + + // Assert + var value = actionModel.Properties[typeof(ApiConventionResult)]; + Assert.NotNull(value); + } + + [Fact] + public void Apply_UsesDefaultErrorType_IfActionHasNoAttributes() + { + // Arrange + var expected = typeof(InvalidFilterCriteriaException); + var controller = new ControllerModel(typeof(object).GetTypeInfo(), Array.Empty()); + var action = new ActionModel(typeof(object).GetMethods()[0], Array.Empty()) + { + Controller = controller, + }; + var convention = GetConvention(expected); + + // Act + convention.Apply(action); + + // Assert + var attribute = GetProperty(action); + Assert.Equal(expected, attribute.Type); + } + + [Fact] + public void Apply_UsesValueFromProducesErrorResponseTypeAttribute_SpecifiedOnControllerAsssembly() + { + // Arrange + var expected = typeof(InvalidEnumArgumentException); + var action = GetActionModel(nameof(TestController.Delete)); + var convention = GetConvention(); + + // Act + convention.Apply(action); + + // Assert + var attribute = GetProperty(action); + Assert.Equal(expected, attribute.Type); + } + + [Fact] + public void Apply_UsesValueFromProducesErrorResponseTypeAttribute_SpecifiedOnController() + { + // Arrange + var expected = typeof(InvalidTimeZoneException); + var action = GetActionModel( + nameof(TestController.Delete), + controllerAttributes: new[] { new ProducesErrorResponseTypeAttribute(expected) }); + var convention = GetConvention(); + + // Act + convention.Apply(action); + + // Assert + var attribute = GetProperty(action); + Assert.Equal(expected, attribute.Type); + } + + [Fact] + public void Apply_UsesValueFromProducesErrorResponseTypeAttribute_SpecifiedOnAction() + { + // Arrange + var expected = typeof(InvalidTimeZoneException); + var action = GetActionModel( + nameof(TestController.Delete), + actionAttributes: new[] { new ProducesErrorResponseTypeAttribute(expected) }, + controllerAttributes: new[] { new ProducesErrorResponseTypeAttribute(typeof(Guid)) }); + var convention = GetConvention(); + + // Act + convention.Apply(action); + + // Assert + var attribute = GetProperty(action); + Assert.Equal(expected, attribute.Type); + } + + [Fact] + public void Apply_AllowsVoidsErrorType() + { + // Arrange + var expected = typeof(void); + var action = GetActionModel(nameof(TestController.Delete), new[] { new ProducesErrorResponseTypeAttribute(expected) }); + var convention = GetConvention(); + + // Act + convention.Apply(action); + + // Assert + var attribute = GetProperty(action); + Assert.Equal(expected, attribute.Type); + } + + private ApiConventionApplicationModelConvention GetConvention(Type errorType = null) + { + errorType = errorType ?? typeof(ProblemDetails); + return new ApiConventionApplicationModelConvention(new ProducesErrorResponseTypeAttribute(errorType)); + } + + private static TValue GetProperty(ActionModel action) + { + return Assert.IsType(action.Properties[typeof(TValue)]); + } + + private static ActionModel GetActionModel( + string actionName, + object[] actionAttributes = null, + object[] controllerAttributes = null) + { + actionAttributes = actionAttributes ?? Array.Empty(); + controllerAttributes = controllerAttributes ?? new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; + + var controllerModel = new ControllerModel(typeof(TestController).GetTypeInfo(), controllerAttributes); + var actionModel = new ActionModel(typeof(TestController).GetMethod(actionName), actionAttributes) + { + Controller = controllerModel, + }; + + controllerModel.Actions.Add(actionModel); + + return actionModel; + } + + private class TestController + { + public IActionResult NoMatch() => null; + + public IActionResult Delete(int id) => null; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiVisibilityConventionTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiVisibilityConventionTest.cs new file mode 100644 index 0000000000..3928909e04 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ApiVisibilityConventionTest.cs @@ -0,0 +1,63 @@ +// 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; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + public class ApiVisibilityConventionTest + { + [Fact] + public void Apply_SetsApiExplorerVisibility() + { + // Arrange + var action = GetActionModel(); + var convention = new ApiVisibilityConvention(); + + // Act + convention.Apply(action); + + // Assert + Assert.True(action.ApiExplorer.IsVisible); + } + + [Fact] + public void Apply_DoesNotSetApiExplorerVisibility_IfAlreadySpecifiedOnAction() + { + // Arrange + var action = GetActionModel(); + action.ApiExplorer.IsVisible = false; + var convention = new ApiVisibilityConvention(); + + // Act + convention.Apply(action); + + // Assert + Assert.False(action.ApiExplorer.IsVisible); + } + + [Fact] + public void Apply_DoesNotSetApiExplorerVisibility_IfAlreadySpecifiedOnController() + { + // Arrange + var action = GetActionModel(); + action.Controller.ApiExplorer.IsVisible = false; + var convention = new ApiVisibilityConvention(); + + // Act + convention.Apply(action); + + // Assert + Assert.Null(action.ApiExplorer.IsVisible); + } + + private static ActionModel GetActionModel() + { + return new ActionModel(typeof(object).GetMethods()[0], new object[0]) + { + Controller = new ControllerModel(typeof(object).GetTypeInfo(), new object[0]), + }; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/AttributeRouteModelTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/AttributeRouteModelTests.cs similarity index 99% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/AttributeRouteModelTests.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/AttributeRouteModelTests.cs index c7a578850b..dc312a50bd 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/AttributeRouteModelTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/AttributeRouteModelTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using Microsoft.AspNetCore.Routing; using Xunit; namespace Microsoft.AspNetCore.Mvc.ApplicationModels diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ClientErrorResultFilterConventionTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ClientErrorResultFilterConventionTest.cs new file mode 100644 index 0000000000..4fc09baf2b --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ClientErrorResultFilterConventionTest.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 System.Linq; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + public class ClientErrorResultFilterConventionTest + { + [Fact] + public void Apply_AddsFilter() + { + // Arrange + var action = GetActionModel(); + var convention = GetConvention(); + + // Act + convention.Apply(action); + + // Assert + Assert.Single(action.Filters.OfType()); + } + + private ClientErrorResultFilterConvention GetConvention() + { + return new ClientErrorResultFilterConvention(); + } + + private static ActionModel GetActionModel() + { + var action = new ActionModel(typeof(object).GetMethods()[0], new object[0]); + + return action; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ConsumesConstraintForFormFileParameterConventionTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ConsumesConstraintForFormFileParameterConventionTest.cs new file mode 100644 index 0000000000..9f7d07402a --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ConsumesConstraintForFormFileParameterConventionTest.cs @@ -0,0 +1,104 @@ +// 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.Reflection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + public class ConsumesConstraintForFormFileParameterConventionTest + { + [Fact] + public void AddMultipartFormDataConsumesAttribute_NoOpsIfConsumesConstraintIsAlreadyPresent() + { + // Arrange + var actionName = nameof(TestController.ActionWithConsumesAttribute); + var action = GetActionModel(typeof(TestController), actionName); + var convention = GetConvention(); + + // Act + convention.AddMultipartFormDataConsumesAttribute(action); + + // Assert + var attribute = Assert.Single(action.Filters); + var consumesAttribute = Assert.IsType(attribute); + Assert.Equal("application/json", Assert.Single(consumesAttribute.ContentTypes)); + } + + [Fact] + public void AddMultipartFormDataConsumesAttribute_AddsConsumesAttribute_WhenActionHasFromFormFileParameter() + { + // Arrange + var actionName = nameof(TestController.FormFileParameter); + var action = GetActionModel(typeof(TestController), actionName); + action.Parameters[0].BindingInfo = new BindingInfo + { + BindingSource = BindingSource.FormFile, + }; + var convention = GetConvention(); + + // Act + convention.AddMultipartFormDataConsumesAttribute(action); + + // Assert + var attribute = Assert.Single(action.Filters); + var consumesAttribute = Assert.IsType(attribute); + Assert.Equal("multipart/form-data", Assert.Single(consumesAttribute.ContentTypes)); + } + + private ConsumesConstraintForFormFileParameterConvention GetConvention() + { + return new ConsumesConstraintForFormFileParameterConvention(); + } + + private static ApplicationModelProviderContext GetContext( + Type type, + IModelMetadataProvider modelMetadataProvider = null) + { + var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() }); + var mvcOptions = Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = true }); + modelMetadataProvider = modelMetadataProvider ?? new EmptyModelMetadataProvider(); + var convention = new DefaultApplicationModelProvider(mvcOptions, modelMetadataProvider); + convention.OnProvidersExecuting(context); + + return context; + } + + private static ControllerModel GetControllerModel(Type controllerType) + { + var context = GetContext(controllerType); + return Assert.Single(context.Result.Controllers); + } + + private static ActionModel GetActionModel(Type controllerType, string actionName) + { + var context = GetContext(controllerType); + var controller = Assert.Single(context.Result.Controllers); + return Assert.Single(controller.Actions, m => m.ActionName == actionName); + } + + private class TestController + { + [HttpPost("form-file")] + public IActionResult FormFileParameter(IFormFile formFile) => null; + + [HttpPost("form-file-collection")] + public IActionResult FormFileCollectionParameter(IFormFileCollection formFiles) => null; + + [HttpPost("form-file-sequence")] + public IActionResult FormFileSequenceParameter(IFormFile[] formFiles) => null; + + [HttpPost] + public IActionResult FromFormParameter([FromForm] string parameter) => null; + + [HttpPost] + [Consumes("application/json")] + public IActionResult ActionWithConsumesAttribute([FromForm] string parameter) => null; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/ControllerModelTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ControllerModelTest.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/ControllerModelTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ControllerModelTest.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InferParameterBindingInfoConventionTest.cs similarity index 59% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InferParameterBindingInfoConventionTest.cs index 5b4b210d1a..8b82f3d6ad 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InferParameterBindingInfoConventionTest.cs @@ -9,471 +9,136 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc.ApplicationModels { - public class ApiBehaviorApplicationModelProviderTest + public class InferParameterBindingInfoConventionTest { [Fact] - public void OnProvidersExecuting_AddsModelStateInvalidFilter_IfTypeIsAnnotatedWithAttribute() - { - // Arrange - var context = GetContext(typeof(TestApiController)); - var provider = GetProvider(); - - // Act - provider.OnProvidersExecuting(context); - - // Assert - var actionModel = Assert.Single(Assert.Single(context.Result.Controllers).Actions); - Assert.IsType(actionModel.Filters.Last()); - } - - [Fact] - public void OnProvidersExecuting_DoesNotAddModelStateInvalidFilterToController_IfFeatureIsDisabledViaOptions() - { - // Arrange - var context = GetContext(typeof(TestApiController)); - var options = new ApiBehaviorOptions - { - SuppressModelStateInvalidFilter = true, - }; - - var provider = GetProvider(options); - - // Act - provider.OnProvidersExecuting(context); - - // Assert - var controllerModel = Assert.Single(context.Result.Controllers); - Assert.DoesNotContain(typeof(ModelStateInvalidFilter), controllerModel.Filters.Select(f => f.GetType())); - } - - [Fact] - public void OnProvidersExecuting_AddsModelStateInvalidFilter_IfActionIsAnnotatedWithAttribute() - { - // Arrange - var context = GetContext(typeof(SimpleController)); - var provider = GetProvider(); - - // Act - provider.OnProvidersExecuting(context); - - // Assert - Assert.Collection( - Assert.Single(context.Result.Controllers).Actions.OrderBy(a => a.ActionName), - action => - { - Assert.Contains(typeof(ModelStateInvalidFilter), action.Filters.Select(f => f.GetType())); - }, - action => - { - Assert.DoesNotContain(typeof(ModelStateInvalidFilter), action.Filters.Select(f => f.GetType())); - }); - } - - [Fact] - public void OnProvidersExecuting_SkipsAddingFilterToActionIfFeatureIsDisabledUsingOptions() - { - // Arrange - var context = GetContext(typeof(SimpleController)); - var options = new ApiBehaviorOptions - { - SuppressModelStateInvalidFilter = true, - }; - - var provider = GetProvider(options); - - // Act - provider.OnProvidersExecuting(context); - - // Assert - Assert.Collection( - Assert.Single(context.Result.Controllers).Actions.OrderBy(a => a.ActionName), - action => - { - Assert.DoesNotContain(typeof(ModelStateInvalidFilter), action.Filters.Select(f => f.GetType())); - }, - action => - { - Assert.DoesNotContain(typeof(ModelStateInvalidFilter), action.Filters.Select(f => f.GetType())); - }); - } - - [Fact] - public void OnProvidersExecuting_MakesControllerVisibleInApiExplorer_IfItIsAnnotatedWithAttribute() - { - // Arrange - var context = GetContext(typeof(TestApiController)); - var options = new ApiBehaviorOptions - { - SuppressModelStateInvalidFilter = true, - }; - - var provider = GetProvider(options); - - // Act - provider.OnProvidersExecuting(context); - - // Assert - var controller = Assert.Single(context.Result.Controllers); - Assert.True(controller.ApiExplorer.IsVisible); - } - - [Fact] - public void OnProvidersExecuting_DoesNotModifyVisibilityInApiExplorer_IfValueIsAlreadySet() - { - // Arrange - var context = GetContext(typeof(TestApiController)); - context.Result.Controllers[0].ApiExplorer.IsVisible = false; - var options = new ApiBehaviorOptions - { - SuppressModelStateInvalidFilter = true, - }; - - var provider = GetProvider(options); - - // Act - provider.OnProvidersExecuting(context); - - // Assert - var controller = Assert.Single(context.Result.Controllers); - Assert.False(controller.ApiExplorer.IsVisible); - } - - [Fact] - public void OnProvidersExecuting_ThrowsIfControllerWithAttribute_HasActionsWithoutAttributeRouting() - { - // Arrange - var actionName = $"{typeof(ActionsWithoutAttributeRouting).FullName}.{nameof(ActionsWithoutAttributeRouting.Index)} ({typeof(ActionsWithoutAttributeRouting).Assembly.GetName().Name})"; - var expected = $"Action '{actionName}' does not have an attribute route. Action methods on controllers annotated with ApiControllerAttribute must be attribute routed."; - var context = GetContext(typeof(ActionsWithoutAttributeRouting)); - var provider = GetProvider(); - - // Act & Assert - var ex = Assert.Throws(() => provider.OnProvidersExecuting(context)); - Assert.Equal(expected, ex.Message); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterNameExistsInRouteAsSimpleToken() - { - // Arrange - var actionName = nameof(ParameterBindingController.SimpleRouteToken); - var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterNameExistsInRouteAsOptionalToken() - { - // Arrange - var actionName = nameof(ParameterBindingController.OptionalRouteToken); - var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterNameExistsInRouteAsConstrainedToken() - { - // Arrange - var actionName = nameof(ParameterBindingController.ConstrainedRouteToken); - var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterNameExistsInAbsoluteRoute() - { - // Arrange - var actionName = nameof(ParameterBindingController.AbsoluteRoute); - var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInAnyRoutes_MulitpleRoutes() - { - // Arrange - var actionName = nameof(ParameterBindingController.ParameterInMultipleRoutes); - var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInAnyRoute() - { - // Arrange - var actionName = nameof(ParameterBindingController.ParameterNotInAllRoutes); - var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInControllerRoute() - { - // Arrange - var actionName = nameof(ParameterInController.ActionWithoutRoute); - var parameter = GetParameterModel(typeof(ParameterInController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInControllerRoute_AndActionHasRoute() - { - // Arrange - var actionName = nameof(ParameterInController.ActionWithRoute); - var parameter = GetParameterModel(typeof(ParameterInController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInAllActionRoutes() - { - // Arrange - var actionName = nameof(ParameterInController.MultipleRoute); - var parameter = GetParameterModel(typeof(ParameterInController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_DoesNotReturnPath_IfActionRouteOverridesControllerRoute() - { - // Arrange - var actionName = nameof(ParameterInController.AbsoluteRoute); - var parameter = GetParameterModel(typeof(ParameterInController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Query, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterPresentInNonOverriddenControllerRoute() - { - // Arrange - var actionName = nameof(ParameterInController.MultipleRouteWithOverride); - var parameter = GetParameterModel(typeof(ParameterInController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterExistsInRoute_OnControllersWithoutSelectors() - { - // Arrange - var actionName = nameof(ParameterBindingNoRoutesOnController.SimpleRoute); - var parameter = GetParameterModel(typeof(ParameterBindingNoRoutesOnController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsPath_IfParameterExistsInAllRoutes_OnControllersWithoutSelectors() - { - // Arrange - var actionName = nameof(ParameterBindingNoRoutesOnController.ParameterInMultipleRoutes); - var parameter = GetParameterModel(typeof(ParameterBindingNoRoutesOnController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Path, result); - } - - [Fact] - public void InferBindingSourceForParameter_DoesNotReturnPath_IfNeitherActionNorControllerHasTemplate() - { - // Arrange - var actionName = nameof(ParameterBindingNoRoutesOnController.NoRouteTemplate); - var parameter = GetParameterModel(typeof(ParameterBindingNoRoutesOnController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Query, result); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsBodyForComplexTypes() - { - // Arrange - var actionName = nameof(ParameterBindingController.ComplexTypeModel); - var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Body, result); - } - - [Fact] - public void OnProvidersExecuting_DoesNotInferBindingSourceForParametersWithBindingInfo() + public void Apply_DoesNotInferBindingSourceForParametersWithBindingInfo() { // Arrange var actionName = nameof(ParameterWithBindingInfo.Action); - var provider = GetProvider(); - var context = GetContext(typeof(ParameterWithBindingInfo)); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterWithBindingInfo), actionName); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controllerModel = Assert.Single(context.Result.Controllers); - var actionModel = Assert.Single(controllerModel.Actions, a => a.ActionName == actionName); - var parameterModel = Assert.Single(actionModel.Parameters); + var parameterModel = Assert.Single(action.Parameters); Assert.NotNull(parameterModel.BindingInfo); Assert.Same(BindingSource.Custom, parameterModel.BindingInfo.BindingSource); } [Fact] - public void OnProvidersExecuting_Throws_IfMultipleParametersAreInferredAsBodyBound() + public void InferParameterBindingSources_Throws_IfMultipleParametersAreInferredAsBodyBound() { // Arrange + var actionName = nameof(MultipleFromBodyController.MultipleInferred); var expected = -$@"Action '{typeof(ControllerWithMultipleInferredFromBodyParameters).FullName}.{nameof(ControllerWithMultipleInferredFromBodyParameters.Action)} ({typeof(ControllerWithMultipleInferredFromBodyParameters).Assembly.GetName().Name})' " + +$@"Action '{typeof(MultipleFromBodyController).FullName}.{actionName} ({typeof(MultipleFromBodyController).Assembly.GetName().Name})' " + "has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use 'FromQueryAttribute' to specify bound from query, 'FromRouteAttribute' to specify bound from route, and 'FromBodyAttribute' for parameters to be bound from body:" + Environment.NewLine + "TestModel a" + Environment.NewLine + "Car b"; - var context = GetContext(typeof(ControllerWithMultipleInferredFromBodyParameters)); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(MultipleFromBodyController), actionName); // Act & Assert - var ex = Assert.Throws(() => provider.OnProvidersExecuting(context)); + var ex = Assert.Throws(() => convention.InferParameterBindingSources(action)); Assert.Equal(expected, ex.Message); } [Fact] - public void OnProvidersExecuting_Throws_IfMultipleParametersAreInferredOrSpecifiedAsBodyBound() + public void InferParameterBindingSources_Throws_IfMultipleParametersAreInferredOrSpecifiedAsBodyBound() { // Arrange + var actionName = nameof(MultipleFromBodyController.InferredAndSpecified); var expected = -$@"Action '{typeof(ControllerWithMultipleInferredOrSpecifiedFromBodyParameters).FullName}.{nameof(ControllerWithMultipleInferredOrSpecifiedFromBodyParameters.Action)} ({typeof(ControllerWithMultipleInferredOrSpecifiedFromBodyParameters).Assembly.GetName().Name})' " + +$@"Action '{typeof(MultipleFromBodyController).FullName}.{actionName} ({typeof(MultipleFromBodyController).Assembly.GetName().Name})' " + "has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use 'FromQueryAttribute' to specify bound from query, 'FromRouteAttribute' to specify bound from route, and 'FromBodyAttribute' for parameters to be bound from body:" + Environment.NewLine + "TestModel a" + Environment.NewLine + "int b"; - var context = GetContext(typeof(ControllerWithMultipleInferredOrSpecifiedFromBodyParameters)); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(MultipleFromBodyController), actionName); // Act & Assert - var ex = Assert.Throws(() => provider.OnProvidersExecuting(context)); + var ex = Assert.Throws(() => convention.InferParameterBindingSources(action)); Assert.Equal(expected, ex.Message); } [Fact] - public void OnProvidersExecuting_Throws_IfMultipleParametersAreFromBody() + public void InferParameterBindingSources_Throws_IfMultipleParametersAreFromBody() { // Arrange + var actionName = nameof(MultipleFromBodyController.MultipleSpecified); var expected = -$@"Action '{typeof(ControllerWithMultipleFromBodyParameters).FullName}.{nameof(ControllerWithMultipleFromBodyParameters.Action)} ({typeof(ControllerWithMultipleFromBodyParameters).Assembly.GetName().Name})' " + +$@"Action '{typeof(MultipleFromBodyController).FullName}.{actionName} ({typeof(MultipleFromBodyController).Assembly.GetName().Name})' " + "has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use 'FromQueryAttribute' to specify bound from query, 'FromRouteAttribute' to specify bound from route, and 'FromBodyAttribute' for parameters to be bound from body:" + Environment.NewLine + "decimal a" + Environment.NewLine + "int b"; - var context = GetContext(typeof(ControllerWithMultipleFromBodyParameters)); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(MultipleFromBodyController), actionName); // Act & Assert - var ex = Assert.Throws(() => provider.OnProvidersExecuting(context)); + var ex = Assert.Throws(() => convention.InferParameterBindingSources(action)); Assert.Equal(expected, ex.Message); } [Fact] - public void OnProvidersExecuting_PreservesBindingInfo_WhenInferringFor_ParameterWithModelBinder_AndExplicitName() + public void InferParameterBindingSources_InfersSources() + { + // Arrange + var actionName = nameof(ParameterBindingController.ComplexTypeModelWithCancellationToken); + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var convention = GetConvention(modelMetadataProvider); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); + + // Act + convention.InferParameterBindingSources(action); + + // Assert + Assert.Collection( + action.Parameters, + parameter => + { + Assert.Equal("model", parameter.Name); + + var bindingInfo = parameter.BindingInfo; + Assert.NotNull(bindingInfo); + Assert.Same(BindingSource.Body, bindingInfo.BindingSource); + }, + parameter => + { + Assert.Equal("cancellationToken", parameter.Name); + + var bindingInfo = parameter.BindingInfo; + Assert.NotNull(bindingInfo); + Assert.Equal(BindingSource.Special, bindingInfo.BindingSource); + }); + } + + [Fact] + public void Apply_PreservesBindingInfo_WhenInferringFor_ParameterWithModelBinder_AndExplicitName() { // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ModelBinderOnParameterController.ModelBinderAttributeWithExplicitModelName); - var context = GetContext(typeof(ModelBinderOnParameterController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ModelBinderOnParameterController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -483,20 +148,18 @@ Environment.NewLine + "int b"; } [Fact] - public void OnProvidersExecuting_PreservesBindingInfo_WhenInferringFor_ParameterWithModelBinderType() + public void Apply_PreservesBindingInfo_WhenInferringFor_ParameterWithModelBinderType() { // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ModelBinderOnParameterController.ModelBinderType); - var context = GetContext(typeof(ModelBinderOnParameterController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ModelBinderOnParameterController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -511,15 +174,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ModelBinderOnParameterController.ModelBinderTypeWithExplicitModelName); - var context = GetContext(typeof(ModelBinderOnParameterController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ModelBinderOnParameterController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -528,21 +189,346 @@ Environment.NewLine + "int b"; Assert.Equal("foo", bindingInfo.BinderModelName); } + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterNameExistsInRouteAsSimpleToken() + { + // Arrange + var actionName = nameof(ParameterBindingController.SimpleRouteToken); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterNameExistsInRouteAsOptionalToken() + { + // Arrange + var actionName = nameof(ParameterBindingController.OptionalRouteToken); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterNameExistsInRouteAsConstrainedToken() + { + // Arrange + var actionName = nameof(ParameterBindingController.ConstrainedRouteToken); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsBody_ForComplexTypeParameterThatAppearsInRoute() + { + // Arrange + var actionName = nameof(ParameterBindingController.ComplexTypeInRoute); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Body, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInAnyRoutes_MulitpleRoutes() + { + // Arrange + var actionName = nameof(ParameterBindingController.ParameterInMultipleRoutes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInAnyRoute() + { + // Arrange + var actionName = nameof(ParameterBindingController.ParameterNotInAllRoutes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInControllerRoute() + { + // Arrange + var actionName = nameof(ParameterInController.ActionWithoutRoute); + var parameter = GetParameterModel(typeof(ParameterInController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInControllerRoute_AndActionHasRoute() + { + // Arrange + var actionName = nameof(ParameterInController.ActionWithRoute); + var parameter = GetParameterModel(typeof(ParameterInController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterAppearsInAllActionRoutes() + { + // Arrange + var actionName = nameof(ParameterInController.MultipleRoute); + var parameter = GetParameterModel(typeof(ParameterInController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_DoesNotReturnPath_IfActionRouteOverridesControllerRoute() + { + // Arrange + var actionName = nameof(ParameterInController.AbsoluteRoute); + var parameter = GetParameterModel(typeof(ParameterInController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Query, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterPresentInNonOverriddenControllerRoute() + { + // Arrange + var actionName = nameof(ParameterInController.MultipleRouteWithOverride); + var parameter = GetParameterModel(typeof(ParameterInController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterExistsInRoute_OnControllersWithoutSelectors() + { + // Arrange + var actionName = nameof(ParameterBindingNoRoutesOnController.SimpleRoute); + var parameter = GetParameterModel(typeof(ParameterBindingNoRoutesOnController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsPath_IfParameterExistsInAllRoutes_OnControllersWithoutSelectors() + { + // Arrange + var actionName = nameof(ParameterBindingNoRoutesOnController.ParameterInMultipleRoutes); + var parameter = GetParameterModel(typeof(ParameterBindingNoRoutesOnController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Path, result); + } + + [Fact] + public void InferBindingSourceForParameter_DoesNotReturnPath_IfNeitherActionNorControllerHasTemplate() + { + // Arrange + var actionName = nameof(ParameterBindingNoRoutesOnController.NoRouteTemplate); + var parameter = GetParameterModel(typeof(ParameterBindingNoRoutesOnController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Query, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsBodyForComplexTypes() + { + // Arrange + var actionName = nameof(ParameterBindingController.ComplexTypeModel); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Body, result); + } + + [Fact] + public void InferParameterBindingSources_SetsCorrectBindingSourceForComplexTypesWithCancellationToken() + { + // Arrange + var actionName = nameof(ParameterBindingController.ComplexTypeModelWithCancellationToken); + + // Use the default set of ModelMetadataProviders so we get metadata details for CancellationToken. + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); + var controllerModel = Assert.Single(context.Result.Controllers); + var actionModel = Assert.Single(controllerModel.Actions, m => m.ActionName == actionName); + + var convention = GetConvention(); + + // Act + convention.InferParameterBindingSources(actionModel); + + // Assert + var model = GetParameterModel(actionModel); + Assert.Same(BindingSource.Body, model.BindingInfo.BindingSource); + + var cancellationToken = GetParameterModel(actionModel); + Assert.Same(BindingSource.Special, cancellationToken.BindingInfo.BindingSource); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsQueryForSimpleTypes() + { + // Arrange + var actionName = nameof(ParameterBindingController.SimpleTypeModel); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Query, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsBodyForCollectionOfSimpleTypes() + { + // Arrange + var actionName = nameof(ParameterBindingController.CollectionOfSimpleTypes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Body, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsQueryForCollectionOfSimpleTypes_WhenAllowInferringBindingSourceForCollectionTypesAsFromQueryIsSet() + { + // Arrange + var actionName = nameof(ParameterBindingController.CollectionOfSimpleTypes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + convention.AllowInferringBindingSourceForCollectionTypesAsFromQuery = true; + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Query, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsBodyForCollectionOfComplexTypes() + { + // Arrange + var actionName = nameof(ParameterBindingController.CollectionOfComplexTypes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Body, result); + } + + [Fact] + public void InferBindingSourceForParameter_ReturnsQueryForCollectionOfComplexTypes_WhenAllowInferringBindingSourceForCollectionTypesAsFromQueryIsSet() + { + // Arrange + var actionName = nameof(ParameterBindingController.CollectionOfComplexTypes); + var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); + var convention = GetConvention(); + convention.AllowInferringBindingSourceForCollectionTypesAsFromQuery = true; + + // Act + var result = convention.InferBindingSourceForParameter(parameter); + + // Assert + Assert.Same(BindingSource.Query, result); + } + [Fact] public void PreservesBindingSourceInference_ForFromQueryParameter_WithDefaultName() { // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromQuery); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -557,15 +543,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromQueryWithCustomName); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -580,21 +564,18 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromQueryOnComplexType); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; Assert.NotNull(bindingInfo); Assert.Same(BindingSource.Query, bindingInfo.BindingSource); - Assert.Equal(string.Empty, bindingInfo.BinderModelName); } [Fact] @@ -603,15 +584,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromQueryOnComplexTypeWithCustomName); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -626,15 +605,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromQueryOnCollectionType); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -649,15 +626,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromQueryOnArrayType); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -672,15 +647,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromQueryOnArrayTypeWithCustomName); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -695,15 +668,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromRoute); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -718,15 +689,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromRouteWithCustomName); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -741,21 +710,18 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromRouteOnComplexType); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; Assert.NotNull(bindingInfo); Assert.Same(BindingSource.Path, bindingInfo.BindingSource); - Assert.Equal(string.Empty, bindingInfo.BinderModelName); } [Fact] @@ -764,15 +730,13 @@ Environment.NewLine + "int b"; // Arrange var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.FromRouteOnComplexTypeWithCustomName); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var convention = GetConvention(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -789,15 +753,13 @@ Environment.NewLine + "int b"; var expectedPropertyFilter = CustomRequestPredicateAndPropertyFilterProviderAttribute.PropertyFilterStatic; var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionName = nameof(ParameterBindingController.ParameterWithRequestPredicateProvider); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var provider = GetProvider(); + var action = GetActionModel(typeof(ParameterBindingController), actionName, modelMetadataProvider); + var convention = GetConvention(); // Act - provider.OnProvidersExecuting(context); + convention.Apply(action); // Assert - var controller = Assert.Single(context.Result.Controllers); - var action = Assert.Single(controller.Actions, a => a.ActionName == actionName); var parameter = Assert.Single(action.Parameters); var bindingInfo = parameter.BindingInfo; @@ -808,170 +770,12 @@ Environment.NewLine + "int b"; Assert.Null(bindingInfo.BinderModelName); } - [Fact] - public void InferParameterBindingSources_SetsCorrectBindingSourceForComplexTypesWithCancellationToken() - { - // Arrange - var actionName = nameof(ParameterBindingController.ComplexTypeModelWithCancellationToken); - - // Use the default set of ModelMetadataProviders so we get metadata details for CancellationToken. - var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var context = GetContext(typeof(ParameterBindingController), modelMetadataProvider); - var controllerModel = Assert.Single(context.Result.Controllers); - var actionModel = Assert.Single(controllerModel.Actions, m => m.ActionName == actionName); - - var provider = GetProvider(); - - // Act - provider.InferParameterBindingSources(actionModel); - - // Assert - var model = GetParameterModel(actionModel); - Assert.Same(BindingSource.Body, model.BindingInfo.BindingSource); - - var cancellationToken = GetParameterModel(actionModel); - Assert.Same(BindingSource.Special, cancellationToken.BindingInfo.BindingSource); - } - - [Fact] - public void InferBindingSourceForParameter_ReturnsBodyForSimpleTypes() - { - // Arrange - var actionName = nameof(ParameterBindingController.SimpleTypeModel); - var parameter = GetParameterModel(typeof(ParameterBindingController), actionName); - var provider = GetProvider(); - - // Act - var result = provider.InferBindingSourceForParameter(parameter); - - // Assert - Assert.Same(BindingSource.Query, result); - } - - [Fact] - public void InferBoundPropertyModelPrefixes_SetsModelPrefix_ForComplexTypeFromValueProvider() - { - // Arrange - var controller = GetControllerModel(typeof(ControllerWithBoundProperty)); - - var provider = GetProvider(); - - // Act - provider.InferBoundPropertyModelPrefixes(controller); - - // Assert - var property = Assert.Single(controller.ControllerProperties); - Assert.Equal(string.Empty, property.BindingInfo.BinderModelName); - } - - [Fact] - public void InferBoundPropertyModelPrefixes_SetsModelPrefix_ForCollectionTypeFromValueProvider() - { - // Arrange - var controller = GetControllerModel(typeof(ControllerWithBoundCollectionProperty)); - - var provider = GetProvider(); - - // Act - provider.InferBoundPropertyModelPrefixes(controller); - - // Assert - var property = Assert.Single(controller.ControllerProperties); - Assert.Null(property.BindingInfo.BinderModelName); - } - - [Fact] - public void InferParameterModelPrefixes_SetsModelPrefix_ForComplexTypeFromValueProvider() - { - // Arrange - var action = GetActionModel(typeof(ControllerWithBoundProperty), nameof(ControllerWithBoundProperty.SomeAction)); - - var provider = GetProvider(); - - // Act - provider.InferParameterModelPrefixes(action); - - // Assert - var parameter = Assert.Single(action.Parameters); - Assert.Equal(string.Empty, parameter.BindingInfo.BinderModelName); - } - - [Fact] - public void AddMultipartFormDataConsumesAttribute_NoOpsIfBehaviorIsDisabled() - { - // Arrange - var actionName = nameof(ParameterBindingController.FromFormParameter); - var action = GetActionModel(typeof(ParameterBindingController), actionName); - var options = new ApiBehaviorOptions - { - SuppressConsumesConstraintForFormFileParameters = true, - InvalidModelStateResponseFactory = _ => null, - }; - var provider = GetProvider(options); - - // Act - provider.AddMultipartFormDataConsumesAttribute(action); - - // Assert - Assert.Empty(action.Filters); - } - - [Fact] - public void AddMultipartFormDataConsumesAttribute_NoOpsIfConsumesConstraintIsAlreadyPresent() - { - // Arrange - var actionName = nameof(ParameterBindingController.ActionWithConsumesAttribute); - var action = GetActionModel(typeof(ParameterBindingController), actionName); - var options = new ApiBehaviorOptions - { - SuppressConsumesConstraintForFormFileParameters = true, - InvalidModelStateResponseFactory = _ => null, - }; - var provider = GetProvider(options); - - // Act - provider.AddMultipartFormDataConsumesAttribute(action); - - // Assert - var attribute = Assert.Single(action.Filters); - var consumesAttribute = Assert.IsType(attribute); - Assert.Equal("application/json", Assert.Single(consumesAttribute.ContentTypes)); - } - - [Fact] - public void AddMultipartFormDataConsumesAttribute_AddsConsumesAttribute_WhenActionHasFromFormFileParameter() - { - // Arrange - var actionName = nameof(ParameterBindingController.FormFileParameter); - var action = GetActionModel(typeof(ParameterBindingController), actionName); - action.Parameters[0].BindingInfo = new BindingInfo - { - BindingSource = BindingSource.FormFile, - }; - var provider = GetProvider(); - - // Act - provider.AddMultipartFormDataConsumesAttribute(action); - - // Assert - var attribute = Assert.Single(action.Filters); - var consumesAttribute = Assert.IsType(attribute); - Assert.Equal("multipart/form-data", Assert.Single(consumesAttribute.ContentTypes)); - } - - private static ApiBehaviorApplicationModelProvider GetProvider( - ApiBehaviorOptions options = null, + private static InferParameterBindingInfoConvention GetConvention( IModelMetadataProvider modelMetadataProvider = null) { - options = options ?? new ApiBehaviorOptions - { - InvalidModelStateResponseFactory = _ => null, - }; - var optionsAccessor = Options.Create(options); - var loggerFactory = NullLoggerFactory.Instance; modelMetadataProvider = modelMetadataProvider ?? new EmptyModelMetadataProvider(); - return new ApiBehaviorApplicationModelProvider(optionsAccessor, modelMetadataProvider, loggerFactory); + return new InferParameterBindingInfoConvention(modelMetadataProvider); } private static ApplicationModelProviderContext GetContext( @@ -981,21 +785,18 @@ Environment.NewLine + "int b"; var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() }); var mvcOptions = Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = true }); modelMetadataProvider = modelMetadataProvider ?? new EmptyModelMetadataProvider(); - var provider = new DefaultApplicationModelProvider(mvcOptions, modelMetadataProvider); - provider.OnProvidersExecuting(context); + var convention = new DefaultApplicationModelProvider(mvcOptions, modelMetadataProvider); + convention.OnProvidersExecuting(context); return context; } - private static ControllerModel GetControllerModel(Type controllerType) + private static ActionModel GetActionModel( + Type controllerType, + string actionName, + IModelMetadataProvider modelMetadataProvider = null) { - var context = GetContext(controllerType); - return Assert.Single(context.Result.Controllers); - } - - private static ActionModel GetActionModel(Type controllerType, string actionName) - { - var context = GetContext(controllerType); + var context = GetContext(controllerType, modelMetadataProvider); var controller = Assert.Single(context.Result.Controllers); return Assert.Single(controller.Actions, m => m.ActionName == actionName); } @@ -1011,34 +812,6 @@ Environment.NewLine + "int b"; return Assert.Single(action.Parameters.Where(x => typeof(T).IsAssignableFrom(x.ParameterType))); } - [ApiController] - [Route("TestApi")] - private class TestApiController : Controller - { - [HttpGet] - public IActionResult TestAction() => null; - } - - private class SimpleController : Controller - { - public IActionResult ActionWithoutFilter() => null; - - [TestApiBehavior] - [HttpGet("/Simple/ActionWithFilter")] - public IActionResult ActionWithFilter() => null; - } - - [ApiController] - private class ActionsWithoutAttributeRouting - { - public IActionResult Index() => null; - } - - [AttributeUsage(AttributeTargets.Method)] - private class TestApiBehavior : Attribute, IApiBehaviorMetadata - { - } - [ApiController] [Route("[controller]/[action]")] private class ParameterBindingController @@ -1053,10 +826,10 @@ Environment.NewLine + "int b"; public IActionResult OptionalRouteToken(int id) => null; [HttpDelete("delete-by-status/{status:int?}")] - public IActionResult ConstrainedRouteToken(object status) => null; + public IActionResult ConstrainedRouteToken(int status) => null; [HttpPut("/absolute-route/{status:int}")] - public IActionResult AbsoluteRoute(object status) => null; + public IActionResult ComplexTypeInRoute(object status) => null; [HttpPost("multiple/{id}")] [HttpPut("multiple/{id}")] @@ -1130,16 +903,14 @@ Environment.NewLine + "int b"; [HttpGet] public IActionResult ParameterWithRequestPredicateProvider([CustomRequestPredicateAndPropertyFilterProvider] int value) => null; - } - private class CustomRequestPredicateAndPropertyFilterProviderAttribute : Attribute, IRequestPredicateProvider, IPropertyFilterProvider - { - public static Func RequestPredicateStatic => (c) => true; - public static Func PropertyFilterStatic => (c) => true; + public IActionResult FromFormFormFileParameters([FromForm] IFormFile p1, [FromForm] IFormFile[] p2, [FromForm] IFormFileCollection p3) => null; - public Func RequestPredicate => RequestPredicateStatic; + public IActionResult FormFileParameters(IFormFile p1, IFormFile[] p2, IFormFileCollection p3) => null; - public Func PropertyFilter => PropertyFilterStatic; + public IActionResult CollectionOfSimpleTypes(IList parameter) => null; + + public IActionResult CollectionOfComplexTypes(IList parameter) => null; } [ApiController] @@ -1204,52 +975,14 @@ Environment.NewLine + "int b"; => sourceType == typeof(string); } - [ApiController] - private class ControllerWithBoundProperty + private class CustomRequestPredicateAndPropertyFilterProviderAttribute : Attribute, IRequestPredicateProvider, IPropertyFilterProvider { - [FromQuery] - public TestModel TestProperty { get; set; } + public static Func RequestPredicateStatic => (c) => true; + public static Func PropertyFilterStatic => (c) => true; - public IActionResult SomeAction([FromQuery] TestModel test) => null; - } + public Func RequestPredicate => RequestPredicateStatic; - [ApiController] - private class ControllerWithBoundCollectionProperty - { - [FromQuery] - public List TestProperty { get; set; } - - public IActionResult SomeAction([FromQuery] List test) => null; - } - - private class Car { } - - [ApiController] - private class ControllerWithMultipleInferredFromBodyParameters - { - [HttpGet("test")] - public IActionResult Action(TestModel a, Car b) => null; - } - - [ApiController] - private class ControllerWithMultipleInferredOrSpecifiedFromBodyParameters - { - [HttpGet("test")] - public IActionResult Action(TestModel a, [FromBody] int b) => null; - } - - [ApiController] - private class ControllerWithMultipleFromBodyParameters - { - [HttpGet("test")] - public IActionResult Action([FromBody] decimal a, [FromBody] int b) => null; - } - - [ApiController] - private class ParameterWithBindingInfo - { - [HttpGet("test")] - public IActionResult Action([ModelBinder(typeof(object))] Car car) => null; + public Func PropertyFilter => PropertyFilterStatic; } private class GpsCoordinates @@ -1265,5 +998,41 @@ Environment.NewLine + "int b"; throw new NotImplementedException(); } } + + private class ControllerWithBoundProperty + { + [FromQuery] + public TestModel TestProperty { get; set; } + + [FromForm] + public IList Files { get; set; } + + public IActionResult SomeAction([FromQuery] TestModel test) => null; + } + + private class ControllerWithBoundCollectionProperty + { + [FromQuery] + public List TestProperty { get; set; } + + public IActionResult SomeAction([FromQuery] List test) => null; + } + + private class Car { } + + private class MultipleFromBodyController + { + public IActionResult MultipleInferred(TestModel a, Car b) => null; + + public IActionResult InferredAndSpecified(TestModel a, [FromBody] int b) => null; + + public IActionResult MultipleSpecified([FromBody] decimal a, [FromBody] int b) => null; + } + + private class ParameterWithBindingInfo + { + [HttpGet("test")] + public IActionResult Action([ModelBinder(typeof(object))] Car car) => null; + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InvalidModelStateFilterConventionTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InvalidModelStateFilterConventionTest.cs new file mode 100644 index 0000000000..c53357d4f1 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/InvalidModelStateFilterConventionTest.cs @@ -0,0 +1,39 @@ +// 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.Infrastructure; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels +{ + public class InvalidModelStateFilterConventionTest + { + [Fact] + public void Apply_AddsFilter() + { + // Arrange + var action = GetActionModel(); + var convention = GetConvention(); + + // Act + convention.Apply(action); + + // Assert + Assert.Single(action.Filters.OfType()); + } + + + private static ActionModel GetActionModel() + { + var action = new ActionModel(typeof(object).GetMethods()[0], new object[0]); + + return action; + } + + private InvalidModelStateFilterConvention GetConvention() + { + return new InvalidModelStateFilterConvention(); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/ParameterModelTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ParameterModelTest.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/ParameterModelTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/ParameterModelTest.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/PropertyModelTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/PropertyModelTest.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModel/PropertyModelTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/PropertyModelTest.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/RouteTokenTransformerConventionTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/RouteTokenTransformerConventionTest.cs new file mode 100644 index 0000000000..52214026ef --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationModels/RouteTokenTransformerConventionTest.cs @@ -0,0 +1,80 @@ +// 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.Reflection; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Routing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Test.ApplicationModels +{ + public class RouteTokenTransformerConventionTest + { + [Fact] + public void Apply_HasAttributeRouteModel_SetRouteTokenTransformer() + { + // Arrange + var transformer = new TestParameterTransformer(); + var convention = new RouteTokenTransformerConvention(transformer); + + var model = new ActionModel(GetMethodInfo(), Array.Empty()); + model.Selectors.Add(new SelectorModel() + { + AttributeRouteModel = new AttributeRouteModel() + }); + + // Act + convention.Apply(model); + + // Assert + Assert.True(model.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out var routeTokenTransformer)); + Assert.Equal(transformer, routeTokenTransformer); + } + + [Fact] + public void Apply_ShouldApplyFalse_NoOp() + { + // Arrange + var transformer = new TestParameterTransformer(); + var convention = new CustomRouteTokenTransformerConvention(transformer); + + var model = new ActionModel(GetMethodInfo(), Array.Empty()); + model.Selectors.Add(new SelectorModel() + { + AttributeRouteModel = new AttributeRouteModel() + }); + + // Act + convention.Apply(model); + + // Assert + Assert.False(model.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out _)); + } + + private MethodInfo GetMethodInfo() + { + return typeof(RouteTokenTransformerConventionTest).GetMethod(nameof(GetMethodInfo), BindingFlags.NonPublic | BindingFlags.Instance); + } + + private class TestParameterTransformer : IOutboundParameterTransformer + { + public string TransformOutbound(object value) + { + return value?.ToString(); + } + } + + private class CustomRouteTokenTransformerConvention : RouteTokenTransformerConvention + { + public CustomRouteTokenTransformerConvention(IOutboundParameterTransformer parameterTransformer) : base(parameterTransformer) + { + } + + protected override bool ShouldApply(ActionModel action) + { + return false; + } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationParts/ApplicationAssembliesProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationParts/ApplicationAssembliesProviderTest.cs index 1904003bdd..c3f864b4d7 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationParts/ApplicationAssembliesProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationParts/ApplicationAssembliesProviderTest.cs @@ -454,49 +454,6 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationParts Assert.Equal(new[] { "ControllersAssembly", "MvcSandbox" }, candidates.Select(a => a.Name)); } - // This test verifies DefaultAssemblyPartDiscoveryProvider.ReferenceAssemblies reflects the actual loadable assemblies - // of the libraries that Microsoft.AspNetCore.Mvc depends on. - // If we add or remove dependencies, this test should be changed together. - [Fact] - public void ReferenceAssemblies_ReturnsLoadableReferenceAssemblies() - { - // Arrange - var excludeAssemblies = new string[] - { - "Microsoft.AspNetCore.Mvc.Core.Test", - "Microsoft.AspNetCore.Mvc.Razor.Extensions.Reference", - "Microsoft.AspNetCore.Mvc.TestCommon", - "Microsoft.AspNetCore.Mvc.TestDiagnosticListener", - "Microsoft.AspNetCore.Mvc.WebApiCompatShim", - }; - - var additionalAssemblies = new[] - { - // The following assemblies are not reachable from Microsoft.AspNetCore.Mvc - "Microsoft.AspNetCore.All", - "Microsoft.AspNetCore.Mvc.Formatters.Xml", - }; - - var dependencyContextLibraries = DependencyContext.Load(ThisAssembly) - .CompileLibraries - .Where(r => r.Name.StartsWith("Microsoft.AspNetCore.Mvc", StringComparison.OrdinalIgnoreCase) && - !excludeAssemblies.Contains(r.Name, StringComparer.OrdinalIgnoreCase)) - .Select(r => r.Name); - - var expected = dependencyContextLibraries - .Concat(additionalAssemblies) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(p => p, StringComparer.OrdinalIgnoreCase); - - // Act - var referenceAssemblies = ApplicationAssembliesProvider - .ReferenceAssemblies - .OrderBy(p => p, StringComparer.OrdinalIgnoreCase); - - // Assert - Assert.Equal(expected, referenceAssemblies, StringComparer.OrdinalIgnoreCase); - } - private class TestApplicationAssembliesProvider : ApplicationAssembliesProvider { public DependencyContext DependencyContext { get; set; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationParts/RelatedAssemblyPartTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationParts/RelatedAssemblyPartTest.cs index efce8dfaad..6819818bf1 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationParts/RelatedAssemblyPartTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ApplicationParts/RelatedAssemblyPartTest.cs @@ -80,7 +80,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationParts { // Arrange var destination = Path.Combine(AssemblyDirectory, "RelatedAssembly.dll"); - var codeBase = "file://x/file/Assembly.dll"; + var codeBase = "file://x:/file/Assembly.dll"; var expected = new Uri(codeBase).LocalPath; var assembly = new TestAssembly { @@ -109,6 +109,54 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationParts Assert.Equal(expected, actual); } + [Fact] + public void GetAssemblyLocation_CodeBase_HasPoundCharacterUnixPath() + { + var destination = Path.Combine(AssemblyDirectory, "RelatedAssembly.dll"); + var expected = @"/etc/#NIN/dotnetcore/tryx/try1.dll"; + var assembly = new TestAssembly + { + CodeBaseSettable = "file:///etc/#NIN/dotnetcore/tryx/try1.dll", + LocationSettable = expected, + }; + + // Act + var actual = RelatedAssemblyAttribute.GetAssemblyLocation(assembly); + Assert.Equal(expected, actual); + } + + [Fact] + public void GetAssemblyLocation_CodeBase_HasPoundCharacterUNCPath() + { + var destination = Path.Combine(AssemblyDirectory, "RelatedAssembly.dll"); + var expected = @"\\server\#NIN\dotnetcore\tryx\try1.dll"; + var assembly = new TestAssembly + { + CodeBaseSettable = "file://server/#NIN/dotnetcore/tryx/try1.dll", + LocationSettable = expected, + }; + + // Act + var actual = RelatedAssemblyAttribute.GetAssemblyLocation(assembly); + Assert.Equal(expected, actual); + } + + [Fact] + public void GetAssemblyLocation_CodeBase_HasPoundCharacterDOSPath() + { + var destination = Path.Combine(AssemblyDirectory, "RelatedAssembly.dll"); + var expected = @"C:\#NIN\dotnetcore\tryx\try1.dll"; + var assembly = new TestAssembly + { + CodeBaseSettable = "file:///C:/#NIN/dotnetcore/tryx/try1.dll", + LocationSettable = expected, + }; + + // Act + var actual = RelatedAssemblyAttribute.GetAssemblyLocation(assembly); + Assert.Equal(expected, actual); + } + private class TestAssembly : Assembly { public override AssemblyName GetName() diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Authorization/AuthorizeFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Authorization/AuthorizeFilterTest.cs index 765fccd0e3..fdde1514de 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Authorization/AuthorizeFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Authorization/AuthorizeFilterTest.cs @@ -221,6 +221,81 @@ namespace Microsoft.AspNetCore.Mvc.Authorization Assert.Null(authorizationContext.Result); } + private class TestPolicyProvider : IAuthorizationPolicyProvider + { + private AuthorizationPolicy _true = new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build(); + private AuthorizationPolicy _false = new AuthorizationPolicyBuilder().RequireAssertion(_ => false).Build(); + + public int GetPolicyCalls = 0; + + public Task GetDefaultPolicyAsync() + => Task.FromResult(_true); + + public Task GetPolicyAsync(string policyName) + { + GetPolicyCalls++; + return Task.FromResult(policyName == "true" ? _true : _false); + } + } + + [Fact] + public async Task AuthorizationFilterCombinesMultipleFiltersWithPolicyProvider() + { + // Arrange + var authorizeFilter = new AuthorizeFilter(new TestPolicyProvider(), new IAuthorizeData[] + { + new AuthorizeAttribute { Policy = "true"}, + new AuthorizeAttribute { Policy = "false"} + }); + var authorizationContext = GetAuthorizationContext(anonymous: false, registerServices: s => s.Configure(o => o.AllowCombiningAuthorizeFilters = true)); + // Effective policy should fail, if both are combined + authorizationContext.Filters.Add(authorizeFilter); + var secondFilter = new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAssertion(a => true).Build()); + authorizationContext.Filters.Add(secondFilter); + + // Act + await secondFilter.OnAuthorizationAsync(authorizationContext); + + // Assert + Assert.IsType(authorizationContext.Result); + } + + [Fact] + public async Task AuthorizationFilterCombinesMultipleFiltersWithDifferentPolicyProvider() + { + // Arrange + var testProvider1 = new TestPolicyProvider(); + var testProvider2 = new TestPolicyProvider(); + var authorizeFilter = new AuthorizeFilter(testProvider1, new IAuthorizeData[] + { + new AuthorizeAttribute { Policy = "true"}, + new AuthorizeAttribute { Policy = "false"} + }); + var authorizationContext = GetAuthorizationContext(anonymous: false, registerServices: s => s.Configure(o => o.AllowCombiningAuthorizeFilters = true)); + // Effective policy should fail, if both are combined + authorizationContext.Filters.Add(authorizeFilter); + var secondFilter = new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAssertion(a => true).Build()); + authorizationContext.Filters.Add(secondFilter); + var thirdFilter = new AuthorizeFilter(testProvider2, new IAuthorizeData[] { new AuthorizeAttribute(policy: "something") }); + authorizationContext.Filters.Add(thirdFilter); + + // Act + await thirdFilter.OnAuthorizationAsync(authorizationContext); + + // Assert + Assert.IsType(authorizationContext.Result); + Assert.Equal(2, testProvider1.GetPolicyCalls); + Assert.Equal(1, testProvider2.GetPolicyCalls); + + // Make sure the policy calls are not cached + await thirdFilter.OnAuthorizationAsync(authorizationContext); + + // Assert + Assert.IsType(authorizationContext.Result); + Assert.Equal(4, testProvider1.GetPolicyCalls); + Assert.Equal(2, testProvider2.GetPolicyCalls); + } + [Fact] public async Task AuthorizationFilterCombinesMultipleFilters() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs index 8470b7ea25..3bd2cfa1f6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs @@ -2,7 +2,14 @@ // 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.Linq; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder.Internal; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Moq; using Xunit; @@ -29,5 +36,61 @@ namespace Microsoft.AspNetCore.Mvc.Core.Builder "in the application startup code.", exception.Message); } + + [Fact] + public void UseMvc_EndpointRoutingDisabled_NoEndpointInfos() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new DiagnosticListener("Microsoft.AspNetCore")); + services.AddLogging(); + services.AddMvcCore(o => o.EnableEndpointRouting = false); + var serviceProvider = services.BuildServiceProvider(); + var appBuilder = new ApplicationBuilder(serviceProvider); + + // Act + appBuilder.UseMvc(routes => + { + routes.MapRoute( + name: "default", + template: "{controller=Home}/{action=Index}/{id?}"); + }); + + var mvcEndpointDataSource = appBuilder.ApplicationServices + .GetRequiredService>() + .OfType() + .First(); + + Assert.Empty(mvcEndpointDataSource.ConventionalEndpointInfos); + } + + [Fact] + public void UseMvc_EndpointRoutingEnabled_NoEndpointInfos() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new DiagnosticListener("Microsoft.AspNetCore")); + services.AddLogging(); + services.AddMvcCore(o => o.EnableEndpointRouting = true); + var serviceProvider = services.BuildServiceProvider(); + var appBuilder = new ApplicationBuilder(serviceProvider); + + // Act + appBuilder.UseMvc(routes => + { + routes.MapRoute( + name: "default", + template: "{controller=Home}/{action=Index}/{id?}"); + }); + + var mvcEndpointDataSource = appBuilder.ApplicationServices + .GetRequiredService>() + .OfType() + .First(); + + var endpointInfo = Assert.Single(mvcEndpointDataSource.ConventionalEndpointInfos); + Assert.Equal("default", endpointInfo.Name); + Assert.Equal("{controller=Home}/{action=Index}/{id?}", endpointInfo.Pattern); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ConsumesAttributeTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ConsumesAttributeTests.cs index a2738ed9a2..1ecf35fb8e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ConsumesAttributeTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ConsumesAttributeTests.cs @@ -80,7 +80,7 @@ namespace Microsoft.AspNetCore.Mvc [InlineData("application/json")] [InlineData("application/json;Parameter1=12")] [InlineData("text/xml")] - public void Accept_MatchesForMachingRequestContentType(string contentType) + public void ActionConstraint_Accept_MatchesForMatchingRequestContentType(string contentType) { // Arrange var constraint = new ConsumesAttribute("application/json", "text/xml"); @@ -104,7 +104,7 @@ namespace Microsoft.AspNetCore.Mvc } [Fact] - public void Accept_TheFirstCandidateReturnsFalse_IfALaterOneMatches() + public void ActionConstraint_Accept_TheFirstCandidateReturnsFalse_IfALaterOneMatches() { // Arrange var constraint1 = new ConsumesAttribute("application/json", "text/xml"); @@ -114,7 +114,7 @@ namespace Microsoft.AspNetCore.Mvc new List() { new FilterDescriptor(constraint1, FilterScope.Action) } }; - var constraint2 = new Mock(); + var constraint2 = new Mock(); var action2 = new ActionDescriptor() { FilterDescriptors = @@ -142,7 +142,7 @@ namespace Microsoft.AspNetCore.Mvc [InlineData("application/custom")] [InlineData("")] [InlineData(null)] - public void Accept_ForNoMatchingCandidates_SelectsTheFirstCandidate(string contentType) + public void ActionConstraint_Accept_ForNoMatchingCandidates_SelectsTheFirstCandidate(string contentType) { // Arrange var constraint1 = new ConsumesAttribute("application/json", "text/xml"); @@ -152,7 +152,7 @@ namespace Microsoft.AspNetCore.Mvc new List() { new FilterDescriptor(constraint1, FilterScope.Action) } }; - var constraint2 = new Mock(); + var constraint2 = new Mock(); var action2 = new ActionDescriptor() { FilterDescriptors = @@ -179,7 +179,7 @@ namespace Microsoft.AspNetCore.Mvc [Theory] [InlineData("")] [InlineData(null)] - public void Accept_ForNoRequestType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) + public void ActionConstraint_Accept_ForNoRequestType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) { // Arrange var constraint1 = new ConsumesAttribute("application/json"); @@ -219,7 +219,7 @@ namespace Microsoft.AspNetCore.Mvc [InlineData("application/xml")] [InlineData("application/custom")] [InlineData("invalid/invalid")] - public void Accept_UnrecognizedMediaType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) + public void ActionConstraint_Accept_UnrecognizedMediaType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) { // Arrange var actionWithoutConstraint = new ActionDescriptor(); @@ -258,7 +258,7 @@ namespace Microsoft.AspNetCore.Mvc [Theory] [InlineData("")] [InlineData(null)] - public void Accept_ForNoRequestType_ReturnsTrueForAllConstraints(string contentType) + public void ActionConstraint_Accept_ForNoRequestType_ReturnsTrueForAllConstraints(string contentType) { // Arrange var constraint1 = new ConsumesAttribute("application/json"); @@ -326,7 +326,7 @@ namespace Microsoft.AspNetCore.Mvc [Theory] [InlineData("")] [InlineData(null)] - public void OnResourceExecuting_NullOrEmptyRequestContentType_IsNoOp(string contentType) + public void OnResourceExecuting_NullOrEmptyRequestContentType_SetsUnsupportedMediaTypeResult(string contentType) { // Arrange var httpContext = new DefaultHttpContext(); @@ -349,7 +349,8 @@ namespace Microsoft.AspNetCore.Mvc consumesFilter.OnResourceExecuting(resourceExecutingContext); // Assert - Assert.Null(resourceExecutingContext.Result); + Assert.NotNull(resourceExecutingContext.Result); + Assert.IsType(resourceExecutingContext.Result); } [Theory] @@ -404,11 +405,7 @@ namespace Microsoft.AspNetCore.Mvc private static RouteContext CreateRouteContext(string contentType = null, object routeValues = null) { - var httpContext = new DefaultHttpContext(); - if (contentType != null) - { - httpContext.Request.ContentType = contentType; - } + var httpContext = CreateHttpContext(contentType); var routeContext = new RouteContext(httpContext); routeContext.RouteData = new RouteData(); @@ -421,7 +418,18 @@ namespace Microsoft.AspNetCore.Mvc return routeContext; } - public interface ITestConsumeConstraint : IConsumesActionConstraint, IResourceFilter + private static HttpContext CreateHttpContext(string contentType = null, object routeValues = null) + { + var httpContext = new DefaultHttpContext(); + if (contentType != null) + { + httpContext.Request.ContentType = contentType; + } + + return httpContext; + } + + public interface ITestActionConsumeConstraint : IConsumesActionConstraint, IResourceFilter { } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ContentResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ContentResultTest.cs index a9e878cc8f..6fd9433aa0 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ContentResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ContentResultTest.cs @@ -9,8 +9,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Internal; -using Microsoft.AspNetCore.Mvc.TestCommon; -using Microsoft.AspNetCore.Mvc.ViewComponents; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -259,7 +257,7 @@ namespace Microsoft.AspNetCore.Mvc new ActionDescriptor()); } - private static IServiceCollection CreateServices(params ViewComponentDescriptor[] descriptors) + private static IServiceCollection CreateServices() { // An array pool could return a buffer which is greater or equal to the size of the default character // chunk size. Since the tests here depend on a specific character buffer size to test boundary conditions, diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ControllerBaseTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ControllerBaseTest.cs index 4ff9dac6ec..659260734a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ControllerBaseTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ControllerBaseTest.cs @@ -15,7 +15,6 @@ using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; @@ -1028,10 +1027,10 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test // Arrange var controller = new TestableController(); var pageName = "/Page-Name"; - var routeVaues = new { key = "value" }; + var routeValues = new { key = "value" }; // Act - var result = controller.RedirectToPage(pageName, routeVaues); + var result = controller.RedirectToPage(pageName, routeValues); // Assert Assert.IsType(result); @@ -2327,7 +2326,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test public async Task TryUpdateModel_FallsBackOnEmptyPrefix_IfNotSpecified() { // Arrange - var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var metadataProvider = new EmptyModelMetadataProvider(); var valueProvider = Mock.Of(); var binder = new StubModelBinder(context => { @@ -2356,7 +2355,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test // Arrange var modelName = "mymodel"; - var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var metadataProvider = new EmptyModelMetadataProvider(); var valueProvider = Mock.Of(); var binder = new StubModelBinder(context => { @@ -2578,7 +2577,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test // Arrange var modelName = "mymodel"; - var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var metadataProvider = new EmptyModelMetadataProvider(); var valueProvider = Mock.Of(); var binder = new StubModelBinder(context => { @@ -2606,7 +2605,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test // Arrange var modelName = "mymodel"; - var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var metadataProvider = new EmptyModelMetadataProvider(); var valueProvider = Mock.Of(); var binder = new StubModelBinder(context => { @@ -2712,7 +2711,8 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test var controller = GetController(binder, valueProvider: null); controller.ObjectValidator = new DefaultObjectValidator( controller.MetadataProvider, - new[] { Mock.Of() }); + new[] { Mock.Of() }, + new MvcOptions()); var model = new TryValidateModelModel(); @@ -2748,7 +2748,8 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test var controller = GetController(binder, valueProvider: null); controller.ObjectValidator = new DefaultObjectValidator( controller.MetadataProvider, - new[] { provider.Object }); + new[] { provider.Object }, + new MvcOptions()); // Act var result = controller.TryValidateModel(model, "Prefix"); @@ -2784,7 +2785,8 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test var controller = GetController(binder, valueProvider: null); controller.ObjectValidator = new DefaultObjectValidator( controller.MetadataProvider, - new[] { provider.Object }); + new[] { provider.Object }, + new MvcOptions()); // Act var result = controller.TryValidateModel(model); @@ -2834,7 +2836,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test private static ControllerBase GetController(IModelBinder binder, IValueProvider valueProvider) { - var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var metadataProvider = new EmptyModelMetadataProvider(); var services = new ServiceCollection(); services.AddSingleton(NullLoggerFactory.Instance); @@ -2868,7 +2870,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test ControllerContext = controllerContext, MetadataProvider = metadataProvider, ModelBinderFactory = binderFactory.Object, - ObjectValidator = new DefaultObjectValidator(metadataProvider, validatorProviders), + ObjectValidator = new DefaultObjectValidator(metadataProvider, validatorProviders, new MvcOptions()), }; return controller; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Controllers/DefaultControllerActivatorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Controllers/DefaultControllerActivatorTest.cs index 90305c7a08..2e72325feb 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Controllers/DefaultControllerActivatorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Controllers/DefaultControllerActivatorTest.cs @@ -135,7 +135,7 @@ namespace Microsoft.AspNetCore.Mvc.Controllers .Returns(metadataProvider); services .Setup(s => s.GetService(typeof(IObjectModelValidator))) - .Returns(new DefaultObjectValidator(metadataProvider, new List())); + .Returns(new DefaultObjectValidator(metadataProvider, new List(), new MvcOptions())); return services.Object; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Controllers/DefaultControllerFactoryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Controllers/DefaultControllerFactoryTest.cs index 436693bbb9..3a705e334e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Controllers/DefaultControllerFactoryTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Controllers/DefaultControllerFactoryTest.cs @@ -215,7 +215,8 @@ namespace Microsoft.AspNetCore.Mvc.Controllers .Setup(s => s.GetService(typeof(IObjectModelValidator))) .Returns(new DefaultObjectValidator( metadataProvider, - TestModelValidatorProvider.CreateDefaultProvider().ValidatorProviders)); + TestModelValidatorProvider.CreateDefaultProvider().ValidatorProviders, + new MvcOptions())); return services.Object; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcBuilderExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcBuilderExtensionsTest.cs index 1aa3240f6f..9ef4358c3f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcBuilderExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcBuilderExtensionsTest.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.MvcServiceCollectionExtensionsTestControllers; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -110,6 +111,33 @@ namespace Microsoft.AspNetCore.Mvc Assert.Single(collection, d => d.ServiceType.Equals(typeof(ControllerTwo))); } + [Fact] + public void ConfigureApiBehaviorOptions_InvokesSetupAction() + { + // Arrange + var serviceCollection = new ServiceCollection() + .AddOptions(); + + var builder = new MvcBuilder( + serviceCollection, + new ApplicationPartManager()); + + var part = new TestApplicationPart(); + + // Act + var result = builder.ConfigureApiBehaviorOptions(o => + { + o.SuppressMapClientErrors = true; + }); + + // Assert + var options = serviceCollection. + BuildServiceProvider() + .GetRequiredService>() + .Value; + Assert.True(options.SuppressMapClientErrors); + } + private class ControllerOne { } @@ -136,7 +164,7 @@ namespace Microsoft.AspNetCore.Mvc // independent. namespace Microsoft.AspNetCore.Mvc.MvcServiceCollectionExtensionsTestControllers { - public class ControllerTypeA : Microsoft.AspNetCore.Mvc.Controller + public class ControllerTypeA : ControllerBase { } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreBuilderExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreBuilderExtensionsTest.cs index 4fc6d41572..2311cb3987 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreBuilderExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreBuilderExtensionsTest.cs @@ -6,6 +6,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -51,5 +52,32 @@ namespace Microsoft.AspNetCore.Mvc.DependencyInjection Assert.Same(result, builder); Assert.Equal(new ApplicationPart[] { part }, builder.PartManager.ApplicationParts.ToArray()); } + + [Fact] + public void ConfigureApiBehaviorOptions_InvokesSetupAction() + { + // Arrange + var serviceCollection = new ServiceCollection() + .AddOptions(); + + var builder = new MvcCoreBuilder( + serviceCollection, + new ApplicationPartManager()); + + var part = new TestApplicationPart(); + + // Act + var result = builder.ConfigureApiBehaviorOptions(o => + { + o.SuppressMapClientErrors = true; + }); + + // Assert + var options = serviceCollection. + BuildServiceProvider() + .GetRequiredService>() + .Value; + Assert.True(options.SuppressMapClientErrors); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs index 237eb47046..e4e56bebd2 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -38,7 +39,7 @@ namespace Microsoft.AspNetCore.Mvc var services = new ServiceCollection(); // Register a mock implementation of each service, AddMvcServices should add another implementation. - foreach (var serviceType in MutliRegistrationServiceTypes) + foreach (var serviceType in MultiRegistrationServiceTypes) { var mockType = typeof(Mock<>).MakeGenericType(serviceType.Key); services.Add(ServiceDescriptor.Transient(serviceType.Key, mockType)); @@ -48,7 +49,7 @@ namespace Microsoft.AspNetCore.Mvc MvcCoreServiceCollectionExtensions.AddMvcCoreServices(services); // Assert - foreach (var serviceType in MutliRegistrationServiceTypes) + foreach (var serviceType in MultiRegistrationServiceTypes) { AssertServiceCountEquals(services, serviceType.Key, serviceType.Value.Length + 1); @@ -222,14 +223,14 @@ namespace Microsoft.AspNetCore.Mvc var services = new ServiceCollection(); MvcCoreServiceCollectionExtensions.AddMvcCoreServices(services); - var multiRegistrationServiceTypes = MutliRegistrationServiceTypes; + var multiRegistrationServiceTypes = MultiRegistrationServiceTypes; return services .Where(sd => !multiRegistrationServiceTypes.Keys.Contains(sd.ServiceType)) .Select(sd => sd.ServiceType); } } - private Dictionary MutliRegistrationServiceTypes + private Dictionary MultiRegistrationServiceTypes { get { @@ -247,6 +248,7 @@ namespace Microsoft.AspNetCore.Mvc new Type[] { typeof(MvcOptionsConfigureCompatibilityOptions), + typeof(MvcCoreMvcOptionsSetup), } }, { @@ -263,6 +265,13 @@ namespace Microsoft.AspNetCore.Mvc typeof(ApiBehaviorOptionsSetup), } }, + { + typeof(IPostConfigureOptions), + new Type[] + { + typeof(ApiBehaviorOptionsSetup), + } + }, { typeof(IActionConstraintProvider), new Type[] @@ -306,6 +315,28 @@ namespace Microsoft.AspNetCore.Mvc typeof(ApiBehaviorApplicationModelProvider), } }, + { + typeof(EndpointDataSource), + new Type[] + { + typeof(MvcEndpointDataSource), + } + }, + { + typeof(IStartupFilter), + new Type[] + { + typeof(MiddlewareFilterBuilderStartupFilter) + } + }, + { + typeof(MatcherPolicy), + new Type[] + { + typeof(ConsumesMatcherPolicy), + typeof(ActionConstraintMatcherPolicy), + } + }, }; } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileContentResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileContentResultTest.cs index e2d0dd087d..378422388b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileContentResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileContentResultTest.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs index da6808d055..8e467419b2 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs @@ -30,8 +30,7 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal("text/plain", result.ContentType.ToString()); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Fact] public async Task ContentDispositionHeader_IsEncodedCorrectly() { // See comment in FileResult.cs detailing how the FileDownloadName should be encoded. @@ -55,8 +54,7 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(@"attachment; filename=""some\\file""; filename*=UTF-8''some%5Cfile", httpContext.Response.Headers["Content-Disposition"]); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Fact] public async Task ContentDispositionHeader_IsEncodedCorrectly_ForUnicodeCharacters() { // Arrange @@ -78,8 +76,7 @@ namespace Microsoft.AspNetCore.Mvc httpContext.Response.Headers["Content-Disposition"]); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Fact] public async Task ExecuteResultAsync_DoesNotSetContentDisposition_IfNotSpecified() { // Arrange @@ -104,8 +101,7 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(Stream.Null, httpContext.Response.Body); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Fact] public async Task ExecuteResultAsync_SetsContentDisposition_IfSpecified() { // Arrange @@ -126,8 +122,7 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal("attachment; filename=filename.ext; filename*=UTF-8''filename.ext", httpContext.Response.Headers["Content-Disposition"]); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Fact] public async Task ExecuteResultAsync_ThrowsException_IfCannotResolveLoggerFactory() { // Arrange @@ -234,8 +229,7 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(expectedOutput, actual); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Fact] public async Task SetsAcceptRangeHeader() { // Arrange @@ -538,4 +532,4 @@ namespace Microsoft.AspNetCore.Mvc } } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileStreamResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileStreamResultTest.cs index 081eaf7b81..bfb001a9bc 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileStreamResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/FileStreamResultTest.cs @@ -10,7 +10,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Filters/MiddlewareFilterAttributeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Filters/MiddlewareFilterAttributeTest.cs index bedfd14f4a..bcfec63de6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Filters/MiddlewareFilterAttributeTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Filters/MiddlewareFilterAttributeTest.cs @@ -25,7 +25,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal Pipeline1.ConfigurePipeline = (ab) => { configureCallCount++; - ab.Use((httpCtxt, next) => + ab.Use((httpContext, next) => { return next(); }); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/FormatFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/FormatFilterTest.cs index b33698b52a..7d5760fca9 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/FormatFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/FormatFilterTest.cs @@ -1,15 +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; using System.Buffers; using System.Collections.Generic; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -30,13 +30,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } [Theory] - [InlineData("json", FormatSource.RouteData, "application/json")] - [InlineData("json", FormatSource.QueryData, "application/json")] - [InlineData("json", FormatSource.RouteAndQueryData, "application/json")] - public void FormatFilter_ContextContainsFormat_DefaultFormat( - string format, - FormatSource place, - string contentType) + [InlineData("json", FormatSource.RouteData)] + [InlineData("json", FormatSource.QueryData)] + [InlineData("json", FormatSource.RouteAndQueryData)] + public void FormatFilter_ContextContainsFormat_DefaultFormat(string format, FormatSource place) { // Arrange var mediaType = new StringSegment("application/json"); @@ -77,7 +74,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters httpContext.Setup(c => c.Request.Query.ContainsKey("format")).Returns(true); httpContext.Setup(c => c.Request.Query["format"]).Returns("xml"); - // Routedata contains json + // RouteData contains json var data = new RouteData(); data.Values.Add("format", "json"); @@ -307,6 +304,25 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.Equal(expected, filter.GetFormat(context)); } + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void FormatFilter_GetFormat_UsesInvariantCulture() + { + // Arrange + var mockObjects = new MockObjects(); + var context = mockObjects.CreateResultExecutingContext(); + context.RouteData.Values["format"] = new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)); + var expected = "10/31/2018 07:37:38 -07:00"; + var filterAttribute = new FormatFilterAttribute(); + var filter = new FormatFilter(mockObjects.OptionsManager, NullLoggerFactory.Instance); + + // Act + var format = filter.GetFormat(context); + + // Assert + Assert.Equal(expected, filter.GetFormat(context)); + } + [Fact] public void FormatFilter_ExplicitContentType_SetOnObjectResult_TakesPrecedence() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/FormatterMappingsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/FormatterMappingsTest.cs index d5428abc3f..a181b5528b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/FormatterMappingsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/FormatterMappingsTest.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Testing; using Microsoft.Net.Http.Headers; using Xunit; @@ -35,7 +34,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters var options = new FormatterMappings(); options.SetMediaTypeMappingForFormat(setFormat, MediaTypeHeaderValue.Parse(contentType)); - // Act + // Act var returnMediaType = options.GetMediaTypeMappingForFormat(getFormat); // Assert @@ -79,7 +78,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [InlineData("application/*")] [InlineData("*/json")] [InlineData("*/*")] - public void FormatterMappings_Wildcardformat(string format) + public void FormatterMappings_WildcardFormat(string format) { // Arrange var options = new FormatterMappings(); @@ -118,7 +117,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters mediaType = MediaTypeHeaderValue.Parse("application/bar"); options.SetMediaTypeMappingForFormat("bar", mediaType); - // Act + // Act var cleared = options.ClearMediaTypeMappingForFormat(format); // Assert diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/InputFormatterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/InputFormatterTest.cs index 2ad5752053..d5b631d9b6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/InputFormatterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/InputFormatterTest.cs @@ -265,6 +265,9 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [Theory] [InlineData("application/xml")] + [InlineData("application/mathml-content+xml")] + [InlineData("application/mathml-presentation+xml")] + [InlineData("application/mathml+xml; test=value")] public void XMLFormatter_CanRead_ReturnsTrueForSupportedMediaTypes(string requestContentType) { // Arrange @@ -289,9 +292,6 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } [Theory] - [InlineData("application/mathml-content+xml")] - [InlineData("application/mathml-presentation+xml")] - [InlineData("application/mathml+xml; undefined=ignored")] [InlineData("application/octet-stream; padding=3")] [InlineData("application/xml-dtd; undefined=ignored")] [InlineData("multipart/mixed; boundary=gc0p4Jq0M2Yt08j34c0p")] diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/MediaTypeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/MediaTypeTest.cs index 28591cf017..9ab131f8ec 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/MediaTypeTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/MediaTypeTest.cs @@ -214,6 +214,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [InlineData("application/entity+json", "application/entity+json")] [InlineData("application/*+json", "application/entity+json")] [InlineData("application/*", "application/entity+json")] + [InlineData("application/json", "application/vnd.restful+json")] + [InlineData("application/json", "application/problem+json")] public void IsSubsetOf_ReturnsTrueWhenExpected(string set, string subset) { // Arrange @@ -242,6 +244,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [InlineData("application/*+*", "application/json")] [InlineData("application/entity+*", "application/entity+json")] // We don't allow suffixes to be wildcards [InlineData("application/*+*", "application/entity+json")] // We don't allow suffixes to be wildcards + [InlineData("application/entity+json", "application/entity")] public void IsSubsetOf_ReturnsFalseWhenExpected(string set, string subset) { // Arrange diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/NoContentFormatterTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/NoContentFormatterTests.cs index 408806e727..b8e1d51bc2 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/NoContentFormatterTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/NoContentFormatterTests.cs @@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters { get { - // object value, bool useDeclaredTypeAsString, bool expectedCanwriteResult, bool useNonNullContentType + // object value, bool useDeclaredTypeAsString, bool expectedCanWriteResult, bool useNonNullContentType yield return new object[] { "valid value", true, false, true }; yield return new object[] { "valid value", false, false, true }; yield return new object[] { "", false, false, true }; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/HttpStatusCodeResultTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/HttpStatusCodeResultTests.cs index e69fabaf5e..48634917fb 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/HttpStatusCodeResultTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/HttpStatusCodeResultTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -32,6 +33,19 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(StatusCodes.Status404NotFound, httpContext.Response.StatusCode); } + [Fact] + public void HttpStatusCodeResult_ReturnsCorrectStatusCodeAsIStatusCodeActionResult() + { + // Arrange + var result = new StatusCodeResult(StatusCodes.Status404NotFound); + + // Act + var statusResult = result as IStatusCodeActionResult; + + // Assert + Assert.Equal(StatusCodes.Status404NotFound, statusResult?.StatusCode); + } + private static IServiceCollection CreateServices() { var services = new ServiceCollection(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ClientErrorResultFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ClientErrorResultFilterTest.cs new file mode 100644 index 0000000000..0a2c6db76d --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ClientErrorResultFilterTest.cs @@ -0,0 +1,124 @@ +// 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.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + public class ClientErrorResultFilterTest + { + private static readonly IActionResult Result = new EmptyResult(); + + [Fact] + public void OnResultExecuting_DoesNothing_IfActionIsNotClientErrorActionResult() + { + // Arrange + var actionResult = new NotFoundObjectResult(new object()); + var context = GetContext(actionResult); + var filter = GetFilter(); + + // Act + filter.OnResultExecuting(context); + + // Assert + Assert.Same(actionResult, context.Result); + } + + [Fact] + public void OnResultExecuting_DoesNothing_IfTransformedValueIsNull() + { + // Arrange + var actionResult = new NotFoundResult(); + var context = GetContext(actionResult); + var factory = new Mock(); + factory + .Setup(f => f.GetClientError(It.IsAny(), It.IsAny())) + .Returns((IActionResult)null) + .Verifiable(); + + var filter = new ClientErrorResultFilter(factory.Object, NullLogger.Instance); + + // Act + filter.OnResultExecuting(context); + + // Assert + Assert.Same(actionResult, context.Result); + factory.Verify(); + } + + [Fact] + public void OnResultExecuting_TransformsClientErrors() + { + // Arrange + var actionResult = new NotFoundResult(); + var context = GetContext(actionResult); + var filter = GetFilter(); + + // Act + filter.OnResultExecuting(context); + + // Assert + Assert.Same(Result, context.Result); + } + + [Theory] + [InlineData(400)] + [InlineData(409)] + [InlineData(503)] + public void OnResultExecuting_Transforms4XXStatusCodeResult(int statusCode) + { + // Arrange + var actionResult = new StatusCodeResult(statusCode); + var context = GetContext(actionResult); + var filter = GetFilter(); + + // Act + filter.OnResultExecuting(context); + + // Assert + Assert.Same(Result, context.Result); + } + + [Theory] + [InlineData(201)] + [InlineData(302)] + [InlineData(399)] + public void OnResultExecuting_DoesNotTransformStatusCodesLessThan400(int statusCode) + { + // Arrange + var actionResult = new StatusCodeResult(statusCode); + var context = GetContext(actionResult); + var filter = GetFilter(); + + // Act + filter.OnResultExecuting(context); + + // Assert + Assert.Same(actionResult, context.Result); + } + + private static ClientErrorResultFilter GetFilter() + { + var factory = Mock.Of( + f => f.GetClientError(It.IsAny(), It.IsAny()) == Result); + + return new ClientErrorResultFilter(factory, NullLogger.Instance); + } + + private static ResultExecutingContext GetContext(IActionResult actionResult) + { + return new ResultExecutingContext( + new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()), + Array.Empty(), + actionResult, + new object()); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/CompatibilitySwitchTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/CompatibilitySwitchTest.cs index 119113f668..aa91274e77 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/CompatibilitySwitchTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/CompatibilitySwitchTest.cs @@ -19,7 +19,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure } [Fact] - public void Constructor_WithNameAndInitalValue_IsValueSetIsFalse() + public void Constructor_WithNameAndInitialValue_IsValueSetIsFalse() { // Arrange & Act var @switch = new CompatibilitySwitch("TestProperty", initialValue: true); @@ -36,7 +36,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure var @switch = new CompatibilitySwitch("TestProperty"); // Act - @switch.Value = false; // You don't need to actually change the value, just caling the setting works + @switch.Value = false; // You don't need to actually change the value, just calling the setting works // Assert Assert.False(@switch.Value); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionDescriptorCollectionProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/DefaultActionDescriptorCollectionProviderTest.cs similarity index 70% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionDescriptorCollectionProviderTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/DefaultActionDescriptorCollectionProviderTest.cs index 0c599b47c6..e5eb4b7f94 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionDescriptorCollectionProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/DefaultActionDescriptorCollectionProviderTest.cs @@ -4,14 +4,13 @@ using System.Linq; using System.Threading; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Primitives; using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Internal +namespace Microsoft.AspNetCore.Mvc.Infrastructure { - public class ActionDescriptorCollectionProviderTest + public class DefaultActionDescriptorCollectionProviderTest { [Fact] public void ActionDescriptors_ReadsDescriptorsFromActionDescriptorProviders() @@ -24,7 +23,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var expected3 = new ActionDescriptor(); var actionDescriptorProvider2 = GetActionDescriptorProvider(expected2, expected3); - var actionDescriptorCollectionProvider = new ActionDescriptorCollectionProvider( + var actionDescriptorCollectionProvider = new DefaultActionDescriptorCollectionProvider( new[] { actionDescriptorProvider1, actionDescriptorProvider2 }, Enumerable.Empty()); @@ -46,7 +45,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Arrange var actionDescriptorProvider = GetActionDescriptorProvider(new ActionDescriptor()); - var actionDescriptorCollectionProvider = new ActionDescriptorCollectionProvider( + var actionDescriptorCollectionProvider = new DefaultActionDescriptorCollectionProvider( new[] { actionDescriptorProvider }, Enumerable.Empty()); @@ -66,55 +65,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } [Fact] - public void ActionDescriptors_UpdateWhenChangeTokenProviderChanges() - { - // Arrange - var actionDescriptorProvider = new Mock(); - var expected1 = new ActionDescriptor(); - var expected2 = new ActionDescriptor(); - - var invocations = 0; - actionDescriptorProvider - .Setup(p => p.OnProvidersExecuting(It.IsAny())) - .Callback((ActionDescriptorProviderContext context) => - { - if (invocations == 0) - { - context.Results.Add(expected1); - } - else - { - context.Results.Add(expected2); - } - - invocations++; - }); - var changeProvider = new TestChangeProvider(); - var actionDescriptorCollectionProvider = new ActionDescriptorCollectionProvider( - new[] { actionDescriptorProvider.Object }, - new[] { changeProvider }); - - // Act - 1 - var collection1 = actionDescriptorCollectionProvider.ActionDescriptors; - - // Assert - 1 - Assert.Equal(0, collection1.Version); - Assert.Collection(collection1.Items, - item => Assert.Same(expected1, item)); - - // Act - 2 - changeProvider.TokenSource.Cancel(); - var collection2 = actionDescriptorCollectionProvider.ActionDescriptors; - - // Assert - 2 - Assert.NotSame(collection1, collection2); - Assert.Equal(1, collection2.Version); - Assert.Collection(collection2.Items, - item => Assert.Same(expected2, item)); - } - - [Fact] - public void ActionDescriptors_SubscribesToNewChangeNotificationsAfterInvalidating() + public void ActionDescriptors_UpdatesAndResubscribes_WhenChangeTokenTriggers() { // Arrange var actionDescriptorProvider = new Mock(); @@ -143,34 +94,61 @@ namespace Microsoft.AspNetCore.Mvc.Internal invocations++; }); var changeProvider = new TestChangeProvider(); - var actionDescriptorCollectionProvider = new ActionDescriptorCollectionProvider( + var actionDescriptorCollectionProvider = new DefaultActionDescriptorCollectionProvider( new[] { actionDescriptorProvider.Object }, new[] { changeProvider }); // Act - 1 + var changeToken1 = actionDescriptorCollectionProvider.GetChangeToken(); var collection1 = actionDescriptorCollectionProvider.ActionDescriptors; + ActionDescriptorCollection captured = null; + changeToken1.RegisterChangeCallback((_) => + { + captured = actionDescriptorCollectionProvider.ActionDescriptors; + }, null); + // Assert - 1 + Assert.False(changeToken1.HasChanged); Assert.Equal(0, collection1.Version); Assert.Collection(collection1.Items, item => Assert.Same(expected1, item)); // Act - 2 changeProvider.TokenSource.Cancel(); + var changeToken2 = actionDescriptorCollectionProvider.GetChangeToken(); var collection2 = actionDescriptorCollectionProvider.ActionDescriptors; + changeToken2.RegisterChangeCallback((_) => + { + captured = actionDescriptorCollectionProvider.ActionDescriptors; + }, null); + // Assert - 2 + Assert.NotSame(changeToken1, changeToken2); + Assert.True(changeToken1.HasChanged); + Assert.False(changeToken2.HasChanged); + Assert.NotSame(collection1, collection2); + Assert.NotNull(captured); + Assert.Same(captured, collection2); Assert.Equal(1, collection2.Version); Assert.Collection(collection2.Items, item => Assert.Same(expected2, item)); // Act - 3 changeProvider.TokenSource.Cancel(); + var changeToken3 = actionDescriptorCollectionProvider.GetChangeToken(); var collection3 = actionDescriptorCollectionProvider.ActionDescriptors; // Assert - 3 + Assert.NotSame(changeToken2, changeToken3); + Assert.True(changeToken2.HasChanged); + Assert.False(changeToken3.HasChanged); + Assert.NotSame(collection2, collection3); + Assert.NotNull(captured); + Assert.Same(captured, collection3); Assert.Equal(2, collection3.Version); Assert.Collection(collection3.Items, item => Assert.Same(expected3, item)); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/MvcOptionsConfigureCompatibilityOptionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/MvcOptionsConfigureCompatibilityOptionsTest.cs new file mode 100644 index 0000000000..d661fe0bd5 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/MvcOptionsConfigureCompatibilityOptionsTest.cs @@ -0,0 +1,83 @@ +// 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.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Core.Infrastructure +{ + public class MvcOptionsConfigureCompatibilityOptionsTest + { + [Fact] + public void PostConfigure_ConfiguresMaxValidationDepth() + { + // Arrange + var mvcOptions = new MvcOptions(); + var mvcCompatibilityOptions = new MvcCompatibilityOptions + { + CompatibilityVersion = CompatibilityVersion.Version_2_2, + }; + + var configureOptions = new MvcOptionsConfigureCompatibilityOptions( + NullLoggerFactory.Instance, + Options.Create(mvcCompatibilityOptions)); + + // Act + configureOptions.PostConfigure(string.Empty, mvcOptions); + + // Assert + Assert.Equal(32, mvcOptions.MaxValidationDepth); + } + + [Fact] + public void PostConfigure_DoesNotConfiguresMaxValidationDepth_WhenSetToNull() + { + // Arrange + var mvcOptions = new MvcOptions + { + MaxValidationDepth = null, + }; + var mvcCompatibilityOptions = new MvcCompatibilityOptions + { + CompatibilityVersion = CompatibilityVersion.Version_2_2, + }; + + var configureOptions = new MvcOptionsConfigureCompatibilityOptions( + NullLoggerFactory.Instance, + Options.Create(mvcCompatibilityOptions)); + + // Act + configureOptions.PostConfigure(string.Empty, mvcOptions); + + // Assert + Assert.Null(mvcOptions.MaxValidationDepth); + } + + [Fact] + public void PostConfigure_DoesNotConfiguresMaxValidationDepth_WhenSetToValue() + { + // Arrange + var expected = 13; + var mvcOptions = new MvcOptions + { + MaxValidationDepth = expected, + }; + var mvcCompatibilityOptions = new MvcCompatibilityOptions + { + CompatibilityVersion = CompatibilityVersion.Version_2_2, + }; + + var configureOptions = new MvcOptionsConfigureCompatibilityOptions( + NullLoggerFactory.Instance, + Options.Create(mvcCompatibilityOptions)); + + // Act + configureOptions.PostConfigure(string.Empty, mvcOptions); + + // Assert + Assert.Equal(expected, mvcOptions.MaxValidationDepth); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/NullableCompatibilitySwitchTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/NullableCompatibilitySwitchTest.cs new file mode 100644 index 0000000000..bb5f1de6ae --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/NullableCompatibilitySwitchTest.cs @@ -0,0 +1,77 @@ +// 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.Infrastructure +{ + public class NullableCompatibilitySwitchTest + { + [Fact] + public void Constructor_WithName_IsValueSetIsFalse() + { + // Arrange & Act + var @switch = new NullableCompatibilitySwitch("TestProperty"); + + // Assert + Assert.Null(@switch.Value); + Assert.False(@switch.IsValueSet); + } + + [Fact] + public void ValueNonInterface_SettingValueToNull_SetsIsValueSetToTrue() + { + // Arrange + var @switch = new NullableCompatibilitySwitch("TestProperty"); + + // Act + @switch.Value = null; + + // Assert + Assert.Null(@switch.Value); + Assert.True(@switch.IsValueSet); + } + + [Fact] + public void ValueNonInterface_SettingValue_SetsIsValueSetToTrue() + { + // Arrange + var @switch = new NullableCompatibilitySwitch("TestProperty"); + + // Act + @switch.Value = false; + + // Assert + Assert.False(@switch.Value); + Assert.True(@switch.IsValueSet); + } + + [Fact] + public void ValueInterface_SettingValueToNull_SetsIsValueSetToTrue() + { + // Arrange + var @switch = new NullableCompatibilitySwitch("TestProperty"); + + // Act + ((ICompatibilitySwitch)@switch).Value = null; + + // Assert + Assert.Null(@switch.Value); + Assert.True(@switch.IsValueSet); + } + + [Fact] + public void ValueInterface_SettingValue_SetsIsValueSetToTrue() + { + // Arrange + var @switch = new NullableCompatibilitySwitch("TestProperty"); + + // Act + ((ICompatibilitySwitch)@switch).Value = true; + + // Assert + Assert.True(@switch.Value); + Assert.True(@switch.IsValueSet); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ObjectResultExecutorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ObjectResultExecutorTest.cs index 2cf6981202..207d04161f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ObjectResultExecutorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ObjectResultExecutorTest.cs @@ -2,18 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.AspNetCore.Mvc.Internal; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Xunit; @@ -21,6 +17,32 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure { public class ObjectResultExecutorTest { + [Fact] + public async Task ExecuteAsync_UsesSpecifiedContentType() + { + // Arrange + var executor = CreateExecutor(); + + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext() { HttpContext = httpContext }; + httpContext.Request.Headers[HeaderNames.Accept] = "application/xml"; // This will not be used + httpContext.Response.ContentType = "text/json"; + + var result = new ObjectResult("input") + { + ContentTypes = { "text/xml", }, + }; + result.Formatters.Add(new TestXmlOutputFormatter()); + result.Formatters.Add(new TestJsonOutputFormatter()); + result.Formatters.Add(new TestStringOutputFormatter()); // This will be chosen based on the content type + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + MediaTypeAssert.Equal("text/xml; charset=utf-8", httpContext.Response.ContentType); + } + // For this test case probably the most common use case is when there is a format mapping based // content type selected but the developer had set the content type on the Response.ContentType [Fact] @@ -89,6 +111,74 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure Assert.Equal(406, httpContext.Response.StatusCode); } + [Fact] + public async Task ExecuteAsync_ForProblemDetailsValue_UsesSpecifiedContentType() + { + // Arrange + var executor = CreateExecutor(); + + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext() { HttpContext = httpContext }; + httpContext.Response.ContentType = "application/json"; + + var result = new ObjectResult(new ProblemDetails()) + { + ContentTypes = { "text/plain" }, + }; + result.Formatters.Add(new TestXmlOutputFormatter()); + result.Formatters.Add(new TestJsonOutputFormatter()); + result.Formatters.Add(new TestStringOutputFormatter()); // This will be chosen based on the content type + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + MediaTypeAssert.Equal("text/plain; charset=utf-8", httpContext.Response.ContentType); + } + + [Fact] + public async Task ExecuteAsync_ForProblemDetailsValue_UsesResponseContentType() + { + // Arrange + var executor = CreateExecutor(); + + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext() { HttpContext = httpContext }; + httpContext.Response.ContentType = "application/json"; + + var result = new ObjectResult(new ProblemDetails()); + result.Formatters.Add(new TestXmlOutputFormatter()); + result.Formatters.Add(new TestJsonOutputFormatter()); // This will be chosen based on the response content type + result.Formatters.Add(new TestStringOutputFormatter()); + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + MediaTypeAssert.Equal("application/json; charset=utf-8", httpContext.Response.ContentType); + } + + [Fact] + public async Task ExecuteAsync_NoContentTypeProvidedForProblemDetails_UsesDefaultContentTypes() + { + // Arrange + var executor = CreateExecutor(); + + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext() { HttpContext = httpContext }; + + var result = new ObjectResult(new ProblemDetails()); + result.Formatters.Add(new TestXmlOutputFormatter()); // This will be chosen based on the implicitly added content type + result.Formatters.Add(new TestJsonOutputFormatter()); + result.Formatters.Add(new TestStringOutputFormatter()); + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + MediaTypeAssert.Equal("application/problem+xml; charset=utf-8", httpContext.Response.ContentType); + } + [Fact] public async Task ExecuteAsync_NoFormatterFound_Returns406() { @@ -314,6 +404,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure { SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/json")); SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/json")); + SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/*+json")); SupportedEncodings.Add(Encoding.UTF8); } @@ -330,6 +421,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure { SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/xml")); SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/xml")); + SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/*+xml")); SupportedEncodings.Add(Encoding.UTF8); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ProblemDetalsClientErrorFactoryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ProblemDetalsClientErrorFactoryTest.cs new file mode 100644 index 0000000000..3a84deabe7 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ProblemDetalsClientErrorFactoryTest.cs @@ -0,0 +1,129 @@ +// 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.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure +{ + public class ProblemDetailsClientErrorFactoryTest + { + [Fact] + public void GetClientError_ReturnsProblemDetails_IfNoMappingWasFound() + { + // Arrange + var clientError = new UnsupportedMediaTypeResult(); + var factory = new ProblemDetailsClientErrorFactory(Options.Create(new ApiBehaviorOptions + { + ClientErrorMapping = + { + [405] = new ClientErrorData { Link = "Some link", Title = "Summary" }, + }, + })); + + // Act + var result = factory.GetClientError(GetActionContext(), clientError); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(new[] { "application/problem+json", "application/problem+xml" }, objectResult.ContentTypes); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal(415, problemDetails.Status); + Assert.Equal("about:blank", problemDetails.Type); + Assert.Null(problemDetails.Title); + Assert.Null(problemDetails.Detail); + Assert.Null(problemDetails.Instance); + } + + [Fact] + public void GetClientError_ReturnsProblemDetails() + { + // Arrange + var clientError = new UnsupportedMediaTypeResult(); + var factory = new ProblemDetailsClientErrorFactory(Options.Create(new ApiBehaviorOptions + { + ClientErrorMapping = + { + [415] = new ClientErrorData { Link = "Some link", Title = "Summary" }, + }, + })); + + // Act + var result = factory.GetClientError(GetActionContext(), clientError); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(new[] { "application/problem+json", "application/problem+xml" }, objectResult.ContentTypes); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal(415, problemDetails.Status); + Assert.Equal("Some link", problemDetails.Type); + Assert.Equal("Summary", problemDetails.Title); + Assert.Null(problemDetails.Detail); + Assert.Null(problemDetails.Instance); + } + + [Fact] + public void GetClientError_UsesActivityId_ToSetTraceId() + { + // Arrange + using (new ActivityReplacer()) + { + var clientError = new UnsupportedMediaTypeResult(); + var factory = new ProblemDetailsClientErrorFactory(Options.Create(new ApiBehaviorOptions + { + ClientErrorMapping = + { + [415] = new ClientErrorData { Link = "Some link", Title = "Summary" }, + }, + })); + + // Act + var result = factory.GetClientError(GetActionContext(), clientError); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(new[] { "application/problem+json", "application/problem+xml" }, objectResult.ContentTypes); + var problemDetails = Assert.IsType(objectResult.Value); + + Assert.Equal(Activity.Current.Id, problemDetails.Extensions["traceId"]); + } + } + + [Fact] + public void GetClientError_UsesHttpContext_ToSetTraceIdIfActivityIdIsNotSet() + { + // Arrange + var clientError = new UnsupportedMediaTypeResult(); + var factory = new ProblemDetailsClientErrorFactory(Options.Create(new ApiBehaviorOptions + { + ClientErrorMapping = + { + [415] = new ClientErrorData { Link = "Some link", Title = "Summary" }, + }, + })); + + // Act + var result = factory.GetClientError(GetActionContext(), clientError); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(new[] { "application/problem+json", "application/problem+xml" }, objectResult.ContentTypes); + var problemDetails = Assert.IsType(objectResult.Value); + + Assert.Equal("42", problemDetails.Extensions["traceId"]); + } + + private static ActionContext GetActionContext() + { + return new ActionContext + { + HttpContext = new DefaultHttpContext + { + TraceIdentifier = "42", + } + }; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionConstraintCacheTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionConstraintCacheTest.cs index 0187f81666..c4374a5bbb 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionConstraintCacheTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionConstraintCacheTest.cs @@ -159,7 +159,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal private static ActionConstraintCache CreateCache(params IActionConstraintProvider[] providers) { - var descriptorProvider = new ActionDescriptorCollectionProvider( + var descriptorProvider = new DefaultActionDescriptorCollectionProvider( Enumerable.Empty(), Enumerable.Empty()); return new ActionConstraintCache(descriptorProvider, providers); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionMethodExecutorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionMethodExecutorTest.cs index 6cd343dcd6..8e3f8c37e0 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionMethodExecutorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionMethodExecutorTest.cs @@ -60,7 +60,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Internal var valueTask = actionMethodExecutor.Execute(mapper, objectMethodExecutor, controller, Array.Empty()); // Assert - Assert.IsType(valueTask.Result); + Assert.IsType(valueTask.Result); } [Fact] @@ -133,7 +133,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Internal var valueTask = actionMethodExecutor.Execute(mapper, objectMethodExecutor, controller, Array.Empty()); // Assert - Assert.IsType(valueTask.Result); + Assert.IsType(valueTask.Result); } [Fact] @@ -166,7 +166,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Internal var valueTask = actionMethodExecutor.Execute(mapper, objectMethodExecutor, controller, Array.Empty()); // Assert - Assert.IsType(valueTask.Result); + Assert.IsType(valueTask.Result); } [Fact] @@ -183,7 +183,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Internal // Assert await valueTask; - Assert.IsType(valueTask.Result); + Assert.IsType(valueTask.Result); } [Fact] @@ -291,7 +291,7 @@ namespace Microsoft.AspNetCore.Mvc.Core.Internal public IActionResult ReturnIActionResult() => new ContentResult(); - public PartialViewResult ReturnsIActionResultSubType() => new PartialViewResult(); + public ContentResult ReturnsIActionResultSubType() => new ContentResult(); public ActionResult ReturnsActionResultOfT() => new ActionResult(new TestModel()); @@ -309,9 +309,9 @@ namespace Microsoft.AspNetCore.Mvc.Core.Internal return Task.CompletedTask; } - public Task ReturnIActionResultAsync() => Task.FromResult((IActionResult)new ViewResult()); + public Task ReturnIActionResultAsync() => Task.FromResult((IActionResult)new StatusCodeResult(201)); - public Task ReturnsIActionResultSubTypeAsync() => Task.FromResult(new ViewResult()); + public Task ReturnsIActionResultSubTypeAsync() => Task.FromResult(new StatusCodeResult(200)); public Task ReturnsModelAsModelAsync() => Task.FromResult(new TestModel()); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs index 6db71801b5..214c6eebb7 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -64,6 +65,49 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure Assert.Collection(candidates, (a) => Assert.Same(actions[0], a)); } + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void SelectCandidates_SingleMatch_UsesInvariantCulture() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" }, + { "date", "10/31/2018 07:37:38 -07:00" }, + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var selector = CreateSelector(actions); + + var routeContext = CreateRouteContext("GET"); + routeContext.RouteData.Values.Add("controller", "Home"); + routeContext.RouteData.Values.Add("action", "Index"); + routeContext.RouteData.Values.Add( + "date", + new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7))); + + // Act + var candidates = selector.SelectCandidates(routeContext); + + // Assert + Assert.Collection(candidates, (a) => Assert.Same(actions[0], a)); + } + [Fact] public void SelectCandidates_MultipleMatches() { @@ -932,7 +976,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure private ControllerActionDescriptor InvokeActionSelector(RouteContext context) { var actionDescriptorProvider = GetActionDescriptorProvider(); - var actionDescriptorCollectionProvider = new ActionDescriptorCollectionProvider( + var actionDescriptorCollectionProvider = new DefaultActionDescriptorCollectionProvider( new[] { actionDescriptorProvider }, Enumerable.Empty()); @@ -961,7 +1005,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure var manager = GetApplicationManager(controllerTypes); - var modelProvider = new DefaultApplicationModelProvider(options, TestModelMetadataProvider.CreateDefaultProvider()); + var modelProvider = new DefaultApplicationModelProvider(options, new EmptyModelMetadataProvider()); var provider = new ControllerActionDescriptorProvider( manager, @@ -1092,7 +1136,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure private static ActionConstraintCache GetActionConstraintCache(IActionConstraintProvider[] actionConstraintProviders = null) { - var descriptorProvider = new ActionDescriptorCollectionProvider( + var descriptorProvider = new DefaultActionDescriptorCollectionProvider( Enumerable.Empty(), Enumerable.Empty()); return new ActionConstraintCache(descriptorProvider, actionConstraintProviders.AsEnumerable() ?? new List()); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorOptionsSetupTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorOptionsSetupTest.cs new file mode 100644 index 0000000000..4ae53cd4d7 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorOptionsSetupTest.cs @@ -0,0 +1,163 @@ +// 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.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + public class ApiBehaviorOptionsSetupTest + { + [Fact] + public void Configure_AssignsInvalidModelStateResponseFactory() + { + // Arrange + var optionsSetup = new ApiBehaviorOptionsSetup( + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions())); + var options = new ApiBehaviorOptions(); + + // Act + optionsSetup.Configure(options); + + // Assert + Assert.Same(ApiBehaviorOptionsSetup.DefaultFactory, options.InvalidModelStateResponseFactory); + } + + [Fact] + public void Configure_AddsClientErrorMappings() + { + // Arrange + var expected = new[] { 400, 401, 403, 404, 406, 409, 415, 422, }; + var optionsSetup = new ApiBehaviorOptionsSetup( + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions())); + var options = new ApiBehaviorOptions(); + + // Act + optionsSetup.Configure(options); + + // Assert + Assert.Equal(expected, options.ClientErrorMapping.Keys); + } + + [Fact] + public void PostConfigure_SetProblemDetailsModelStateResponseFactory() + { + // Arrange + var optionsSetup = new ApiBehaviorOptionsSetup( + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions { CompatibilityVersion = CompatibilityVersion.Latest })); + var options = new ApiBehaviorOptions(); + + // Act + optionsSetup.Configure(options); + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.Same(ApiBehaviorOptionsSetup.ProblemDetailsFactory, options.InvalidModelStateResponseFactory); + } + + [Fact] + public void PostConfigure_DoesNotSetProblemDetailsFactoryWithLegacyCompatBehavior() + { + // Arrange + var optionsSetup = new ApiBehaviorOptionsSetup( + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions { CompatibilityVersion = CompatibilityVersion.Version_2_1 })); + var options = new ApiBehaviorOptions(); + + // Act + optionsSetup.Configure(options); + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.Same(ApiBehaviorOptionsSetup.DefaultFactory, options.InvalidModelStateResponseFactory); + } + + [Fact] + public void PostConfigure_DoesNotSetProblemDetailsFactory_IfValueWasModified() + { + // Arrange + var optionsSetup = new ApiBehaviorOptionsSetup( + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions { CompatibilityVersion = CompatibilityVersion.Latest })); + var options = new ApiBehaviorOptions(); + Func expected = _ => null; + + // Act + optionsSetup.Configure(options); + // This is equivalent to user code updating the value via ConfigureOptions + options.InvalidModelStateResponseFactory = expected; + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.Same(expected, options.InvalidModelStateResponseFactory); + } + + [Fact] + public void ProblemDetailsInvalidModelStateResponse_ReturnsBadRequestWithProblemDetails() + { + // Arrange + var actionContext = new ActionContext + { + HttpContext = new DefaultHttpContext { TraceIdentifier = "42" }, + }; + + // Act + var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(actionContext); + + // Assert + var badRequest = Assert.IsType(result); + Assert.Equal(new[] { "application/problem+json", "application/problem+xml" }, badRequest.ContentTypes.OrderBy(c => c)); + + var problemDetails = Assert.IsType(badRequest.Value); + Assert.Equal(400, problemDetails.Status); + } + + [Fact] + public void ProblemDetailsInvalidModelStateResponse_SetsTraceId() + { + // Arrange + using (new ActivityReplacer()) + { + var actionContext = new ActionContext + { + HttpContext = new DefaultHttpContext { TraceIdentifier = "42" }, + }; + + // Act + var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(actionContext); + + // Assert + var badRequest = Assert.IsType(result); + var problemDetails = Assert.IsType(badRequest.Value); + Assert.Equal(Activity.Current.Id, problemDetails.Extensions["traceId"]); + } + } + + [Fact] + public void ProblemDetailsInvalidModelStateResponse_SetsTraceIdFromRequest_IfActivityIsNull() + { + // Arrange + var actionContext = new ActionContext + { + HttpContext = new DefaultHttpContext { TraceIdentifier = "42" }, + }; + + // Act + var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(actionContext); + + // Assert + var badRequest = Assert.IsType(result); + var problemDetails = Assert.IsType(badRequest.Value); + Assert.Equal("42", problemDetails.Extensions["traceId"]); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/AttributeRoutingTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/AttributeRoutingTest.cs index c9d281b80d..4c475cadad 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/AttributeRoutingTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/AttributeRoutingTest.cs @@ -175,8 +175,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal .SetupGet(o => o.Value) .Returns(new RouteOptions()); +#pragma warning disable CS0618 // Type or member is obsolete + var inlineConstraintResolver = new DefaultInlineConstraintResolver(routeOptions.Object); +#pragma warning restore CS0618 // Type or member is obsolete + var services = new ServiceCollection() - .AddSingleton(new DefaultInlineConstraintResolver(routeOptions.Object)) + .AddSingleton(inlineConstraintResolver) .AddSingleton(); services.AddSingleton(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/AuthorizationApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/AuthorizationApplicationModelProviderTest.cs index 5015aaa56a..866f13e461 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/AuthorizationApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/AuthorizationApplicationModelProviderTest.cs @@ -169,7 +169,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { var defaultProvider = new DefaultApplicationModelProvider( Options.Create(new MvcOptions()), - TestModelMetadataProvider.CreateDefaultProvider()); + new EmptyModelMetadataProvider()); var context = new ApplicationModelProviderContext(new[] { controllerType.GetTypeInfo() }); defaultProvider.OnProvidersExecuting(context); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionDescriptorProviderTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionDescriptorProviderTests.cs index 0afae5ae78..e35f55a9d9 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionDescriptorProviderTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionDescriptorProviderTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -251,6 +253,90 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Equal(nameof(ConventionallyRoutedController.ConventionalAction), actionConstraint.Value); } + [Fact] + public void GetDescriptors_EndpointMetadata_ContainsAttributesFromActionAndController() + { + // Arrange & Act + var descriptors = GetDescriptors( + typeof(AuthorizeController).GetTypeInfo()); + + // Assert + Assert.Equal(2, descriptors.Count()); + + var anonymousAction = Assert.Single(descriptors, a => a.RouteValues["action"] == "AllowAnonymousAction"); + + Assert.NotNull(anonymousAction.EndpointMetadata); + + Assert.Collection(anonymousAction.EndpointMetadata, + metadata => Assert.IsType(metadata), + metadata => Assert.IsType(metadata)); + + var authorizeAction = Assert.Single(descriptors, a => a.RouteValues["action"] == "AuthorizeAction"); + + Assert.NotNull(authorizeAction.EndpointMetadata); + + Assert.Collection(authorizeAction.EndpointMetadata, + metadata => Assert.Equal("ActionPolicy", Assert.IsType(metadata).Policy), + metadata => Assert.Equal("ControllerPolicy", Assert.IsType(metadata).Policy)); + } + + [Fact] + public void GetDescriptors_ActionWithHttpMethods_AddedToEndpointMetadata() + { + // Arrange & Act + var descriptors = GetDescriptors( + typeof(AttributeRoutedController).GetTypeInfo()); + + // Assert + var action = Assert.Single(descriptors); + + Assert.NotNull(action.EndpointMetadata); + + Assert.Collection(action.EndpointMetadata, + metadata => Assert.IsType(metadata), + metadata => + { + var httpMethodMetadata = Assert.IsType(metadata); + + Assert.False(httpMethodMetadata.AcceptCorsPreflight); + Assert.Equal("GET", Assert.Single(httpMethodMetadata.HttpMethods)); + }, + metadata => Assert.IsType(metadata)); + } + + [Fact] + public void GetDescriptors_ActionWithMultipleHttpMethods_SingleHttpMethodMetadata() + { + // Arrange & Act + var descriptors = GetDescriptors( + typeof(NonDuplicatedAttributeRouteController).GetTypeInfo()); + + // Assert + var actions = descriptors + .OfType() + .Where(d => d.ActionName == nameof(NonDuplicatedAttributeRouteController.DifferentHttpMethods)); + + Assert.Collection(actions, + InspectElement("GET"), + InspectElement("POST"), + InspectElement("PUT"), + InspectElement("PATCH"), + InspectElement("DELETE")); + + Action InspectElement(string httpMethod) + { + return (descriptor) => + { + var httpMethodAttribute = Assert.Single(descriptor.EndpointMetadata.OfType()); + Assert.Equal(httpMethod, httpMethodAttribute.HttpMethods.Single(), ignoreCase: true); + + var httpMethodMetadata = Assert.Single(descriptor.EndpointMetadata.OfType()); + Assert.Equal(httpMethod, httpMethodMetadata.HttpMethods.Single(), ignoreCase: true); + Assert.False(httpMethodMetadata.AcceptCorsPreflight); + }; + } + } + [Fact] public void GetDescriptors_AddsControllerAndActionDefaults_ToAttributeRoutedActions() { @@ -709,7 +795,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var manager = GetApplicationManager(new[] { controllerTypeInfo }); var options = Options.Create(new MvcOptions()); options.Value.Conventions.Add(new TestRoutingConvention()); - var modelProvider = new DefaultApplicationModelProvider(options, TestModelMetadataProvider.CreateDefaultProvider()); + var modelProvider = new DefaultApplicationModelProvider(options, new EmptyModelMetadataProvider()); var provider = new ControllerActionDescriptorProvider( manager, new[] { modelProvider }, @@ -1118,7 +1204,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal [Theory] [InlineData("A", typeof(ApiExplorerEnabledConventionalRoutedController))] [InlineData("A", typeof(ApiExplorerEnabledActionConventionalRoutedController))] - public void ApiExplorer_ThrowsForContentionalRouting(string actionName, Type type) + public void ApiExplorer_ThrowsForConventionalRouting(string actionName, Type type) { // Arrange var assemblyName = type.GetTypeInfo().Assembly.GetName().Name; @@ -1397,7 +1483,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var manager = GetApplicationManager(new[] { controllerTypeInfo }); - var modelProvider = new DefaultApplicationModelProvider(options, TestModelMetadataProvider.CreateDefaultProvider()); + var modelProvider = new DefaultApplicationModelProvider(options, new EmptyModelMetadataProvider()); var provider = new ControllerActionDescriptorProvider( manager, @@ -1413,7 +1499,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var options = Options.Create(new MvcOptions()); var manager = GetApplicationManager(controllerTypeInfos); - var modelProvider = new DefaultApplicationModelProvider(options, TestModelMetadataProvider.CreateDefaultProvider()); + var modelProvider = new DefaultApplicationModelProvider(options, new EmptyModelMetadataProvider()); var provider = new ControllerActionDescriptorProvider( manager, @@ -1432,7 +1518,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var manager = GetApplicationManager(new[] { controllerTypeInfo }); - var modelProvider = new DefaultApplicationModelProvider(options, TestModelMetadataProvider.CreateDefaultProvider()); + var modelProvider = new DefaultApplicationModelProvider(options, new EmptyModelMetadataProvider()); var provider = new ControllerActionDescriptorProvider( manager, @@ -1808,6 +1894,16 @@ namespace Microsoft.AspNetCore.Mvc.Internal public void AttributeRoutedAction() { } } + [Authorize("ControllerPolicy")] + private class AuthorizeController + { + [AllowAnonymous] + public void AllowAnonymousAction() { } + + [Authorize("ActionPolicy")] + public void AuthorizeAction() { } + } + private class EmptyController { } @@ -2024,7 +2120,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } - private class UserController : Controller + private class UserController : ControllerBase { public string GetUser(int id) { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionInvokerCacheTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionInvokerCacheTest.cs index 19b1b3b171..247206469f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionInvokerCacheTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionInvokerCacheTest.cs @@ -21,8 +21,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { public class ControllerActionInvokerCacheTest { - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Fact] public void GetControllerActionMethodExecutor_CachesFilters() { // Arrange @@ -43,8 +42,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Equal(cacheEntry1.filters, cacheEntry2.filters); } - [ConditionalFact] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Fact] public void GetControllerActionMethodExecutor_CachesEntry() { // Arrange @@ -102,7 +100,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { var descriptorProvider = new CustomActionDescriptorCollectionProvider( new[] { controllerContext.ActionDescriptor }); - var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadataProvider = new EmptyModelMetadataProvider(); var modelBinderFactory = TestModelBinderFactory.CreateDefault(); var mvcOptions = Options.Create(new MvcOptions { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionInvokerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionInvokerTest.cs index 9d5c551ce1..74090a932c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionInvokerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerActionInvokerTest.cs @@ -1206,7 +1206,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } [Theory] - [InlineData(nameof(TestController.AsynActionMethodWithTestActionResult))] + [InlineData(nameof(TestController.AsyncActionMethodWithTestActionResult))] [InlineData(nameof(TestController.ActionMethodWithTestActionResult))] public async Task InvokeAction_ReturnTypeAsIActionResult_ReturnsExpected(string methodName) { @@ -1654,7 +1654,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal return new TestActionResult { Value = value }; } - public async Task AsynActionMethodWithTestActionResult(int value) + public async Task AsyncActionMethodWithTestActionResult(int value) { return await Task.FromResult(new TestActionResult { Value = value }); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerBinderDelegateProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerBinderDelegateProviderTest.cs index 6b9b9041db..a666abd913 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerBinderDelegateProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ControllerBinderDelegateProviderTest.cs @@ -62,14 +62,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal var factory = GetModelBinderFactory(binder.Object); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var controller = new TestController(); - var parameterBinder = new ParameterBinder( + + var parameterBinder = GetParameterBinder( modelMetadataProvider, factory, - new DefaultObjectValidator( - modelMetadataProvider, - new[] { GetModelValidatorProvider(mockValidator.Object) }), - _optionsAccessor, - NullLoggerFactory.Instance); + GetModelValidatorProvider(mockValidator.Object)); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -115,18 +112,16 @@ namespace Microsoft.AspNetCore.Mvc.Internal var mockValidator = new Mock(MockBehavior.Strict); mockValidator.Setup(o => o.Validate(It.IsAny())); + + var validatorProvider = GetModelValidatorProvider(mockValidator.Object); var factory = GetModelBinderFactory(binder.Object); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var controller = new TestController(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, factory, - new DefaultObjectValidator( - modelMetadataProvider, - new[] { GetModelValidatorProvider(mockValidator.Object) }), - _optionsAccessor, - NullLoggerFactory.Instance); + GetModelValidatorProvider(mockValidator.Object)); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -170,14 +165,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var factory = GetModelBinderFactory(binder.Object); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); var controllerContext = GetControllerContext(actionDescriptor); var controller = new TestController(); @@ -217,14 +207,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var factory = GetModelBinderFactory(binder.Object); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); var controllerContext = GetControllerContext(actionDescriptor); var controller = new TestController(); @@ -272,14 +257,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var factory = GetModelBinderFactory(binder.Object); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); var controllerContext = GetControllerContext(actionDescriptor); var controller = new TestController(); @@ -329,14 +309,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal .Setup(p => p.GetMetadataForParameter(ParameterInfos.NoAttributesParameterInfo)) .Returns(modelMetadata.Object); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( mockMetadataProvider.Object, - factory, - new DefaultObjectValidator( - mockMetadataProvider.Object, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -382,14 +357,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal .Setup(p => p.GetMetadataForType(typeof(Person))) .Returns(modelMetadata.Object); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( mockMetadataProvider.Object, - factory, - new DefaultObjectValidator( - mockMetadataProvider.Object, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -431,14 +401,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal .Returns(new[] { new ModelValidationResult("memberName", "some message") }); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, factory, - new DefaultObjectValidator( - modelMetadataProvider, - new[] { GetModelValidatorProvider(mockValidator.Object) }), - _optionsAccessor, - NullLoggerFactory.Instance); + GetModelValidatorProvider(mockValidator.Object)); + var controller = new TestController(); var arguments = new Dictionary(StringComparer.Ordinal); @@ -489,9 +456,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var parameterBinder = new ParameterBinder( modelMetadataProvider, factory, - new DefaultObjectValidator( - modelMetadataProvider, - new[] { GetModelValidatorProvider(mockValidator.Object) }), + GetObjectValidator(modelMetadataProvider, GetModelValidatorProvider(mockValidator.Object)), Options.Create(mvcOptions), NullLoggerFactory.Instance); var controller = new TestController(); @@ -538,14 +503,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal var factory = GetModelBinderFactory(binder.Object); var controller = new TestController(); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + GetModelValidatorProvider(mockValidator.Object)); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -590,9 +551,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var parameterBinder = new ParameterBinder( modelMetadataProvider, factory, - new DefaultObjectValidator( - modelMetadataProvider, - new[] { GetModelValidatorProvider(mockValidator.Object) }), + GetObjectValidator(modelMetadataProvider, GetModelValidatorProvider(mockValidator.Object)), _optionsAccessor, NullLoggerFactory.Instance); @@ -689,9 +648,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var parameterBinder = new ParameterBinder( modelMetadataProvider, factory, - new DefaultObjectValidator( - modelMetadataProvider, - new[] { GetModelValidatorProvider(mockValidator.Object) }), + GetObjectValidator(modelMetadataProvider, GetModelValidatorProvider(mockValidator.Object)), _optionsAccessor, NullLoggerFactory.Instance); @@ -731,14 +688,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var factory = GetModelBinderFactory("Hello"); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -776,14 +728,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var expected = new List { "Hello", "World", "!!" }; var factory = GetModelBinderFactory(expected); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -821,14 +768,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var binder = new StubModelBinder(ModelBindingResult.Success(model: null)); var factory = GetModelBinderFactory(binder); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Some non default value. controller.NonNullableProperty = -1; @@ -867,14 +809,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var binder = new StubModelBinder(ModelBindingResult.Success(model: null)); var factory = GetModelBinderFactory(binder); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Some non default value. controller.NullableProperty = -1; @@ -934,14 +871,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var binder = new StubModelBinder(ModelBindingResult.Success(model: null)); var factory = GetModelBinderFactory(binder); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Some non default value. controller.NullableProperty = -1; @@ -1002,14 +934,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var binder = new StubModelBinder(ModelBindingResult.Success(model: null)); var factory = GetModelBinderFactory(binder); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Some non default value. controller.NullableProperty = -1; @@ -1094,14 +1021,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var factory = GetModelBinderFactory(inputValue); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -1174,14 +1096,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal controllerContext.ValueProviderFactories.Add(new SimpleValueProviderFactory()); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var parameterBinder = new ParameterBinder( + var parameterBinder = GetParameterBinder( modelMetadataProvider, - factory, - new DefaultObjectValidator( - modelMetadataProvider, - new IModelValidatorProvider[] { }), - _optionsAccessor, - NullLoggerFactory.Instance); + factory); // Act var binderDelegate = ControllerBinderDelegateProvider.CreateBinderDelegate( @@ -1278,7 +1195,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var parameterBinder = new Mock( new EmptyModelMetadataProvider(), factory, - new DefaultObjectValidator(modelMetadataProvider, new[] { modelValidatorProvider }), + GetObjectValidator(modelMetadataProvider, modelValidatorProvider), _optionsAccessor, NullLoggerFactory.Instance); parameterBinder.Setup(p => p.BindModelAsync( @@ -1415,16 +1332,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal } private static ParameterBinder GetParameterBinder( - IModelBinderFactory factory = null, - IObjectModelValidator validator = null, IModelMetadataProvider modelMetadataProvider = null, + IModelBinderFactory factory = null, IModelValidatorProvider modelValidatorProvider = null) { - if (validator == null) - { - validator = CreateObjectValidator(); - } - if (factory == null) { factory = TestModelBinderFactory.CreateDefault(); @@ -1436,9 +1347,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } var metadataProvider = modelMetadataProvider ?? TestModelMetadataProvider.CreateDefaultProvider(); - var objectModelValidator = new DefaultObjectValidator( - metadataProvider, - new[] { modelValidatorProvider }); + var objectModelValidator = GetObjectValidator(modelMetadataProvider, modelValidatorProvider); return new ParameterBinder( metadataProvider, @@ -1448,16 +1357,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal NullLoggerFactory.Instance); } - private static IObjectModelValidator CreateObjectValidator() + private static DefaultObjectValidator GetObjectValidator( + IModelMetadataProvider modelMetadataProvider, + IModelValidatorProvider validatorProvider) { - var mockValidator = new Mock(MockBehavior.Strict); - mockValidator - .Setup(o => o.Validate( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())); - return mockValidator.Object; + return new DefaultObjectValidator( + modelMetadataProvider, + new[] { validatorProvider }, + _options); } // No need for bind-related attributes on properties in this controller class. Properties are added directly diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultApplicationModelProviderTest.cs index c7f548efa8..98e358cabc 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultApplicationModelProviderTest.cs @@ -40,7 +40,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal public void OnProvidersExecuting_AddsControllerProperties() { // Arrange - var builder = new TestApplicationModelProvider(); + var builder = new TestApplicationModelProvider( + new MvcOptions { AllowValidatingTopLevelNodes = true }, + TestModelMetadataProvider.CreateDefaultProvider()); var typeInfo = typeof(ModelBinderController).GetTypeInfo(); var context = new ApplicationModelProviderContext(new[] { typeInfo }); @@ -84,7 +86,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var detailsProvider = new BindingSourceMetadataProvider(typeof(string), BindingSource.Services); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(new[] { detailsProvider }); var typeInfo = typeof(ModelBinderController).GetTypeInfo(); - var provider = new TestApplicationModelProvider(Options.Create(new MvcOptions()), modelMetadataProvider); + var provider = new TestApplicationModelProvider(new MvcOptions(), modelMetadataProvider); var context = new ApplicationModelProviderContext(new[] { typeInfo }); @@ -124,7 +126,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal public void OnProvidersExecuting_AddsBindingSources_ForActionParameters() { // Arrange - var builder = new TestApplicationModelProvider(); + var builder = new TestApplicationModelProvider( + new MvcOptions { AllowValidatingTopLevelNodes = true }, + TestModelMetadataProvider.CreateDefaultProvider()); var typeInfo = typeof(ModelBinderController).GetTypeInfo(); var context = new ApplicationModelProviderContext(new[] { typeInfo }); @@ -166,9 +170,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal public void OnProvidersExecuting_AddsBindingSources_ForActionParameters_WithLegacyValidationBehavior() { // Arrange - var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); - var options = Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = false }); - var builder = new TestApplicationModelProvider(options, modelMetadataProvider); + var builder = new TestApplicationModelProvider( + new MvcOptions(), + TestModelMetadataProvider.CreateDefaultProvider()); var typeInfo = typeof(ModelBinderController).GetTypeInfo(); var context = new ApplicationModelProviderContext(new[] { typeInfo }); @@ -207,6 +211,52 @@ namespace Microsoft.AspNetCore.Mvc.Internal }); } + [Fact] + public void OnProvidersExecuting_InfersFormFileSourceForTypesAssignableFromIEnumerableOfFormFiles() + { + // Arrange + var builder = new TestApplicationModelProvider( + new MvcOptions { AllowValidatingTopLevelNodes = true }, + TestModelMetadataProvider.CreateDefaultProvider()); + var typeInfo = typeof(ModelBinderController).GetTypeInfo(); + + var context = new ApplicationModelProviderContext(new[] { typeInfo }); + + // Act + builder.OnProvidersExecuting(context); + + // Assert + var controllerModel = Assert.Single(context.Result.Controllers); + var action = Assert.Single(controllerModel.Actions, a => a.ActionMethod.Name == nameof(ModelBinderController.FormFilesSequences)); + Assert.Collection( + action.Parameters, + parameter => + { + Assert.Equal("formFileEnumerable", parameter.ParameterName); + Assert.Equal(BindingSource.FormFile, parameter.BindingInfo.BindingSource); + }, + parameter => + { + Assert.Equal("formFileCollection", parameter.ParameterName); + Assert.Equal(BindingSource.FormFile, parameter.BindingInfo.BindingSource); + }, + parameter => + { + Assert.Equal("formFileIList", parameter.ParameterName); + Assert.Equal(BindingSource.FormFile, parameter.BindingInfo.BindingSource); + }, + parameter => + { + Assert.Equal("formFileList", parameter.ParameterName); + Assert.Equal(BindingSource.FormFile, parameter.BindingInfo.BindingSource); + }, + parameter => + { + Assert.Equal("formFileArray", parameter.ParameterName); + Assert.Equal(BindingSource.FormFile, parameter.BindingInfo.BindingSource); + }); + } + [Fact] public void OnProvidersExecuting_AddsBindingSources_ForActionParameters_ReadFromModelMetadata() { @@ -215,7 +265,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var detailsProvider = new BindingSourceMetadataProvider(typeof(Guid), BindingSource.Special); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(new[] { detailsProvider }); - var provider = new TestApplicationModelProvider(Options.Create(options), modelMetadataProvider); + var provider = new TestApplicationModelProvider(options, modelMetadataProvider); var typeInfo = typeof(ModelBinderController).GetTypeInfo(); var context = new ApplicationModelProviderContext(new[] { typeInfo }); @@ -243,7 +293,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var detailsProvider = new BindingSourceMetadataProvider(typeof(Guid), BindingSource.Special); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(new[] { detailsProvider }); - var provider = new TestApplicationModelProvider(Options.Create(options), modelMetadataProvider); + var provider = new TestApplicationModelProvider(options, modelMetadataProvider); var typeInfo = typeof(ModelBinderController).GetTypeInfo(); var context = new ApplicationModelProviderContext(new[] { typeInfo }); @@ -819,7 +869,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } [Fact] - public void CreateActionModel_AttributeRouteOnAction_CreatesOneActionInforPerRouteTemplate() + public void CreateActionModel_AttributeRouteOnAction_CreatesOneActionInfoPerRouteTemplate() { // Arrange var builder = new TestApplicationModelProvider(); @@ -901,7 +951,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal [Theory] [InlineData(typeof(SingleRouteAttributeController))] [InlineData(typeof(MultipleRouteAttributeController))] - public void CreateActionModel_RouteOnController_CreatesOneActionInforPerRouteTemplateOnAction(Type controller) + public void CreateActionModel_RouteOnController_CreatesOneActionInfoPerRouteTemplateOnAction(Type controller) { // Arrange var builder = new TestApplicationModelProvider(); @@ -1239,7 +1289,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } private class DerivedFromControllerAndExplicitIDisposableImplementationController - : Mvc.Controller, IDisposable + : ViewFeaturesController, IDisposable { void IDisposable.Dispose() { @@ -1247,7 +1297,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } - private class DerivedFromControllerAndHidesBaseDisposeMethodController : Mvc.Controller + private class DerivedFromControllerAndHidesBaseDisposeMethodController : ViewFeaturesController { public new void Dispose() { @@ -1255,6 +1305,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } + private class ViewFeaturesController : ControllerBase, IDisposable + { + public virtual void Dispose() + { + } + } + private class BaseClassWithAttributeRoutesController { [Route("A")] @@ -1614,6 +1671,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal public IActionResult PostAction([FromQuery] string fromQuery, IFormFileCollection formFileCollection, string unbound) => null; + public IActionResult FormFilesSequences( + IEnumerable formFileEnumerable, + ICollection formFileCollection, + IList formFileIList, + List formFileList, + IFormFile[] formFileArray) => null; + public IActionResult PostAction1(Guid guid) => null; public IActionResult PostAction2([FromQuery] Guid fromQuery) => null; @@ -1658,14 +1722,16 @@ namespace Microsoft.AspNetCore.Mvc.Internal private class TestApplicationModelProvider : DefaultApplicationModelProvider { public TestApplicationModelProvider() - : this(Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = true }), TestModelMetadataProvider.CreateDefaultProvider()) + : this( + new MvcOptions { AllowValidatingTopLevelNodes = true }, + new EmptyModelMetadataProvider()) { } public TestApplicationModelProvider( - IOptions options, + MvcOptions options, IModelMetadataProvider modelMetadataProvider) - : base(options, modelMetadataProvider) + : base(Options.Create(options), modelMetadataProvider) { } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultCollectionValidationStrategyTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultCollectionValidationStrategyTest.cs index 70a6fb110d..30f964b93d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultCollectionValidationStrategyTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultCollectionValidationStrategyTest.cs @@ -89,7 +89,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } [Fact] - public void EnumerateElements_TwoEnumerableImplemenations() + public void EnumerateElements_TwoEnumerableImplementations() { // Arrange var model = new TwiceEnumerable(new int[] { 2, 3, 5 }); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultObjectValidatorTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultObjectValidatorTests.cs index 5371633029..83cf64890a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultObjectValidatorTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultObjectValidatorTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; +using System.Reflection; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -22,7 +23,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal { public class DefaultObjectValidatorTests { - private IModelMetadataProvider MetadataProvider { get; } = TestModelMetadataProvider.CreateDefaultProvider(); + private readonly MvcOptions _options = new MvcOptions { AllowShortCircuitingValidationWhenNoValidatorsArePresent = true }; + + private ModelMetadataProvider MetadataProvider { get; } = TestModelMetadataProvider.CreateDefaultProvider(); [Fact] public void Validate_SimpleValueType_Valid_WithPrefix() @@ -133,6 +136,27 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Empty(entry.Errors); } + // More like how product code does suppressions than Validate_SimpleType_SuppressValidation() + [Fact] + public void Validate_SimpleType_SuppressValidationWithNullKey() + { + // Arrange + var actionContext = new ActionContext(); + var modelState = actionContext.ModelState; + var validator = CreateValidator(); + var model = "test"; + var validationState = new ValidationStateDictionary + { + { model, new ValidationStateEntry { SuppressValidation = true } } + }; + + // Act + validator.Validate(actionContext, validationState, "parameter", model); + + // Assert + Assert.True(modelState.IsValid); + Assert.Empty(modelState); + } [Fact] public void Validate_ComplexValueType_Valid() @@ -1146,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"); @@ -1168,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() { @@ -1195,6 +1225,207 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Empty(entry.Value.Errors); } + // Regression test for aspnet/Mvc#7992 + [Fact] + public void Validate_SuppressValidation_AfterHasReachedMaxErrors_Invalid() + { + // Arrange + var actionContext = new ActionContext(); + var modelState = actionContext.ModelState; + modelState.MaxAllowedErrors = 2; + modelState.AddModelError(key: "one", errorMessage: "1"); + modelState.AddModelError(key: "two", errorMessage: "2"); + + var validator = CreateValidator(); + var model = (object)23; // Box ASAP + var validationState = new ValidationStateDictionary + { + { model, new ValidationStateEntry { SuppressValidation = true } } + }; + + // Act + validator.Validate(actionContext, validationState, prefix: string.Empty, model); + + // Assert + Assert.False(modelState.IsValid); + Assert.True(modelState.HasReachedMaxErrors); + Assert.Collection( + modelState, + kvp => + { + Assert.Empty(kvp.Key); + Assert.Equal(ModelValidationState.Invalid, kvp.Value.ValidationState); + var error = Assert.Single(kvp.Value.Errors); + Assert.IsType(error.Exception); + }, + kvp => + { + Assert.Equal("one", kvp.Key); + Assert.Equal(ModelValidationState.Invalid, kvp.Value.ValidationState); + var error = Assert.Single(kvp.Value.Errors); + Assert.Equal("1", error.ErrorMessage); + }); + } + + [Fact] + public void Validate_Throws_IfValidationDepthExceedsMaxDepth() + { + // Arrange + var maxDepth = 5; + var expected = $"ValidationVisitor exceeded the maximum configured validation depth '{maxDepth}' when validating property '{nameof(DepthObject.Depth)}' on type '{typeof(DepthObject)}'. " + + "This may indicate a very deep or infinitely recursive object graph. Consider modifying 'MvcOptions.MaxValidationDepth' or suppressing validation on the model type."; + _options.MaxValidationDepth = maxDepth; + var actionContext = new ActionContext(); + var validator = CreateValidator(); + var model = new DepthObject(maxDepth); + var validationState = new ValidationStateDictionary + { + { model, new ValidationStateEntry() } + }; + + // Act & Assert + var ex = Assert.Throws(() => validator.Validate(actionContext, validationState, prefix: string.Empty, model)); + Assert.Equal(expected, ex.Message); + } + + [Fact] + public void Validate_WorksIfObjectGraphIsSmallerThanMaxDepth() + { + // Arrange + var maxDepth = 5; + _options.MaxValidationDepth = maxDepth; + var actionContext = new ActionContext(); + var validator = CreateValidator(); + var model = new DepthObject(maxDepth - 1); + var validationState = new ValidationStateDictionary + { + { model, new ValidationStateEntry() } + }; + + // Act & Assert + validator.Validate(actionContext, validationState, prefix: string.Empty, model); + Assert.True(actionContext.ModelState.IsValid); + } + + [Fact] + public void Validate_Throws_WithMaxDepth_1() + { + // Arrange + var maxDepth = 1; + var expected = $"ValidationVisitor exceeded the maximum configured validation depth '{maxDepth}' when validating property '{nameof(DepthObject.Depth)}' on type '{typeof(DepthObject)}'. " + + "This may indicate a very deep or infinitely recursive object graph. Consider modifying 'MvcOptions.MaxValidationDepth' or suppressing validation on the model type."; + _options.MaxValidationDepth = maxDepth; + var actionContext = new ActionContext(); + var validator = CreateValidator(); + var model = new DepthObject(maxDepth + 1); + var validationState = new ValidationStateDictionary + { + { model, new ValidationStateEntry() } + }; + var method = GetType().GetMethod(nameof(Validate_Throws_ForTopLevelMetadataData), BindingFlags.NonPublic | BindingFlags.Instance); + + // Act & Assert + var ex = Assert.Throws(() => validator.Validate(actionContext, validationState, prefix: string.Empty, model)); + Assert.Equal(expected, ex.Message); + 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(); @@ -1205,14 +1436,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(excludeFilters.ToArray()); var validatorProviders = TestModelValidatorProvider.CreateDefaultProvider().ValidatorProviders; - return new DefaultObjectValidator(metadataProvider, validatorProviders); + return new DefaultObjectValidator(metadataProvider, validatorProviders, new MvcOptions()); } - private static DefaultObjectValidator CreateValidator(params IMetadataDetailsProvider[] providers) + private DefaultObjectValidator CreateValidator(params IMetadataDetailsProvider[] providers) { var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(providers); var validatorProviders = TestModelValidatorProvider.CreateDefaultProvider().ValidatorProviders; - return new DefaultObjectValidator(metadataProvider, validatorProviders); + return new DefaultObjectValidator(metadataProvider, validatorProviders, _options); } private static void AssertKeysEqual(ModelStateDictionary modelState, params string[] keys) @@ -1222,6 +1453,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal private class ThrowingProperty { + [Required] public string WatchOut { get @@ -1335,6 +1567,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal void DoSomething(); } + private void Validate_Throws_ForTopLevelMetadataData(DepthObject depthObject) { } + // Custom validation attribute that returns multiple entries in ValidationResult.MemberNames and those member // names are indexers. An example scenario is an attribute that confirms all entries in a list are unique. private class InvalidItemsAttribute : ValidationAttribute @@ -1379,5 +1613,31 @@ namespace Microsoft.AspNetCore.Mvc.Internal { public string City { get; set; } } + + private class DepthObject + { + public DepthObject(int maxAllowedDepth, int depth = 0) + { + MaxAllowedDepth = maxAllowedDepth; + Depth = depth; + } + + [Range(-10, 400)] + public int Depth { get; } + public int MaxAllowedDepth { get; } + + public DepthObject Instance + { + get + { + if (Depth == MaxAllowedDepth - 1) + { + return this; + } + + return new DepthObject(MaxAllowedDepth, Depth + 1); + } + } + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DisableRequestSizeLimitFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DisableRequestSizeLimitFilterTest.cs index 4340460963..6e8907f1a5 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DisableRequestSizeLimitFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DisableRequestSizeLimitFilterTest.cs @@ -19,7 +19,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { // Arrange var disableRequestSizeLimitResourceFilter = new DisableRequestSizeLimitFilter(NullLoggerFactory.Instance); - var authorizationFilterContext = CreateauthorizationFilterContext(new IFilterMetadata[] { disableRequestSizeLimitResourceFilter }); + var authorizationFilterContext = CreateAuthorizationFilterContext(new IFilterMetadata[] { disableRequestSizeLimitResourceFilter }); var httpMaxRequestBodySize = new TestHttpMaxRequestBodySizeFeature(); authorizationFilterContext.HttpContext.Features.Set(httpMaxRequestBodySize); @@ -37,7 +37,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Arrange var disableRequestSizeLimitResourceFilter = new DisableRequestSizeLimitFilter(NullLoggerFactory.Instance); var disableRequestSizeLimitResourceFilterFinal = new DisableRequestSizeLimitFilter(NullLoggerFactory.Instance); - var authorizationFilterContext = CreateauthorizationFilterContext( + var authorizationFilterContext = CreateAuthorizationFilterContext( new IFilterMetadata[] { disableRequestSizeLimitResourceFilter, disableRequestSizeLimitResourceFilterFinal }); var httpMaxRequestBodySize = new TestHttpMaxRequestBodySizeFeature(); @@ -60,7 +60,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var loggerFactory = new TestLoggerFactory(sink, enabled: true); var disableRequestSizeLimitResourceFilter = new DisableRequestSizeLimitFilter(loggerFactory); - var authorizationFilterContext = CreateauthorizationFilterContext(new IFilterMetadata[] { disableRequestSizeLimitResourceFilter }); + var authorizationFilterContext = CreateAuthorizationFilterContext(new IFilterMetadata[] { disableRequestSizeLimitResourceFilter }); // Act disableRequestSizeLimitResourceFilter.OnAuthorization(authorizationFilterContext); @@ -79,7 +79,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var loggerFactory = new TestLoggerFactory(sink, enabled: true); var disableRequestSizeLimitResourceFilter = new DisableRequestSizeLimitFilter(loggerFactory); - var authorizationFilterContext = CreateauthorizationFilterContext(new IFilterMetadata[] { disableRequestSizeLimitResourceFilter }); + var authorizationFilterContext = CreateAuthorizationFilterContext(new IFilterMetadata[] { disableRequestSizeLimitResourceFilter }); var httpMaxRequestBodySize = new TestHttpMaxRequestBodySizeFeature(); httpMaxRequestBodySize.IsReadOnly = true; @@ -101,7 +101,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var loggerFactory = new TestLoggerFactory(sink, enabled: true); var disableRequestSizeLimitResourceFilter = new DisableRequestSizeLimitFilter(loggerFactory); - var authorizationFilterContext = CreateauthorizationFilterContext(new IFilterMetadata[] { disableRequestSizeLimitResourceFilter }); + var authorizationFilterContext = CreateAuthorizationFilterContext(new IFilterMetadata[] { disableRequestSizeLimitResourceFilter }); var httpMaxRequestBodySize = new TestHttpMaxRequestBodySizeFeature(); authorizationFilterContext.HttpContext.Features.Set(httpMaxRequestBodySize); @@ -114,7 +114,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Equal($"The request body size limit has been disabled.", write.State.ToString()); } - private static AuthorizationFilterContext CreateauthorizationFilterContext(IFilterMetadata[] filters) + private static AuthorizationFilterContext CreateAuthorizationFilterContext(IFilterMetadata[] filters) { return new AuthorizationFilterContext(CreateActionContext(), filters); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ExplicitIndexCollectionValidationStrategyTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ExplicitIndexCollectionValidationStrategyTest.cs index deeb9f74ae..cf1fecf8f3 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ExplicitIndexCollectionValidationStrategyTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ExplicitIndexCollectionValidationStrategyTest.cs @@ -90,7 +90,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } [Fact] - public void EnumerateElements_TwoEnumerableImplemenations() + public void EnumerateElements_TwoEnumerableImplementations() { // Arrange var model = new TwiceEnumerable(new int[] { 2, 3, 5 }); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MiddlewareFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MiddlewareFilterTest.cs index e9c770e27a..5edff31ab3 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MiddlewareFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MiddlewareFilterTest.cs @@ -16,7 +16,6 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; @@ -37,13 +36,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal { // Arrange Task requestDelegate(HttpContext context) => Task.FromResult(true); - var middlwareFilter = new MiddlewareFilter(requestDelegate); + var middlewareFilter = new MiddlewareFilter(requestDelegate); var httpContext = new DefaultHttpContext(); var resourceExecutingContext = GetResourceExecutingContext(httpContext); var resourceExecutionDelegate = GetResourceExecutionDelegate(httpContext); // Act - await middlwareFilter.OnResourceExecutionAsync(resourceExecutingContext, resourceExecutionDelegate); + await middlewareFilter.OnResourceExecutionAsync(resourceExecutingContext, resourceExecutionDelegate); // Assert var feature = resourceExecutingContext.HttpContext.Features.Get(); @@ -399,7 +398,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal logger, diagnosticSource, mapper, - CreatControllerContext(actionContext, valueProviderFactories, maxAllowedErrorsInModelState), + CreateControllerContext(actionContext, valueProviderFactories, maxAllowedErrorsInModelState), CreateCacheEntry((ControllerActionDescriptor)actionContext.ActionDescriptor, controllerFactory), filters) { @@ -421,7 +420,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal return ObjectMethodExecutor.Create(actionDescriptor.MethodInfo, actionDescriptor.ControllerTypeInfo); } - private static ControllerContext CreatControllerContext( + private static ControllerContext CreateControllerContext( ActionContext actionContext, IReadOnlyList valueProviderFactories, int maxAllowedErrorsInModelState) @@ -450,57 +449,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } - private class TestParameterBinder : ParameterBinder - { - private readonly IDictionary _actionParameters; - - public TestParameterBinder(IDictionary actionParameters) - : base( - new EmptyModelMetadataProvider(), - TestModelBinderFactory.CreateDefault(), - Mock.Of(), - Options.Create(new MvcOptions - { - AllowValidatingTopLevelNodes = true, - }), - NullLoggerFactory.Instance) - { - _actionParameters = actionParameters; - } - - public override Task BindModelAsync( - ActionContext actionContext, - IValueProvider valueProvider, - ParameterDescriptor parameter, - object value) - { - if (_actionParameters.TryGetValue(parameter.Name, out var result)) - { - return Task.FromResult(ModelBindingResult.Success(result)); - } - - return Task.FromResult(ModelBindingResult.Failed()); - } - - public Task BindArgumentsAsync( - ControllerContext controllerContext, - object controller, - IDictionary arguments) - { - foreach (var entry in _actionParameters) - { - arguments.Add(entry.Key, entry.Value); - } - - return Task.CompletedTask; - } - } - private sealed class TestController { } - private enum TestResourceFilterAction { ShortCircuit, diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcCoreLoggerExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcCoreLoggerExtensionsTest.cs index 8835d2da8e..2b56c00572 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcCoreLoggerExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcCoreLoggerExtensionsTest.cs @@ -112,9 +112,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal var asyncResultFilter = Mock.Of(); var resourceFilter = Mock.Of(); var asyncResourceFilter = Mock.Of(); - var orderedresourceFilterMock = new Mock(); - orderedresourceFilterMock.SetupGet(f => f.Order).Returns(-100); - var orderedResourceFilter = orderedresourceFilterMock.Object; + var orderedResourceFilterMock = new Mock(); + orderedResourceFilterMock.SetupGet(f => f.Order).Returns(-100); + var orderedResourceFilter = orderedResourceFilterMock.Object; var filters = new IFilterMetadata[] { actionFilter, diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs new file mode 100644 index 0000000000..a3e0ebe5c3 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs @@ -0,0 +1,1467 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + public class MvcEndpointDataSourceTests + { + [Fact] + public void Endpoints_AccessParameters_InitializedFromProvider() + { + // Arrange + var routeValue = "Value"; + var requiredValues = new Dictionary + { + ["Name"] = routeValue + }; + var displayName = "DisplayName!"; + var order = 1; + var template = "/Template!"; + var filterDescriptor = new FilterDescriptor(new ControllerActionFilter(), 1); + + var mockDescriptorProvider = new Mock(); + mockDescriptorProvider.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + new ActionDescriptor + { + RouteValues = requiredValues, + DisplayName = displayName, + AttributeRouteInfo = new AttributeRouteInfo + { + Order = order, + Template = template + }, + FilterDescriptors = new List + { + filterDescriptor + } + } + }, 0)); + + var dataSource = CreateMvcEndpointDataSource(mockDescriptorProvider.Object); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + var endpoint = Assert.Single(endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + + var routeValuesAddressMetadata = matcherEndpoint.Metadata.GetMetadata(); + Assert.NotNull(routeValuesAddressMetadata); + var endpointValue = routeValuesAddressMetadata.RequiredValues["Name"]; + Assert.Equal(routeValue, endpointValue); + + Assert.Equal(displayName, matcherEndpoint.DisplayName); + Assert.Equal(order, matcherEndpoint.Order); + Assert.Equal("Template!", matcherEndpoint.RoutePattern.RawText); + } + + [Fact] + public async Task Endpoints_InvokeReturnedEndpoint_ActionInvokerProviderCalled() + { + // Arrange + var endpointFeature = new EndpointSelectorContext + { + RouteValues = new RouteValueDictionary() + }; + + var featureCollection = new FeatureCollection(); + featureCollection.Set(endpointFeature); + featureCollection.Set(endpointFeature); + featureCollection.Set(endpointFeature); + + var httpContextMock = new Mock(); + httpContextMock.Setup(m => m.Features).Returns(featureCollection); + + var descriptorProviderMock = new Mock(); + descriptorProviderMock.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + new ActionDescriptor + { + AttributeRouteInfo = new AttributeRouteInfo + { + Template = string.Empty + }, + FilterDescriptors = new List() + } + }, 0)); + + var actionInvokerCalled = false; + var actionInvokerMock = new Mock(); + actionInvokerMock.Setup(m => m.InvokeAsync()).Returns(() => + { + actionInvokerCalled = true; + return Task.CompletedTask; + }); + + var actionInvokerProviderMock = new Mock(); + actionInvokerProviderMock.Setup(m => m.CreateInvoker(It.IsAny())).Returns(actionInvokerMock.Object); + + var dataSource = CreateMvcEndpointDataSource( + descriptorProviderMock.Object, + new MvcEndpointInvokerFactory(actionInvokerProviderMock.Object)); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + var endpoint = Assert.Single(endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + + await matcherEndpoint.RequestDelegate(httpContextMock.Object); + + Assert.True(actionInvokerCalled); + } + + public static TheoryData GetSingleActionData_Conventional + { + get => GetSingleActionData(true); + } + + public static TheoryData GetSingleActionData_Attribute + { + get => GetSingleActionData(false); + } + + private static TheoryData GetSingleActionData(bool isConventionalRouting) + { + var data = new TheoryData + { + {"{controller}/{action}/{id?}", null, new[] { "TestController/TestAction/{id?}" }}, + {"{controller}/{id?}", null, isConventionalRouting ? new string[] { } : new[] { "TestController/{id?}" }}, + {"{action}/{id?}", null, isConventionalRouting ? new string[] { } : new[] { "TestAction/{id?}" }}, + {"{Controller}/{Action}/{id?}", null, new[] { "TestController/TestAction/{id?}" }}, + {"{Controller}/{Action}/{id?}/{more?}", null, new[] { "TestController/TestAction/{id?}/{more?}" }}, + {"{CONTROLLER}/{ACTION}/{id?}", null, new[] { "TestController/TestAction/{id?}" }}, + {"{controller}/{action=TestAction}", "TestController/{action=TestAction}", new[] { "TestController", "TestController/TestAction" }}, + {"{controller}/{action=TestAction}/{id?}", "TestController/{action=TestAction}/{id?}", new[] { "TestController", "TestController/TestAction/{id?}" }}, + {"{controller}/{action=TESTACTION}/{id?}", "TestController/{action=TESTACTION}/{id?}", new[] { "TestController", "TestController/TESTACTION/{id?}" }}, + {"{controller}/{action=TestAction}/{id?}/{more}", null, new[] { "TestController/TestAction/{id?}/{more}" }}, + {"{controller=TestController}/{action=TestAction}/{id?}", "{controller=TestController}/{action=TestAction}/{id?}", new[] { "", "TestController", "TestController/TestAction/{id?}" }}, + {"{controller=TestController}/{action=TestAction}/{id?}/{more?}", "{controller=TestController}/{action=TestAction}/{id?}/{more?}", new[] { "", "TestController", "TestController/TestAction/{id?}/{more?}" }}, + {"{controller}/{action}/{*catchAll}", null, new[] { "TestController/TestAction/{*catchAll}" }}, + {"{controller}/{action=TestAction}/{*catchAll}", "TestController/{action=TestAction}/{*catchAll}", new[] { "TestController", "TestController/TestAction/{*catchAll}" }}, + {"{controller}/{action=TestAction}/{id?}/{*catchAll}", "TestController/{action=TestAction}/{id?}/{*catchAll}", new[] { "TestController", "TestController/TestAction/{id?}/{*catchAll}" }}, + {"{controller}/{action=TestAction}/{id?}/{**catchAll}", "TestController/{action=TestAction}/{id?}/{**catchAll}", new[] { "TestController", "TestController/TestAction/{id?}/{**catchAll}" }}, + {"{controller}/{action}.{ext?}", null, new[] { "TestController/TestAction.{ext?}" }}, + {"{controller}/{action=TestAction}.{ext?}", "TestController/{action=TestAction}.{ext?}", new[] { "TestController", "TestController/TestAction.{ext?}" }}, + {"{controller}/{action=TestAction}.{ext?}/{more?}", "TestController/{action=TestAction}.{ext?}/{more?}", new[] { "TestController", "TestController/TestAction.{ext?}/{more?}" }}, + {"{controller}/{action=TestAction}.{ext?}/{more}", null, new[] { "TestController/TestAction.{ext?}/{more}" }}, + {"{controller:upper-case}/{action:upper-case=TestAction}.{ext?}", "TESTCONTROLLER/{action:upper-case=TestAction}.{ext?}", new[] { "TESTCONTROLLER", "TESTCONTROLLER/TESTACTION.{ext?}" }}, + }; + + return data; + } + + [Theory] + [MemberData(nameof(GetSingleActionData_Conventional))] + public void Endpoints_Conventional_SingleAction(string endpointInfoRoute, string suppressMatchingTemplate, string[] finalEndpointPatterns) + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, endpointInfoRoute)); + + // Act + var endpoints = dataSource.Endpoints.ToList(); + + // Assert + + // Ensure there are no endpoints with duplicate Order values + Assert.DoesNotContain(endpoints.GroupBy(e => Assert.IsType(e).Order), g => g.Count() > 1); + + endpoints = endpoints.OrderBy(e => Assert.IsType(e).Order).ToList(); + + AssertSuppressMatchingTemplate(suppressMatchingTemplate, endpoints); + + var inspectors = finalEndpointPatterns + .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).RoutePattern.RawText))) + .ToArray(); + + // Assert + Assert.Collection(endpoints, inspectors); + } + + [Theory] + [MemberData(nameof(GetSingleActionData_Attribute))] + public void Endpoints_AttributeRouting_SingleAction(string endpointInfoRoute, string suppressMatchingTemplate, string[] finalEndpointPatterns) + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + attributeRouteTemplate: endpointInfoRoute, + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + // Act + var endpoints = dataSource.Endpoints.ToList(); + + // Ensure there are no endpoints with duplicate Order values + Assert.DoesNotContain(endpoints.GroupBy(e => Assert.IsType(e).Order), g => g.Count() > 1); + + endpoints = endpoints.OrderBy(e => Assert.IsType(e).Order).ToList(); + + AssertSuppressMatchingTemplate(suppressMatchingTemplate, endpoints); + + // Assert + var inspectors = finalEndpointPatterns + .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).RoutePattern.RawText))) + .ToArray(); + + // Assert + Assert.Collection(endpoints, inspectors); + } + + [Theory] + [InlineData("{area}/{controller}/{action}/{id?}", null, new[] { "TestArea/TestController/TestAction/{id?}" })] + [InlineData("{controller}/{action}/{id?}", null, new string[] { })] + [InlineData("{area=TestArea}/{controller}/{action}/{id?}", null, new[] { "TestArea/TestController/TestAction/{id?}" })] + [InlineData("{area=TestArea}/{controller}/{action=TestAction}/{id?}", "TestArea/TestController/{action=TestAction}/{id?}", new[] { "TestArea/TestController", "TestArea/TestController/TestAction/{id?}"})] + [InlineData("{area=TestArea}/{controller=TestController}/{action=TestAction}/{id?}", "{area=TestArea}/{controller=TestController}/{action=TestAction}/{id?}", new[] { "", "TestArea", "TestArea/TestController", "TestArea/TestController/TestAction/{id?}" })] + [InlineData("{area:exists}/{controller}/{action}/{id?}", null, new[] { "TestArea/TestController/TestAction/{id?}" })] + [InlineData("{area:exists:upper-case}/{controller}/{action}/{id?}", null, new[] { "TESTAREA/TestController/TestAction/{id?}" })] + public void Endpoints_AreaSingleAction(string endpointInfoRoute, string suppressMatchingTemplate, string[] finalEndpointTemplates) + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction", area = "TestArea" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + var services = new ServiceCollection(); + services.AddRouting(); + services.AddSingleton(actionDescriptorCollection); + + var routeOptionsSetup = new MvcCoreRouteOptionsSetup(); + services.Configure(routeOptionsSetup.Configure); + services.Configure(options => + { + options.ConstraintMap["upper-case"] = typeof(UpperCaseParameterTransform); + }); + + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, endpointInfoRoute, serviceProvider: services.BuildServiceProvider())); + + // Act + var endpoints = dataSource.Endpoints.ToList(); + + // Assert + + // Ensure there are no endpoints with duplicate Order values + Assert.DoesNotContain(endpoints.GroupBy(e => Assert.IsType(e).Order), g => g.Count() > 1); + + endpoints = endpoints.OrderBy(e => Assert.IsType(e).Order).ToList(); + + AssertSuppressMatchingTemplate(suppressMatchingTemplate, endpoints); + + var inspectors = finalEndpointTemplates + .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).RoutePattern.RawText))) + .ToArray(); + + // Assert + Assert.Collection(endpoints, inspectors); + } + + private static void AssertSuppressMatchingTemplate(string suppressMatchingTemplate, List endpoints) + { + if (suppressMatchingTemplate != null) + { + var suppressMatchingEndpoint = endpoints.First(); + Assert.True(suppressMatchingEndpoint.Metadata.GetMetadata()?.SuppressMatching); + Assert.Equal(suppressMatchingTemplate, Assert.IsType(suppressMatchingEndpoint).RoutePattern.RawText); + endpoints.Remove(suppressMatchingEndpoint); + } + } + + [Fact] + public void Endpoints_SingleAction_ConventionalRoute_ContainsParameterWithNullRequiredRouteValue() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction", page = (string)null }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + "{controller}/{action}/{page}", + new RouteValueDictionary(new { action = "TestAction" }))); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Empty(endpoints); + } + + [Fact] + public void Endpoints_SingleAction_AttributeRoute_ContainsParameterWithNullRequiredRouteValue() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + "{controller}/{action}/{page}", + new { controller = "TestController", action = "TestAction", page = (string)null }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection(endpoints, + (e) => Assert.Equal("TestController/TestAction/{page}", Assert.IsType(e).RoutePattern.RawText)); + } + + [Fact] + public void Endpoints_SingleAction_WithActionDefault() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + "{controller}/{action}", + new RouteValueDictionary(new { action = "TestAction" }))); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection(endpoints, + (e) => + { + Assert.Equal("TestController/{action=TestAction}", Assert.IsType(e).RoutePattern.RawText); + Assert.True(e.Metadata.GetMetadata().SuppressMatching); + }, + (e) => Assert.Equal("TestController", Assert.IsType(e).RoutePattern.RawText), + (e) => Assert.Equal("TestController/TestAction", Assert.IsType(e).RoutePattern.RawText)); + } + + [Fact] + public void Endpoints_CalledMultipleTimes_ReturnsSameInstance() + { + // Arrange + var actionDescriptorCollectionProviderMock = new Mock(); + actionDescriptorCollectionProviderMock + .Setup(m => m.ActionDescriptors) + .Returns(new ActionDescriptorCollection(new[] + { + CreateActionDescriptor(new { controller = "TestController", action = "TestAction" }) + }, version: 0)); + + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollectionProviderMock.Object); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + "{controller}/{action}", + new RouteValueDictionary(new { action = "TestAction" }))); + + // Act + var endpoints1 = dataSource.Endpoints; + var endpoints2 = dataSource.Endpoints; + + // Assert + Assert.Collection(endpoints1, + (e) => Assert.Equal("TestController/{action=TestAction}", Assert.IsType(e).RoutePattern.RawText), + (e) => Assert.Equal("TestController", Assert.IsType(e).RoutePattern.RawText), + (e) => Assert.Equal("TestController/TestAction", Assert.IsType(e).RoutePattern.RawText)); + Assert.Same(endpoints1, endpoints2); + + actionDescriptorCollectionProviderMock.VerifyGet(m => m.ActionDescriptors, Times.Once); + } + + [Fact] + public void Endpoints_ChangeTokenTriggered_EndpointsRecreated() + { + // Arrange + var actionDescriptorCollectionProviderMock = new Mock(); + actionDescriptorCollectionProviderMock + .Setup(m => m.ActionDescriptors) + .Returns(new ActionDescriptorCollection(new[] + { + CreateActionDescriptor(new { controller = "TestController", action = "TestAction" }) + }, version: 0)); + + CancellationTokenSource cts = null; + actionDescriptorCollectionProviderMock + .Setup(m => m.GetChangeToken()) + .Returns(() => + { + cts = new CancellationTokenSource(); + var changeToken = new CancellationChangeToken(cts.Token); + + return changeToken; + }); + + + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollectionProviderMock.Object); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + "{controller}/{action}", + new RouteValueDictionary(new { action = "TestAction" }))); + + // Act + var endpoints = dataSource.Endpoints; + + Assert.Collection(endpoints, + (e) => Assert.Equal("TestController/{action=TestAction}", Assert.IsType(e).RoutePattern.RawText), + (e) => Assert.Equal("TestController", Assert.IsType(e).RoutePattern.RawText), + (e) => Assert.Equal("TestController/TestAction", Assert.IsType(e).RoutePattern.RawText)); + + actionDescriptorCollectionProviderMock + .Setup(m => m.ActionDescriptors) + .Returns(new ActionDescriptorCollection(new[] + { + CreateActionDescriptor(new { controller = "NewTestController", action = "NewTestAction" }) + }, version: 1)); + + cts.Cancel(); + + // Assert + var newEndpoints = dataSource.Endpoints; + + Assert.NotSame(endpoints, newEndpoints); + Assert.Collection(newEndpoints, + (e) => Assert.Equal("NewTestController/NewTestAction", Assert.IsType(e).RoutePattern.RawText)); + } + + [Fact] + public void Endpoints_MultipleActions_WithActionConstraint() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }, + new { controller = "TestController", action = "TestAction1" }, + new { controller = "TestController", action = "TestAction2" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + "{controller}/{action}", + constraints: new RouteValueDictionary(new { action = "(TestAction1|TestAction2)" }))); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection(endpoints, + (e) => Assert.Equal("TestController/TestAction1", Assert.IsType(e).RoutePattern.RawText), + (e) => Assert.Equal("TestController/TestAction2", Assert.IsType(e).RoutePattern.RawText)); + } + + [Theory] + [InlineData("{controller}/{action}", new[] { "TestController1/TestAction1", "TestController1/TestAction2", "TestController1/TestAction3", "TestController2/TestAction1" })] + [InlineData("{controller}/{action:regex((TestAction1|TestAction2))}", new[] { "TestController1/TestAction1", "TestController1/TestAction2", "TestController2/TestAction1" })] + [InlineData("{controller}/{action:regex((TestAction1|TestAction2)):upper-case}", new[] { "TestController1/TESTACTION1", "TestController1/TESTACTION2", "TestController2/TESTACTION1" })] + public void Endpoints_MultipleActions(string endpointInfoRoute, string[] finalEndpointTemplates) + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController1", action = "TestAction1" }, + new { controller = "TestController1", action = "TestAction2" }, + new { controller = "TestController1", action = "TestAction3" }, + new { controller = "TestController2", action = "TestAction1" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + endpointInfoRoute)); + + // Act + var endpoints = dataSource.Endpoints; + + var inspectors = finalEndpointTemplates + .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).RoutePattern.RawText))) + .ToArray(); + + // Assert + Assert.Collection(endpoints, inspectors); + } + + [Fact] + public void Endpoints_ConventionalRoute_WithEmptyRouteName_CreatesMetadataWithEmptyRouteName() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "Home", action = "Index" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add( + CreateEndpointInfo(string.Empty, "named/{controller}/{action}/{id?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + var endpoint = Assert.Single(endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + var routeValuesAddressNameMetadata = matcherEndpoint.Metadata.GetMetadata(); + Assert.NotNull(routeValuesAddressNameMetadata); + Assert.Equal(string.Empty, routeValuesAddressNameMetadata.RouteName); + } + + [Fact] + public void Endpoints_CanCreateMultipleEndpoints_WithSameRouteName() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "Home", action = "Index" }, + new { controller = "Products", action = "Details" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add( + CreateEndpointInfo("namedRoute", "named/{controller}/{action}/{id?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + var routeValuesAddressMetadata = matcherEndpoint.Metadata.GetMetadata(); + Assert.NotNull(routeValuesAddressMetadata); + Assert.Equal("namedRoute", routeValuesAddressMetadata.RouteName); + Assert.Equal("named/Home/Index/{id?}", matcherEndpoint.RoutePattern.RawText); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + var routeValuesAddressMetadata = matcherEndpoint.Metadata.GetMetadata(); + Assert.NotNull(routeValuesAddressMetadata); + Assert.Equal("namedRoute", routeValuesAddressMetadata.RouteName); + Assert.Equal("named/Products/Details/{id?}", matcherEndpoint.RoutePattern.RawText); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_StaticallyDefinedOrder_IsMaintained() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "Home", action = "Index" }, + new { controller = "Products", action = "Details" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller}/{action}/{id?}")); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: "namedRoute", + "named/{controller}/{action}/{id?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home/Index/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("named/Home/Index/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Products/Details/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("named/Products/Details/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + }); + } + + [Fact] + public void RequiredValue_WithNoCorresponding_TemplateParameter_DoesNotProduceEndpoint() + { + // Arrange + var requiredValues = new RouteValueDictionary(new { area = "admin", controller = "home", action = "index" }); + var actionDescriptorCollection = GetActionDescriptorCollection(requiredValues); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, "{controller}/{action}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Empty(endpoints); + } + + // area, controller, action and page are special, but not hardcoded. Actions can define custom required + // route values. This has been used successfully for localization, versioning and similar schemes. We should + // be able to replace custom route values too. + [Fact] + public void NonReservedRequiredValue_WithNoCorresponding_TemplateParameter_DoesNotProduceEndpoint() + { + // Arrange + var action1 = new RouteValueDictionary(new { controller = "home", action = "index", locale = "en-NZ" }); + var action2 = new RouteValueDictionary(new { controller = "home", action = "about", locale = "en-CA" }); + var action3 = new RouteValueDictionary(new { controller = "home", action = "index", locale = (string)null }); + + var actionDescriptorCollection = GetActionDescriptorCollection(action1, action2, action3); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + // Adding a localized route a non-localized route + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, "{locale}/{controller}/{action}")); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, "{controller}/{action}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints.Cast().OrderBy(e => e.RoutePattern.RawText), + e => Assert.Equal("en-CA/home/about", e.RoutePattern.RawText), + e => Assert.Equal("en-NZ/home/index", e.RoutePattern.RawText), + e => Assert.Equal("home/index", e.RoutePattern.RawText)); + } + + [Fact] + public void TemplateParameter_WithNoDefaultOrRequiredValue_DoesNotProduceEndpoint() + { + // Arrange + var requiredValues = new RouteValueDictionary(new { controller = "home", action = "index", area = (string)null }); + var actionDescriptorCollection = GetActionDescriptorCollection(requiredValues); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, "{area}/{controller}/{action}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Empty(endpoints); + } + + [Fact] + public void TemplateParameter_WithDefaultValue_AndNullRequiredValue_DoesNotProduceEndpoint() + { + // Arrange + var requiredValues = new RouteValueDictionary(new { area = (string)null, controller = "home", action = "index" }); + var actionDescriptorCollection = GetActionDescriptorCollection(requiredValues); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, "{area=admin}/{controller}/{action}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Empty(endpoints); + } + + [Fact] + public void TemplateParameter_WithNullRequiredValue_DoesNotProduceEndpoint() + { + // Arrange + var requiredValues = new RouteValueDictionary(new { area = (string)null, controller = "home", action = "index" }); + var actionDescriptorCollection = GetActionDescriptorCollection(requiredValues); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, "{area}/{controller}/{action}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Empty(endpoints); + } + + [Fact] + public void NoDefaultValues_RequiredValues_UsedToCreateDefaultValues() + { + // Arrange + var expectedDefaults = new RouteValueDictionary(new { controller = "Foo", action = "Bar" }); + var actionDescriptorCollection = GetActionDescriptorCollection(requiredValues: expectedDefaults); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, "{controller}/{action}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + var endpoint = Assert.Single(endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + Assert.Equal("Foo/Bar", matcherEndpoint.RoutePattern.RawText); + AssertIsSubset(expectedDefaults, matcherEndpoint.RoutePattern.Defaults); + } + + [Fact] + public void RequiredValues_NotPresent_InDefaultValues_IsAddedToDefaultValues() + { + // Arrange + var requiredValues = new RouteValueDictionary( + new { controller = "Foo", action = "Bar", subarea = "test" }); + var expectedDefaults = requiredValues; + var actionDescriptorCollection = GetActionDescriptorCollection(requiredValues: requiredValues); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add( + CreateEndpointInfo(string.Empty, "{subarea}/{controller=Home}/{action=Index}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + var endpoint = Assert.Single(endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + Assert.Equal("test/Foo/Bar", matcherEndpoint.RoutePattern.RawText); + AssertIsSubset(expectedDefaults, matcherEndpoint.RoutePattern.Defaults); + } + + [Fact] + public void RequiredValues_NotPresent_InDefaultValuesOrParameter_EndpointNotCreated() + { + // Arrange + var requiredValues = new RouteValueDictionary( + new { controller = "Foo", action = "Bar", subarea = "test" }); + var expectedDefaults = requiredValues; + var actionDescriptorCollection = GetActionDescriptorCollection(requiredValues: requiredValues); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add( + CreateEndpointInfo(string.Empty, "{controller=Home}/{action=Index}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Empty(endpoints); + } + + [Fact] + public void RequiredValues_IsSubsetOf_DefaultValues() + { + // Arrange + var requiredValues = new RouteValueDictionary( + new { controller = "Foo", action = "Bar", subarea = "test" }); + var expectedDefaults = new RouteValueDictionary( + new { controller = "Foo", action = "Bar", subarea = "test", subscription = "general" }); + var actionDescriptorCollection = GetActionDescriptorCollection(requiredValues); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add( + CreateEndpointInfo( + string.Empty, + "{controller=Home}/{action=Index}/{subscription=general}", + defaults: new RouteValueDictionary(new { subarea = "test", }))); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Foo/Bar/{subscription=general}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Foo/Bar", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Foo/Bar/{subscription=general}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void RequiredValues_DoesNotMatchParameterDefaults_Included() + { + // Arrange + var action = new RouteValueDictionary( + new { controller = "Foo", action = "Baz", }); // Doesn't match default + var expectedDefaults = new RouteValueDictionary( + new { controller = "Foo", action = "Baz", }); + var actionDescriptorCollection = GetActionDescriptorCollection(action); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add( + CreateEndpointInfo( + string.Empty, + "{controller}/{action}/{id?}", + defaults: new RouteValueDictionary(new { controller = "Foo", action = "Bar" }))); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + var endpoint = Assert.Single(endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + Assert.Equal("Foo/Baz/{id?}", matcherEndpoint.RoutePattern.RawText); + AssertIsSubset(expectedDefaults, matcherEndpoint.RoutePattern.Defaults); + } + + [Fact] + public void RequiredValues_DoesNotMatchNonParameterDefaults_FilteredOut() + { + // Arrange + var action1 = new RouteValueDictionary( + new { controller = "Foo", action = "Bar", }); + var action2 = new RouteValueDictionary( + new { controller = "Foo", action = "Baz", }); // Doesn't match default + var expectedDefaults = new RouteValueDictionary( + new { controller = "Foo", action = "Bar", }); + var actionDescriptorCollection = GetActionDescriptorCollection(action1, action2); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add( + CreateEndpointInfo( + string.Empty, + "Blog/{*slug}", + defaults: new RouteValueDictionary(new { controller = "Foo", action = "Bar" }))); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + var endpoint = Assert.Single(endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + Assert.Equal("Blog/{*slug}", matcherEndpoint.RoutePattern.RawText); + AssertIsSubset(expectedDefaults, matcherEndpoint.RoutePattern.Defaults); + } + + [Fact] + public void Endpoints_ConventionalRoutes_NonDefaultAndDefaultValuesEndingWithOptional_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "Home", action = "Index" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller}/{action=Index}/{id?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home/{action=Index}/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home/Index/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValuesEndingWithOptional_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "Home", action = "Index" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=Home}/{action=Index}/{id?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=Home}/{action=Index}/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home/Index/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(4, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValues_Shortened() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=TestController}/{action=TestAction}/{id=17}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=TestController}/{action=TestAction}/{id=17}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(1, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(2, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(3, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(4, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id=17}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(5, matcherEndpoint.Order); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValuesAndCatchAll_EndpointInfoDefaultsNotModified() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + var endpointInfo = CreateEndpointInfo( + name: string.Empty, + defaults: new RouteValueDictionary(), + template: "{controller=TestController}/{action=TestAction}/{id=17}/{**catchAll}"); + dataSource.ConventionalEndpointInfos.Add(endpointInfo); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Empty(endpointInfo.Defaults); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValuesAndCatchAll_Shortened() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=TestController}/{action=TestAction}/{id=17}/{**catchAll}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=TestController}/{action=TestAction}/{id=17}/{**catchAll}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(4, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id=17}/{**catchAll}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(5, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValuesAndOptional_Shortened() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=TestController}/{action=TestAction}/{id=17}/{more?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=TestController}/{action=TestAction}/{id=17}/{more?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(4, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id=17}/{more?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(5, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_OptionalExtension_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller}/{action=TestAction}.{ext?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/{action=TestAction}.{ext?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction.{ext?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_MultipleOptionalAndCatchAll_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=TestController}/{action=TestAction}/{id?}/{more?}/{**catchAll}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=TestController}/{action=TestAction}/{id?}/{more?}/{**catchAll}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id?}/{more?}/{**catchAll}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(4, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_AttributeRoutes_CatchAllWithDefault_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + "/TeamName/{*Name=DefaultName}/", + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TeamName/{*Name=DefaultName}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(0, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TeamName", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("DefaultName", matcherEndpoint.RoutePattern.Defaults["Name"]); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TeamName/{*Name=DefaultName}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("DefaultName", matcherEndpoint.RoutePattern.Defaults["Name"]); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_AttributeRoutes_DefaultDifferentCaseFromRouteValue_UseDefaultCase() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + "{controller}/{action=TESTACTION}/{id?}", + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/{action=TESTACTION}/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("TESTACTION", matcherEndpoint.RoutePattern.Defaults["action"]); + Assert.Equal(0, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + + var routeValuesAddress = matcherEndpoint.Metadata.GetMetadata(); + Assert.Equal("TESTACTION", routeValuesAddress.RequiredValues["action"]); + + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("TESTACTION", matcherEndpoint.RoutePattern.Defaults["action"]); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + + var routeValuesAddress = matcherEndpoint.Metadata.GetMetadata(); + Assert.Equal("TESTACTION", routeValuesAddress.RequiredValues["action"]); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TESTACTION/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("TESTACTION", matcherEndpoint.RoutePattern.Defaults["action"]); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + + var routeValuesAddress = matcherEndpoint.Metadata.GetMetadata(); + Assert.Equal("TESTACTION", routeValuesAddress.RequiredValues["action"]); + }); + } + + [Fact] + public void Endpoints_AttributeRoutes_ActionMetadataDoesNotOverrideDataSourceMetadata() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + CreateActionDescriptor(new { controller = "TestController", action = "TestAction" }, + "{controller}/{action}/{id?}", + new List { new RouteValuesAddressMetadata("fakeroutename", new RouteValueDictionary(new { fake = "Fake!" })) }) + ); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(0, matcherEndpoint.Order); + + var routeValuesAddress = matcherEndpoint.Metadata.GetMetadata(); + Assert.Equal("{controller}/{action}/{id?}", routeValuesAddress.RouteName); + Assert.Equal("TestController", routeValuesAddress.RequiredValues["controller"]); + Assert.Equal("TestAction", routeValuesAddress.RequiredValues["action"]); + }); + } + + private MvcEndpointDataSource CreateMvcEndpointDataSource( + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider = null, + MvcEndpointInvokerFactory mvcEndpointInvokerFactory = null) + { + if (actionDescriptorCollectionProvider == null) + { + actionDescriptorCollectionProvider = new DefaultActionDescriptorCollectionProvider( + Array.Empty(), + Array.Empty()); + } + + var services = new ServiceCollection(); + services.AddSingleton(actionDescriptorCollectionProvider); + services.AddRouting(options => + { + options.ConstraintMap["upper-case"] = typeof(UpperCaseParameterTransform); + }); + var serviceProvider = services.BuildServiceProvider(); + + var dataSource = new MvcEndpointDataSource( + actionDescriptorCollectionProvider, + mvcEndpointInvokerFactory ?? new MvcEndpointInvokerFactory(new ActionInvokerFactory(Array.Empty())), + serviceProvider.GetRequiredService()); + + return dataSource; + } + + private class UpperCaseParameterTransform : IOutboundParameterTransformer + { + public string TransformOutbound(object value) + { + return value?.ToString().ToUpperInvariant(); + } + } + + private MvcEndpointInfo CreateEndpointInfo( + string name, + string template, + RouteValueDictionary defaults = null, + IDictionary constraints = null, + RouteValueDictionary dataTokens = null, + IServiceProvider serviceProvider = null) + { + if (serviceProvider == null) + { + var services = new ServiceCollection(); + services.AddRouting(); + services.AddSingleton(typeof(UpperCaseParameterTransform), new UpperCaseParameterTransform()); + + var routeOptionsSetup = new MvcCoreRouteOptionsSetup(); + services.Configure(routeOptionsSetup.Configure); + services.Configure(options => + { + options.ConstraintMap["upper-case"] = typeof(UpperCaseParameterTransform); + }); + + serviceProvider = services.BuildServiceProvider(); + } + + var parameterPolicyFactory = serviceProvider.GetRequiredService(); + return new MvcEndpointInfo(name, template, defaults, constraints, dataTokens, parameterPolicyFactory); + } + + private IActionDescriptorCollectionProvider GetActionDescriptorCollection(params object[] requiredValues) + { + return GetActionDescriptorCollection(attributeRouteTemplate: null, requiredValues); + } + + private IActionDescriptorCollectionProvider GetActionDescriptorCollection(string attributeRouteTemplate, params object[] requiredValues) + { + var actionDescriptors = new List(); + foreach (var requiredValue in requiredValues) + { + actionDescriptors.Add(CreateActionDescriptor(requiredValue, attributeRouteTemplate)); + } + + return GetActionDescriptorCollection(actionDescriptors.ToArray()); + } + + private IActionDescriptorCollectionProvider GetActionDescriptorCollection(params ActionDescriptor[] actionDescriptors) + { + var actionDescriptorCollectionProviderMock = new Mock(); + actionDescriptorCollectionProviderMock + .Setup(m => m.ActionDescriptors) + .Returns(new ActionDescriptorCollection(actionDescriptors, version: 0)); + return actionDescriptorCollectionProviderMock.Object; + } + + private ActionDescriptor CreateActionDescriptor( + object requiredValues, + string attributeRouteTemplate = null, + IList metadata = null) + { + var actionDescriptor = new ActionDescriptor(); + var routeValues = new RouteValueDictionary(requiredValues); + foreach (var kvp in routeValues) + { + actionDescriptor.RouteValues[kvp.Key] = kvp.Value?.ToString(); + } + if (!string.IsNullOrEmpty(attributeRouteTemplate)) + { + actionDescriptor.AttributeRouteInfo = new AttributeRouteInfo + { + Name = attributeRouteTemplate, + Template = attributeRouteTemplate + }; + } + actionDescriptor.EndpointMetadata = metadata; + return actionDescriptor; + } + + private void AssertIsSubset( + IReadOnlyDictionary subset, + IReadOnlyDictionary fullSet) + { + foreach (var subsetPair in subset) + { + var isPresent = fullSet.TryGetValue(subsetPair.Key, out var fullSetPairValue); + Assert.True(isPresent); + Assert.Equal(subsetPair.Value, fullSetPairValue); + } + } + + private void AssertMatchingSuppressed(Endpoint endpoint, bool suppressed) + { + var isEndpointSuppressed = endpoint.Metadata.GetMetadata()?.SuppressMatching ?? false; + Assert.Equal(suppressed, isEndpointSuppressed); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RequestFormLimitsFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RequestFormLimitsFilterTest.cs index a36dd060e4..7b1234092b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RequestFormLimitsFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RequestFormLimitsFilterTest.cs @@ -21,7 +21,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Arrange var requestFormLimitsFilter = new RequestFormLimitsFilter(NullLoggerFactory.Instance); requestFormLimitsFilter.FormOptions = new FormOptions(); - var authorizationFilterContext = CreateauthorizationFilterContext( + var authorizationFilterContext = CreateAuthorizationFilterContext( new IFilterMetadata[] { requestFormLimitsFilter }); // Set to null explicitly as we want to make sure the filter adds one authorizationFilterContext.HttpContext.Features.Set(null); @@ -40,7 +40,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Arrange var requestFormLimitsFilter = new RequestFormLimitsFilter(NullLoggerFactory.Instance); requestFormLimitsFilter.FormOptions = new FormOptions(); - var authorizationFilterContext = CreateauthorizationFilterContext( + var authorizationFilterContext = CreateAuthorizationFilterContext( new IFilterMetadata[] { requestFormLimitsFilter }); var oldFormFeature = new FormFeature(authorizationFilterContext.HttpContext.Request); // Set to null explicitly as we want to make sure the filter adds one @@ -63,7 +63,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var requestFormLimitsFilter = new RequestFormLimitsFilter(loggerFactory); requestFormLimitsFilter.FormOptions = new FormOptions(); - var authorizationFilterContext = CreateauthorizationFilterContext( + var authorizationFilterContext = CreateAuthorizationFilterContext( new IFilterMetadata[] { requestFormLimitsFilter }); authorizationFilterContext.HttpContext.Request.Form = new FormCollection(null); @@ -87,7 +87,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var requestFormLimitsFilter = new RequestFormLimitsFilter(loggerFactory); requestFormLimitsFilter.FormOptions = new FormOptions(); - var authorizationFilterContext = CreateauthorizationFilterContext( + var authorizationFilterContext = CreateAuthorizationFilterContext( new IFilterMetadata[] { requestFormLimitsFilter }); // Set to null explicitly as we want to make sure the filter adds one authorizationFilterContext.HttpContext.Features.Set(null); @@ -112,7 +112,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var requestFormLimitsFilter = new RequestFormLimitsFilter(loggerFactory); requestFormLimitsFilter.FormOptions = new FormOptions(); - var authorizationFilterContext = CreateauthorizationFilterContext( + var authorizationFilterContext = CreateAuthorizationFilterContext( new IFilterMetadata[] { requestFormLimitsFilter }); // Set to null explicitly as we want to make sure the filter adds one authorizationFilterContext.HttpContext.Features.Set( @@ -129,7 +129,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal write.State.ToString()); } - private static AuthorizationFilterContext CreateauthorizationFilterContext(IFilterMetadata[] filters) + private static AuthorizationFilterContext CreateAuthorizationFilterContext(IFilterMetadata[] filters) { return new AuthorizationFilterContext(CreateActionContext(), filters); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RequestSizeLimitFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RequestSizeLimitFilterTest.cs index 82c040508d..80be3c2e9a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RequestSizeLimitFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RequestSizeLimitFilterTest.cs @@ -20,7 +20,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Arrange var requestSizeLimitResourceFilter = new RequestSizeLimitFilter(NullLoggerFactory.Instance); requestSizeLimitResourceFilter.Bytes = 12345; - var authorizationFilterContext = CreateauthorizationFilterContext(new IFilterMetadata[] { requestSizeLimitResourceFilter }); + var authorizationFilterContext = CreateAuthorizationFilterContext(new IFilterMetadata[] { requestSizeLimitResourceFilter }); var httpMaxRequestBodySize = new TestHttpMaxRequestBodySizeFeature(); authorizationFilterContext.HttpContext.Features.Set(httpMaxRequestBodySize); @@ -40,7 +40,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal requestSizeLimitResourceFilter.Bytes = 12345; var requestSizeLimitResourceFilterFinal = new RequestSizeLimitFilter(NullLoggerFactory.Instance); requestSizeLimitResourceFilterFinal.Bytes = 0; - var authorizationFilterContext = CreateauthorizationFilterContext( + var authorizationFilterContext = CreateAuthorizationFilterContext( new IFilterMetadata[] { requestSizeLimitResourceFilter, requestSizeLimitResourceFilterFinal }); var httpMaxRequestBodySize = new TestHttpMaxRequestBodySizeFeature(); @@ -64,7 +64,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var requestSizeLimitResourceFilter = new RequestSizeLimitFilter(loggerFactory); requestSizeLimitResourceFilter.Bytes = 12345; - var authorizationFilterContext = CreateauthorizationFilterContext(new IFilterMetadata[] { requestSizeLimitResourceFilter }); + var authorizationFilterContext = CreateAuthorizationFilterContext(new IFilterMetadata[] { requestSizeLimitResourceFilter }); // Act requestSizeLimitResourceFilter.OnAuthorization(authorizationFilterContext); @@ -84,7 +84,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var requestSizeLimitResourceFilter = new RequestSizeLimitFilter(loggerFactory); requestSizeLimitResourceFilter.Bytes = 12345; - var authorizationFilterContext = CreateauthorizationFilterContext(new IFilterMetadata[] { requestSizeLimitResourceFilter }); + var authorizationFilterContext = CreateAuthorizationFilterContext(new IFilterMetadata[] { requestSizeLimitResourceFilter }); var httpMaxRequestBodySize = new TestHttpMaxRequestBodySizeFeature(); httpMaxRequestBodySize.IsReadOnly = true; @@ -107,7 +107,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var requestSizeLimitResourceFilter = new RequestSizeLimitFilter(loggerFactory); requestSizeLimitResourceFilter.Bytes = 12345; - var authorizationFilterContext = CreateauthorizationFilterContext(new IFilterMetadata[] { requestSizeLimitResourceFilter }); + var authorizationFilterContext = CreateAuthorizationFilterContext(new IFilterMetadata[] { requestSizeLimitResourceFilter }); var httpMaxRequestBodySize = new TestHttpMaxRequestBodySizeFeature(); authorizationFilterContext.HttpContext.Features.Set(httpMaxRequestBodySize); @@ -120,7 +120,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Equal($"The maximum request body size has been set to 12345.", write.State.ToString()); } - private static AuthorizationFilterContext CreateauthorizationFilterContext(IFilterMetadata[] filters) + private static AuthorizationFilterContext CreateAuthorizationFilterContext(IFilterMetadata[] filters) { return new AuthorizationFilterContext(CreateActionContext(), filters); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ResponseContentTypeHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ResponseContentTypeHelperTest.cs index 9e1e31edec..d7898089b8 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ResponseContentTypeHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ResponseContentTypeHelperTest.cs @@ -2,8 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Text; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.Net.Http.Headers; using Xunit; @@ -107,14 +105,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal var defaultContentType = "text/default; p1=p1-value; charset=utf-8"; // Act - string resolvedContentType = null; - Encoding resolvedContentTypeEncoding = null; ResponseContentTypeHelper.ResolveContentTypeAndEncoding( contentType?.ToString(), responseContentType, defaultContentType, - out resolvedContentType, - out resolvedContentTypeEncoding); + out var resolvedContentType, + out var resolvedContentTypeEncoding); // Assert Assert.Equal(expectedContentType, resolvedContentType); @@ -128,14 +124,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal var defaultContentType = "text/plain; charset=utf-8"; // Act - string resolvedContentType = null; - Encoding resolvedContentTypeEncoding = null; ResponseContentTypeHelper.ResolveContentTypeAndEncoding( null, expectedContentType, defaultContentType, - out resolvedContentType, - out resolvedContentTypeEncoding); + out var resolvedContentType, + out var resolvedContentTypeEncoding); // Assert Assert.Equal(expectedContentType, resolvedContentType); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RoutePatternWriterTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RoutePatternWriterTests.cs new file mode 100644 index 0000000000..c403213b72 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RoutePatternWriterTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing.Patterns; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal +{ + public class RoutePatternWriterTests + { + [Theory] + [InlineData(@"")] + [InlineData(@"Literal")] + [InlineData(@"Literal1/Literal2")] + [InlineData(@"{controller}")] + [InlineData(@"{controller}/{action}")] + [InlineData(@"{controller}/{action}/{param:test(\?)?}")] + [InlineData(@"{param:test(\w,\w)=jsd}")] + [InlineData(@"some/url-{p1:int:test(3)=hello}/{p2=abc}/{p3?}")] + [InlineData(@"{param:test(abc:somevalue):name(test1:differentname=default-value}")] + [InlineData(@"api/Blog/{controller}/{action}/{id?}")] + [InlineData(@"{p1}.{p2}.{p3}")] + public void ToString_TemplateRoundtrips(string template) + { + var routePattern = RoutePatternFactory.Parse(template); + + var sb = new StringBuilder(); + RoutePatternWriter.WriteString(sb, routePattern.PathSegments); + + Assert.Equal(template, sb.ToString()); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/LocalRedirectResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/LocalRedirectResultTest.cs index ebbbac82a7..6e78d6f840 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/LocalRedirectResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/LocalRedirectResultTest.cs @@ -4,6 +4,7 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -168,13 +169,15 @@ namespace Microsoft.AspNetCore.Mvc var serviceProvider = GetServiceProvider(); httpContext.Setup(o => o.Response) - .Returns(response); + .Returns(response); httpContext.SetupGet(o => o.RequestServices) - .Returns(serviceProvider); + .Returns(serviceProvider); httpContext.SetupGet(o => o.Items) - .Returns(new ItemsDictionary()); + .Returns(new ItemsDictionary()); httpContext.Setup(o => o.Request.PathBase) - .Returns(new PathString(appRoot)); + .Returns(new PathString(appRoot)); + httpContext.SetupGet(h => h.Features) + .Returns(new FeatureCollection()); return httpContext.Object; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Microsoft.AspNetCore.Mvc.Core.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Microsoft.AspNetCore.Mvc.Core.Test.csproj index 25368d8cc0..f030903427 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Microsoft.AspNetCore.Mvc.Core.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Microsoft.AspNetCore.Mvc.Core.Test.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) @@ -10,14 +10,12 @@ - - + + + - - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ArrayModelBinderProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ArrayModelBinderProviderTest.cs index 83a96daa9a..537dd3bf4e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ArrayModelBinderProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ArrayModelBinderProviderTest.cs @@ -51,6 +51,31 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.IsType(typeof(ArrayModelBinder<>).MakeGenericType(modelType.GetElementType()), result); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_ForArrayType_ReturnsBinder_WithExpectedAllowValidatingTopLevelNodes( + bool allowValidatingTopLevelNodes) + { + // Arrange + var provider = new ArrayModelBinderProvider(); + + var context = new TestModelBinderProviderContext(typeof(int[])); + context.MvcOptions.AllowValidatingTopLevelNodes = allowValidatingTopLevelNodes; + context.OnCreatingBinder(m => + { + Assert.Equal(typeof(int), m.ModelType); + return Mock.Of(); + }); + + // Act + var result = provider.GetBinder(context); + + // Assert + var binder = Assert.IsType>(result); + Assert.Equal(allowValidatingTopLevelNodes, binder.AllowValidatingTopLevelNodes); + } + [Fact] public void Create_ForModelMetadataReadOnly_ReturnsNull() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ArrayModelBinderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ArrayModelBinderTest.cs index 8c3a372f06..d3efdd8775 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ArrayModelBinderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ArrayModelBinderTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding.Internal; @@ -42,13 +43,21 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.Equal(new[] { 42, 84 }, array); } - [Fact] - public async Task ArrayModelBinder_CreatesEmptyCollection_IfIsTopLevelObject() + private IActionResult ActionWithArrayParameter(string[] parameter) => null; + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + public async Task ArrayModelBinder_CreatesEmptyCollection_IfIsTopLevelObject( + bool allowValidatingTopLevelNodes, + bool isBindingRequired) { // Arrange var binder = new ArrayModelBinder( new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance), - NullLoggerFactory.Instance); + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes); var bindingContext = CreateContext(); bindingContext.IsTopLevelObject = true; @@ -57,7 +66,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders bindingContext.ModelName = "modelName"; var metadataProvider = new TestModelMetadataProvider(); - bindingContext.ModelMetadata = metadataProvider.GetMetadataForType(typeof(string[])); + var parameter = typeof(ArrayModelBinderTest) + .GetMethod(nameof(ActionWithArrayParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = isBindingRequired); + bindingContext.ModelMetadata = metadataProvider.GetMetadataForParameter(parameter); bindingContext.ValueProvider = new TestValueProvider(new Dictionary()); @@ -67,22 +82,74 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // Assert Assert.Empty(Assert.IsType(bindingContext.Result.Model)); Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(0, bindingContext.ModelState.ErrorCount); } - [Theory] - [InlineData("")] - [InlineData("param")] - public async Task ArrayModelBinder_DoesNotCreateCollection_IfNotIsTopLevelObject(string prefix) + [Fact] + public async Task ArrayModelBinder_CreatesEmptyCollectionAndAddsError_IfIsTopLevelObject() { // Arrange var binder = new ArrayModelBinder( new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance), - NullLoggerFactory.Instance); + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes: true); + + var bindingContext = CreateContext(); + bindingContext.IsTopLevelObject = true; + bindingContext.FieldName = "fieldName"; + bindingContext.ModelName = "modelName"; + + var metadataProvider = new TestModelMetadataProvider(); + var parameter = typeof(ArrayModelBinderTest) + .GetMethod(nameof(ActionWithArrayParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + bindingContext.ModelMetadata = metadataProvider.GetMetadataForParameter(parameter); + + bindingContext.ValueProvider = new TestValueProvider(new Dictionary()); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Empty(Assert.IsType(bindingContext.Result.Model)); + Assert.True(bindingContext.Result.IsModelSet); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal("modelName", keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); + } + + [Theory] + [InlineData("", false, false)] + [InlineData("", true, false)] + [InlineData("", false, true)] + [InlineData("", true, true)] + [InlineData("param", false, false)] + [InlineData("param", true, false)] + [InlineData("param", false, true)] + [InlineData("param", true, true)] + public async Task ArrayModelBinder_DoesNotCreateCollection_IfNotIsTopLevelObject( + string prefix, + bool allowValidatingTopLevelNodes, + bool isBindingRequired) + { + // Arrange + var binder = new ArrayModelBinder( + new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance), + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes); var bindingContext = CreateContext(); bindingContext.ModelName = ModelNames.CreatePropertyModelName(prefix, "ArrayProperty"); var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForProperty(typeof(ModelWithArrayProperty), nameof(ModelWithArrayProperty.ArrayProperty)) + .BindingDetails(b => b.IsBindingRequired = isBindingRequired); bindingContext.ModelMetadata = metadataProvider.GetMetadataForProperty( typeof(ModelWithArrayProperty), nameof(ModelWithArrayProperty.ArrayProperty)); @@ -94,6 +161,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // Assert Assert.False(bindingContext.Result.IsModelSet); + Assert.Equal(0, bindingContext.ModelState.ErrorCount); } public static TheoryData ArrayModelData @@ -177,23 +245,23 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private static DefaultModelBindingContext GetBindingContext(IValueProvider valueProvider) { - var bindingContext = new DefaultModelBindingContext() - { - ModelName = "someName", - ModelState = new ModelStateDictionary(), - ValueProvider = valueProvider, - }; + var bindingContext = CreateContext(); + bindingContext.ModelName = "someName"; + bindingContext.ValueProvider = valueProvider; + return bindingContext; } private static DefaultModelBindingContext CreateContext() { - var modelBindingContext = new DefaultModelBindingContext() + var actionContext = new ActionContext { - ActionContext = new ActionContext() - { - HttpContext = new DefaultHttpContext(), - }, + HttpContext = new DefaultHttpContext(), + }; + var modelBindingContext = new DefaultModelBindingContext + { + ActionContext = actionContext, + ModelState = actionContext.ModelState, }; return modelBindingContext; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs index 21a66b0a40..97ceca0963 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs @@ -467,7 +467,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders [Theory] [MemberData(nameof(DerivedInputFormattersThrowingNonInputFormatterException))] - public async Task BindModel_DerivedXmlInputFormatters_ThrowingNonInputFormatingException_AddsErrorToModelState( + public async Task BindModel_DerivedXmlInputFormatters_ThrowingNonInputFormattingException_AddsErrorToModelState( IInputFormatter formatter, string contentType, InputFormatterExceptionPolicy inputFormatterExceptionPolicy) @@ -829,7 +829,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private class TestableXmlSerializerInputFormatter : XmlSerializerInputFormatter { - private bool _throwNonInputFormatterException; + private readonly bool _throwNonInputFormatterException; public TestableXmlSerializerInputFormatter(bool throwNonInputFormatterException) : base(new MvcOptions()) @@ -851,7 +851,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private class TestableXmlDataContractSerializerInputFormatter : XmlDataContractSerializerInputFormatter { - private bool _throwNonInputFormatterException; + private readonly bool _throwNonInputFormatterException; public TestableXmlDataContractSerializerInputFormatter(bool throwNonInputFormatterException) : base(new MvcOptions()) @@ -899,7 +899,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private class DerivedXmlSerializerInputFormatter : XmlSerializerInputFormatter { - private bool _throwNonInputFormatterException; + private readonly bool _throwNonInputFormatterException; public DerivedXmlSerializerInputFormatter(bool throwNonInputFormatterException) : base(new MvcOptions()) @@ -921,7 +921,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private class DerivedXmlDataContractSerializerInputFormatter : XmlDataContractSerializerInputFormatter { - private bool _throwNonInputFormatterException; + private readonly bool _throwNonInputFormatterException; public DerivedXmlDataContractSerializerInputFormatter(bool throwNonInputFormatterException) : base(new MvcOptions()) @@ -952,4 +952,4 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders public string Name { get; set; } } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/CollectionModelBinderProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/CollectionModelBinderProviderTest.cs index d0cd6091bb..d3ee57f216 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/CollectionModelBinderProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/CollectionModelBinderProviderTest.cs @@ -66,6 +66,31 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.IsType>(result); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_ForSupportedType_ReturnsBinder_WithExpectedAllowValidatingTopLevelNodes( + bool allowValidatingTopLevelNodes) + { + // Arrange + var provider = new CollectionModelBinderProvider(); + + var context = new TestModelBinderProviderContext(typeof(List)); + context.MvcOptions.AllowValidatingTopLevelNodes = allowValidatingTopLevelNodes; + context.OnCreatingBinder(m => + { + Assert.Equal(typeof(int), m.ModelType); + return Mock.Of(); + }); + + // Act + var result = provider.GetBinder(context); + + // Assert + var binder = Assert.IsType>(result); + Assert.Equal(allowValidatingTopLevelNodes, binder.AllowValidatingTopLevelNodes); + } + private class Person { public string Name { get; set; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/CollectionModelBinderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/CollectionModelBinderTest.cs index b34cd09da5..bc9cdf1981 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/CollectionModelBinderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/CollectionModelBinderTest.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Internal; @@ -211,13 +212,21 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.Empty(boundCollection.Model); } - [Fact] - public async Task CollectionModelBinder_CreatesEmptyCollection_IfIsTopLevelObject() + private IActionResult ActionWithListParameter(List parameter) => null; + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + public async Task CollectionModelBinder_CreatesEmptyCollection_IfIsTopLevelObject( + bool allowValidatingTopLevelNodes, + bool isBindingRequired) { // Arrange var binder = new CollectionModelBinder( new StubModelBinder(result: ModelBindingResult.Failed()), - NullLoggerFactory.Instance); + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes); var bindingContext = CreateContext(); bindingContext.IsTopLevelObject = true; @@ -226,7 +235,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders bindingContext.ModelName = "modelName"; var metadataProvider = new TestModelMetadataProvider(); - bindingContext.ModelMetadata = metadataProvider.GetMetadataForType(typeof(List)); + var parameter = typeof(CollectionModelBinderTest) + .GetMethod(nameof(ActionWithListParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = isBindingRequired); + bindingContext.ModelMetadata = metadataProvider.GetMetadataForParameter(parameter); bindingContext.ValueProvider = new TestValueProvider(new Dictionary()); @@ -236,6 +251,45 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // Assert Assert.Empty(Assert.IsType>(bindingContext.Result.Model)); Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(0, bindingContext.ModelState.ErrorCount); + } + + [Fact] + public async Task CollectionModelBinder_CreatesEmptyCollectionAndAddsError_IfIsTopLevelObject() + { + // Arrange + var binder = new CollectionModelBinder( + new StubModelBinder(result: ModelBindingResult.Failed()), + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes: true); + + var bindingContext = CreateContext(); + bindingContext.IsTopLevelObject = true; + bindingContext.FieldName = "fieldName"; + bindingContext.ModelName = "modelName"; + + var metadataProvider = new TestModelMetadataProvider(); + var parameter = typeof(CollectionModelBinderTest) + .GetMethod(nameof(ActionWithListParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + bindingContext.ModelMetadata = metadataProvider.GetMetadataForParameter(parameter); + + bindingContext.ValueProvider = new TestValueProvider(new Dictionary()); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Empty(Assert.IsType>(bindingContext.Result.Model)); + Assert.True(bindingContext.Result.IsModelSet); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal("modelName", keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); } // Setup like CollectionModelBinder_CreatesEmptyCollection_IfIsTopLevelObject except @@ -272,19 +326,32 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [InlineData("")] - [InlineData("param")] - public async Task CollectionModelBinder_DoesNotCreateCollection_IfNotIsTopLevelObject(string prefix) + [InlineData("", false, false)] + [InlineData("", true, false)] + [InlineData("", false, true)] + [InlineData("", true, true)] + [InlineData("param", false, false)] + [InlineData("param", true, false)] + [InlineData("param", false, true)] + [InlineData("param", true, true)] + public async Task CollectionModelBinder_DoesNotCreateCollection_IfNotIsTopLevelObject( + string prefix, + bool allowValidatingTopLevelNodes, + bool isBindingRequired) { // Arrange var binder = new CollectionModelBinder( new StubModelBinder(result: ModelBindingResult.Failed()), - NullLoggerFactory.Instance); + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes); var bindingContext = CreateContext(); bindingContext.ModelName = ModelNames.CreatePropertyModelName(prefix, "ListProperty"); var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForProperty(typeof(ModelWithListProperty), nameof(ModelWithListProperty.ListProperty)) + .BindingDetails(b => b.IsBindingRequired = isBindingRequired); bindingContext.ModelMetadata = metadataProvider.GetMetadataForProperty( typeof(ModelWithListProperty), nameof(ModelWithListProperty.ListProperty)); @@ -296,6 +363,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // Assert Assert.False(bindingContext.Result.IsModelSet); + Assert.Equal(0, bindingContext.ModelState.ErrorCount); } // Model type -> can create instance. @@ -365,15 +433,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders typeof(ModelWithIListProperty), nameof(ModelWithIListProperty.ListProperty)); - var bindingContext = new DefaultModelBindingContext - { - ModelMetadata = metadata, - ModelName = "someName", - ModelState = new ModelStateDictionary(), - ValueProvider = valueProvider, - ValidationState = new ValidationStateDictionary(), - FieldName = "testfieldname", - }; + var bindingContext = CreateContext(); + bindingContext.FieldName = "testfieldname"; + bindingContext.ModelName = "someName"; + bindingContext.ModelMetadata = metadata; + bindingContext.ValueProvider = valueProvider; return bindingContext; } @@ -412,12 +476,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private static DefaultModelBindingContext CreateContext() { + var actionContext = new ActionContext() + { + HttpContext = new DefaultHttpContext(), + }; var modelBindingContext = new DefaultModelBindingContext() { - ActionContext = new ActionContext() - { - HttpContext = new DefaultHttpContext(), - }, + ActionContext = actionContext, + ModelState = actionContext.ModelState, + ValidationState = new ValidationStateDictionary(), }; return modelBindingContext; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs index 9dc044f814..45e27d4ddc 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs @@ -55,6 +55,38 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.IsType(result); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_ForSupportedType_ReturnsBinder_WithExpectedAllowValidatingTopLevelNodes( + bool allowValidatingTopLevelNodes) + { + // Arrange + var provider = new ComplexTypeModelBinderProvider(); + + var context = new TestModelBinderProviderContext(typeof(Person)); + context.MvcOptions.AllowValidatingTopLevelNodes = allowValidatingTopLevelNodes; + context.OnCreatingBinder(m => + { + if (m.ModelType == typeof(int) || m.ModelType == typeof(string)) + { + return Mock.Of(); + } + else + { + Assert.False(true, "Not the right model type"); + return null; + } + }); + + // Act + var result = provider.GetBinder(context); + + // Assert + var binder = Assert.IsType(result); + Assert.Equal(allowValidatingTopLevelNodes, binder.AllowValidatingTopLevelNodes); + } + private class Person { public string Name { get; set; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs index c76eb27c0a..32314d7306 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Reflection; using System.Runtime.Serialization; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -155,10 +156,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders [Theory] [InlineData(typeof(TypeWithNoBinderMetadata), false)] [InlineData(typeof(TypeWithNoBinderMetadata), true)] - [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), false)] - [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), true)] - [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), false)] - [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), true)] public void CanCreateModel_CreatesModelForValueProviderBasedBinderMetadatas_IfAValueProviderProvidesValue( Type modelType, bool valueProviderProvidesValue) @@ -182,6 +179,34 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.Equal(valueProviderProvidesValue, canCreate); } + [Theory] + [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), false)] + [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), true)] + [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), false)] + [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), true)] + public void CanCreateModel_CreatesModelForValueProviderBasedBinderMetadatas_IfPropertyHasGreedyBindingSource( + Type modelType, + bool valueProviderProvidesValue) + { + var valueProvider = new Mock(); + valueProvider + .Setup(o => o.ContainsPrefix(It.IsAny())) + .Returns(valueProviderProvidesValue); + + var bindingContext = CreateContext(GetMetadataForType(modelType)); + bindingContext.IsTopLevelObject = false; + bindingContext.ValueProvider = valueProvider.Object; + bindingContext.OriginalValueProvider = valueProvider.Object; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.True(canCreate); + } + [Theory] [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), false)] [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), true)] @@ -214,17 +239,18 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var canCreate = binder.CanCreateModel(bindingContext); // Assert - Assert.Equal(originalValueProviderProvidesValue, canCreate); + Assert.True(canCreate); } [Theory] - [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), false)] - [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), true)] - [InlineData(typeof(TypeWithNoBinderMetadata), false)] - [InlineData(typeof(TypeWithNoBinderMetadata), true)] + [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), false, true)] + [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), true, true)] + [InlineData(typeof(TypeWithNoBinderMetadata), false, false)] + [InlineData(typeof(TypeWithNoBinderMetadata), true, true)] public void CanCreateModel_UnmarkedProperties_UsesCurrentValueProvider( Type modelType, - bool valueProviderProvidesValue) + bool valueProviderProvidesValue, + bool expectedCanCreate) { var valueProvider = new Mock(); valueProvider @@ -247,11 +273,18 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var canCreate = binder.CanCreateModel(bindingContext); // Assert - Assert.Equal(valueProviderProvidesValue, canCreate); + Assert.Equal(expectedCanCreate, canCreate); } - [Fact] - public async Task BindModelAsync_CreatesModel_IfIsTopLevelObject() + private IActionResult ActionWithComplexParameter(Person parameter) => null; + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + public async Task BindModelAsync_CreatesModel_IfIsTopLevelObject( + bool allowValidatingTopLevelNodes, + bool isBindingRequired) { // Arrange var mockValueProvider = new Mock(); @@ -262,6 +295,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // Mock binder fails to bind all properties. var mockBinder = new StubModelBinder(); + var parameter = typeof(ComplexTypeModelBinderTest) + .GetMethod(nameof(ActionWithComplexParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = isBindingRequired); + var metadata = metadataProvider.GetMetadataForParameter(parameter); var bindingContext = new DefaultModelBindingContext { IsTopLevelObject = true, @@ -273,7 +314,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var model = new Person(); - var testableBinder = new Mock { CallBase = true }; + var testableBinder = new Mock(allowValidatingTopLevelNodes) + { + CallBase = true + }; testableBinder .Setup(o => o.CreateModelPublic(bindingContext)) .Returns(model) @@ -287,11 +331,149 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // Assert Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(0, bindingContext.ModelState.ErrorCount); + var returnedPerson = Assert.IsType(bindingContext.Result.Model); Assert.Same(model, returnedPerson); testableBinder.Verify(); } + [Fact] + public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithNoData() + { + // Arrange + var parameter = typeof(ComplexTypeModelBinderTest) + .GetMethod(nameof(ActionWithComplexParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + var metadata = metadataProvider.GetMetadataForParameter(parameter); + var bindingContext = new DefaultModelBindingContext + { + IsTopLevelObject = true, + FieldName = "fieldName", + ModelMetadata = metadata, + ModelName = string.Empty, + ValueProvider = new TestValueProvider(new Dictionary()), + ModelState = new ModelStateDictionary(), + }; + + // Mock binder fails to bind all properties. + var innerBinder = new StubModelBinder(); + var binders = new Dictionary(); + foreach (var property in metadataProvider.GetMetadataForProperties(typeof(Person))) + { + binders.Add(property, innerBinder); + } + + var binder = new ComplexTypeModelBinder( + binders, + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes: true); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.IsType(bindingContext.Result.Model); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal(string.Empty, keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); + } + + private IActionResult ActionWithNoSettablePropertiesParameter(PersonWithNoProperties parameter) => null; + + [Fact] + public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithNoSettableProperties() + { + // Arrange + var parameter = typeof(ComplexTypeModelBinderTest) + .GetMethod( + nameof(ActionWithNoSettablePropertiesParameter), + BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + var metadata = metadataProvider.GetMetadataForParameter(parameter); + var bindingContext = new DefaultModelBindingContext + { + IsTopLevelObject = true, + FieldName = "fieldName", + ModelMetadata = metadata, + ModelName = string.Empty, + ValueProvider = new TestValueProvider(new Dictionary()), + ModelState = new ModelStateDictionary(), + }; + + var binder = new ComplexTypeModelBinder( + new Dictionary(), + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes: true); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.IsType(bindingContext.Result.Model); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal(string.Empty, keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); + } + + private IActionResult ActionWithAllPropertiesExcludedParameter(PersonWithAllPropertiesExcluded parameter) => null; + + [Fact] + public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithAllPropertiesExcluded() + { + // Arrange + var parameter = typeof(ComplexTypeModelBinderTest) + .GetMethod( + nameof(ActionWithAllPropertiesExcludedParameter), + BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + var metadata = metadataProvider.GetMetadataForParameter(parameter); + var bindingContext = new DefaultModelBindingContext + { + IsTopLevelObject = true, + FieldName = "fieldName", + ModelMetadata = metadata, + ModelName = string.Empty, + ValueProvider = new TestValueProvider(new Dictionary()), + ModelState = new ModelStateDictionary(), + }; + + var binder = new ComplexTypeModelBinder( + new Dictionary(), + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes: true); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.IsType(bindingContext.Result.Model); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal(string.Empty, keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); + } + [Theory] [InlineData(nameof(MyModelTestingCanUpdateProperty.ReadOnlyInt), false)] // read-only value type [InlineData(nameof(MyModelTestingCanUpdateProperty.ReadOnlyObject), true)] @@ -619,7 +801,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var modelError = Assert.Single(entry.Errors); Assert.Null(modelError.Exception); Assert.NotNull(modelError.ErrorMessage); - Assert.Equal("A value for the 'Age' property was not provided.", modelError.ErrorMessage); + Assert.Equal("A value for the 'Age' parameter or property was not provided.", modelError.ErrorMessage); } [Fact] @@ -653,7 +835,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders var modelError = Assert.Single(entry.Errors); Assert.Null(modelError.Exception); Assert.NotNull(modelError.ErrorMessage); - Assert.Equal("A value for the 'Age' property was not provided.", modelError.ErrorMessage); + Assert.Equal("A value for the 'Age' parameter or property was not provided.", modelError.ErrorMessage); } [Fact] @@ -1178,6 +1360,23 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders public string name = null; } + private class PersonWithAllPropertiesExcluded + { + [BindNever] + public DateTime DateOfBirth { get; set; } + + [BindNever] + public DateTime? DateOfDeath { get; set; } + + [BindNever] + public string FirstName { get; set; } + + [BindNever] + public string LastName { get; set; } + + public string NonUpdateableProperty { get; private set; } + } + private class PersonWithBindExclusion { [BindNever] @@ -1380,13 +1579,24 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { } + public TestableComplexTypeModelBinder(bool allowValidatingTopLevelNodes) + : this(new Dictionary(), allowValidatingTopLevelNodes) + { + } + public TestableComplexTypeModelBinder(IDictionary propertyBinders) : base(propertyBinders, NullLoggerFactory.Instance) { - Results = new Dictionary(); } - public Dictionary Results { get; } + public TestableComplexTypeModelBinder( + IDictionary propertyBinders, + bool allowValidatingTopLevelNodes) + : base(propertyBinders, NullLoggerFactory.Instance, allowValidatingTopLevelNodes) + { + } + + public Dictionary Results { get; } = new Dictionary(); public virtual Task BindPropertyPublic(ModelBindingContext bindingContext) { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderProviderTest.cs index d60c3b2628..6d7add41c7 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderProviderTest.cs @@ -60,6 +60,39 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.IsType>(result); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_ForDictionaryType_ReturnsBinder_WithExpectedAllowValidatingTopLevelNodes( + bool allowValidatingTopLevelNodes) + { + // Arrange + var provider = new DictionaryModelBinderProvider(); + + var context = new TestModelBinderProviderContext(typeof(Dictionary)); + context.MvcOptions.AllowValidatingTopLevelNodes = allowValidatingTopLevelNodes; + context.OnCreatingBinder(m => + { + if (m.ModelType == typeof(KeyValuePair) || m.ModelType == typeof(string)) + { + return Mock.Of(); + } + else + { + Assert.False(true, "Not the right model type"); + return null; + } + }); + + // Act + var result = provider.GetBinder(context); + + // Assert + var binder = Assert.IsType>(result); + Assert.Equal(allowValidatingTopLevelNodes, binder.AllowValidatingTopLevelNodes); + Assert.False(((CollectionModelBinder>)binder).AllowValidatingTopLevelNodes); + } + private class Person { public string Name { get; set; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderTest.cs index 2beb2a33f9..db059c0090 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderTest.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Internal; @@ -343,14 +344,22 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.Equal(expectedDictionary, resultDictionary); } - [Fact] - public async Task DictionaryModelBinder_CreatesEmptyCollection_IfIsTopLevelObject() + private IActionResult ActionWithDictionaryParameter(Dictionary parameter) => null; + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + public async Task DictionaryModelBinder_CreatesEmptyCollection_IfIsTopLevelObject( + bool allowValidatingTopLevelNodes, + bool isBindingRequired) { // Arrange var binder = new DictionaryModelBinder( new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance), new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance), - NullLoggerFactory.Instance); + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes); var bindingContext = CreateContext(); bindingContext.IsTopLevelObject = true; @@ -359,7 +368,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders bindingContext.ModelName = "modelName"; var metadataProvider = new TestModelMetadataProvider(); - bindingContext.ModelMetadata = metadataProvider.GetMetadataForType(typeof(Dictionary)); + var parameter = typeof(DictionaryModelBinderTest) + .GetMethod(nameof(ActionWithDictionaryParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = isBindingRequired); + bindingContext.ModelMetadata = metadataProvider.GetMetadataForParameter(parameter); bindingContext.ValueProvider = new TestValueProvider(new Dictionary()); @@ -369,23 +384,78 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // Assert Assert.Empty(Assert.IsType>(bindingContext.Result.Model)); Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(0, bindingContext.ModelState.ErrorCount); + } + + [Fact] + public async Task DictionaryModelBinder_CreatesEmptyCollectionAndAddsError_IfIsTopLevelObject() + { + // Arrange + var binder = new DictionaryModelBinder( + new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance), + new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance), + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes: true); + + var bindingContext = CreateContext(); + bindingContext.IsTopLevelObject = true; + bindingContext.FieldName = "fieldName"; + bindingContext.ModelName = "modelName"; + + var metadataProvider = new TestModelMetadataProvider(); + var parameter = typeof(DictionaryModelBinderTest) + .GetMethod(nameof(ActionWithDictionaryParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + bindingContext.ModelMetadata = metadataProvider.GetMetadataForParameter(parameter); + + bindingContext.ValueProvider = new TestValueProvider(new Dictionary()); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Empty(Assert.IsType>(bindingContext.Result.Model)); + Assert.True(bindingContext.Result.IsModelSet); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal("modelName", keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); } [Theory] - [InlineData("")] - [InlineData("param")] - public async Task DictionaryModelBinder_DoesNotCreateCollection_IfNotIsTopLevelObject(string prefix) + [InlineData("", false, false)] + [InlineData("", true, false)] + [InlineData("", false, true)] + [InlineData("", true, true)] + [InlineData("param", false, false)] + [InlineData("param", true, false)] + [InlineData("param", false, true)] + [InlineData("param", true, true)] + public async Task DictionaryModelBinder_DoesNotCreateCollection_IfNotIsTopLevelObject( + string prefix, + bool allowValidatingTopLevelNodes, + bool isBindingRequired) { // Arrange var binder = new DictionaryModelBinder( new SimpleTypeModelBinder(typeof(int), NullLoggerFactory.Instance), new SimpleTypeModelBinder(typeof(int), NullLoggerFactory.Instance), - NullLoggerFactory.Instance); + NullLoggerFactory.Instance, + allowValidatingTopLevelNodes); var bindingContext = CreateContext(); bindingContext.ModelName = ModelNames.CreatePropertyModelName(prefix, "ListProperty"); var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForProperty( + typeof(ModelWithDictionaryProperties), + nameof(ModelWithDictionaryProperties.DictionaryProperty)) + .BindingDetails(b => b.IsBindingRequired = isBindingRequired); bindingContext.ModelMetadata = metadataProvider.GetMetadataForProperty( typeof(ModelWithDictionaryProperties), nameof(ModelWithDictionaryProperties.DictionaryProperty)); @@ -397,6 +467,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders // Assert Assert.False(bindingContext.Result.IsModelSet); + Assert.Equal(0, bindingContext.ModelState.ErrorCount); } // Model type -> can create instance. @@ -436,13 +507,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private static DefaultModelBindingContext CreateContext() { + var actionContext = new ActionContext() + { + HttpContext = new DefaultHttpContext(), + }; var modelBindingContext = new DefaultModelBindingContext() { - ActionContext = new ActionContext() - { - HttpContext = new DefaultHttpContext(), - }, - ModelState = new ModelStateDictionary(), + ActionContext = actionContext, + ModelState = actionContext.ModelState, ValidationState = new ValidationStateDictionary(), }; @@ -495,14 +567,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders valueProvider.Add(kvp.Key, string.Empty); } - var bindingContext = new DefaultModelBindingContext - { - ModelMetadata = metadata, - ModelName = "someName", - ModelState = new ModelStateDictionary(), - ValueProvider = valueProvider, - ValidationState = new ValidationStateDictionary(), - }; + var bindingContext = CreateContext(); + bindingContext.ModelMetadata = metadata; + bindingContext.ModelName = "someName"; + bindingContext.ValueProvider = valueProvider; return bindingContext; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/EnumTypeModelBinderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/EnumTypeModelBinderTest.cs index e7c4a2bf2b..aa8f50aa79 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/EnumTypeModelBinderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/EnumTypeModelBinderTest.cs @@ -42,14 +42,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding [InlineData(false, typeof(IntEnum))] [InlineData(false, typeof(FlagsEnum))] public async Task BindModel_AddsErrorToModelState_ForEmptyValue_AndNonNullableEnumTypes( - bool suprressBindingUndefinedValueToEnumType, + bool suppressBindingUndefinedValueToEnumType, Type modelType) { // Arrange var message = "The value '' is invalid."; var binderInfo = GetBinderAndContext( modelType, - suprressBindingUndefinedValueToEnumType, + suppressBindingUndefinedValueToEnumType, valueProviderValue: ""); var bindingContext = binderInfo.Item1; var binder = binderInfo.Item2; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderTestOfT.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderTestOfT.cs index fbcb7e5893..0a8f69b60f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderTestOfT.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderTestOfT.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { public abstract class FloatingPointTypeModelBinderTest where TFloatingPoint: struct { - public static TheoryData ConvertableTypeData + public static TheoryData ConvertibleTypeData { get { @@ -32,7 +32,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders protected abstract TFloatingPoint ThirtyTwoThousandPointOne { get; } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_ReturnsFailure_IfAttemptedValueCannotBeParsed(Type destinationType) { // Arrange @@ -51,7 +51,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_CreatesError_IfAttemptedValueCannotBeParsed(Type destinationType) { // Arrange @@ -76,7 +76,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_CreatesError_IfAttemptedValueCannotBeCompletelyParsed(Type destinationType) { // Arrange @@ -100,7 +100,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedWhitespace(Type destinationType) { // Arrange @@ -124,7 +124,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedDecimal(Type destinationType) { // Arrange @@ -148,7 +148,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedThousandsSeparator(Type destinationType) { // Arrange @@ -172,7 +172,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_ReturnsFailed_IfValueProviderEmpty(Type destinationType) { // Arrange @@ -236,7 +236,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_Twelve(Type destinationType) { // Arrange @@ -257,7 +257,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] [ReplaceCulture("en-GB", "en-GB")] public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_TwelvePointFive(Type destinationType) { @@ -279,7 +279,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_FrenchTwelvePointFive(Type destinationType) { // Arrange @@ -300,7 +300,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_ThirtyTwoThousand(Type destinationType) { // Arrange @@ -321,7 +321,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_ThirtyTwoThousandPointOne(Type destinationType) { // Arrange @@ -342,7 +342,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_FrenchThirtyTwoThousandPointOne(Type destinationType) { // Arrange diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FormCollectionModelBinderProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FormCollectionModelBinderProviderTest.cs index 8562af3410..38b5a0d65d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FormCollectionModelBinderProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FormCollectionModelBinderProviderTest.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { [Theory] [InlineData(typeof(FormCollection))] - [InlineData(typeof(DerviedFormCollection))] + [InlineData(typeof(DerivedFormCollection))] public void Create_ThrowsException_ForFormCollectionModelType(Type modelType) { // Arrange @@ -62,9 +62,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { } - private class DerviedFormCollection : FormCollection + private class DerivedFormCollection : FormCollection { - public DerviedFormCollection() : base(fields: null, files: null) { } + public DerivedFormCollection() : base(fields: null, files: null) { } } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FormFileModelBinderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FormFileModelBinderTest.cs index d738cdc6ff..40f025b53d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FormFileModelBinderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FormFileModelBinderTest.cs @@ -20,8 +20,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders public async Task FormFileModelBinder_SingleFile_BindSuccessful() { // Arrange - var formFiles = new FormFileCollection(); - formFiles.Add(GetMockFormFile("file", "file1.txt")); + var formFiles = new FormFileCollection + { + GetMockFormFile("file", "file1.txt") + }; var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); var bindingContext = GetBindingContext(typeof(IEnumerable), httpContext); var binder = new FormFileModelBinder(NullLoggerFactory.Instance); @@ -38,6 +40,192 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.Null(entry.Metadata); } + [Fact] + public async Task FormFileModelBinder_SingleFileAtTopLevel_BindSuccessfully_WithEmptyModelName() + { + // Arrange + var formFiles = new FormFileCollection + { + GetMockFormFile("file", "file1.txt") + }; + + var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); + var binder = new FormFileModelBinder(NullLoggerFactory.Instance); + + // Mimic ParameterBinder overwriting ModelName on top level model. In this top-level binding case, + // FormFileModelBinder uses FieldName from the get-go. (OriginalModelName will be checked but ignored.) + var bindingContext = DefaultModelBindingContext.CreateBindingContext( + new ActionContext { HttpContext = httpContext }, + Mock.Of(), + new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)), + bindingInfo: null, + modelName: "file"); + bindingContext.ModelName = string.Empty; + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + + var entry = bindingContext.ValidationState[bindingContext.Result.Model]; + Assert.False(entry.SuppressValidation); + Assert.Equal("file", entry.Key); + Assert.Null(entry.Metadata); + } + + [Fact] + public async Task FormFileModelBinder_SingleFileWithinTopLevelPoco_BindSuccessfully() + { + // Arrange + const string propertyName = nameof(NestedFormFiles.Files); + var formFiles = new FormFileCollection + { + GetMockFormFile($"{propertyName}", "file1.txt") + }; + + var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); + var binder = new FormFileModelBinder(NullLoggerFactory.Instance); + + // In this non-top-level binding case, FormFileModelBinder tries ModelName and succeeds. + var propertyInfo = typeof(NestedFormFiles).GetProperty(propertyName); + var metadata = new EmptyModelMetadataProvider().GetMetadataForProperty( + propertyInfo, + propertyInfo.PropertyType); + var bindingContext = DefaultModelBindingContext.CreateBindingContext( + new ActionContext { HttpContext = httpContext }, + Mock.Of(), + metadata, + bindingInfo: null, + modelName: "FileList"); + bindingContext.IsTopLevelObject = false; + bindingContext.Model = new FileList(); + bindingContext.ModelName = propertyName; + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + + var entry = bindingContext.ValidationState[bindingContext.Result.Model]; + Assert.False(entry.SuppressValidation); + Assert.Equal($"{propertyName}", entry.Key); + Assert.Null(entry.Metadata); + } + + [Fact] + public async Task FormFileModelBinder_SingleFileWithinTopLevelPoco_BindSuccessfully_WithShortenedModelName() + { + // Arrange + const string propertyName = nameof(NestedFormFiles.Files); + var formFiles = new FormFileCollection + { + GetMockFormFile($"FileList.{propertyName}", "file1.txt") + }; + + var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); + var binder = new FormFileModelBinder(NullLoggerFactory.Instance); + + // Mimic ParameterBinder overwriting ModelName on top level model then ComplexTypeModelBinder entering a + // nested context for the NestedFormFiles property. In this non-top-level binding case, FormFileModelBinder + // tries ModelName then falls back to add an (OriginalModelName + ".") prefix. + var propertyInfo = typeof(NestedFormFiles).GetProperty(propertyName); + var metadata = new EmptyModelMetadataProvider().GetMetadataForProperty( + propertyInfo, + propertyInfo.PropertyType); + var bindingContext = DefaultModelBindingContext.CreateBindingContext( + new ActionContext { HttpContext = httpContext }, + Mock.Of(), + metadata, + bindingInfo: null, + modelName: "FileList"); + bindingContext.IsTopLevelObject = false; + bindingContext.Model = new FileList(); + bindingContext.ModelName = propertyName; + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + + var entry = bindingContext.ValidationState[bindingContext.Result.Model]; + Assert.False(entry.SuppressValidation); + Assert.Equal($"FileList.{propertyName}", entry.Key); + Assert.Null(entry.Metadata); + } + + [Fact] + public async Task FormFileModelBinder_SingleFileWithinTopLevelDictionary_BindSuccessfully() + { + // Arrange + var formFiles = new FormFileCollection + { + GetMockFormFile("[myFile]", "file1.txt") + }; + + var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); + var binder = new FormFileModelBinder(NullLoggerFactory.Instance); + + // In this non-top-level binding case, FormFileModelBinder tries ModelName and succeeds. + var bindingContext = DefaultModelBindingContext.CreateBindingContext( + new ActionContext { HttpContext = httpContext }, + Mock.Of(), + new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)), + bindingInfo: null, + modelName: "FileDictionary"); + bindingContext.IsTopLevelObject = false; + bindingContext.ModelName = "[myFile]"; + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + + var entry = bindingContext.ValidationState[bindingContext.Result.Model]; + Assert.False(entry.SuppressValidation); + Assert.Equal("[myFile]", entry.Key); + Assert.Null(entry.Metadata); + } + + [Fact] + public async Task FormFileModelBinder_SingleFileWithinTopLevelDictionary_BindSuccessfully_WithShortenedModelName() + { + // Arrange + var formFiles = new FormFileCollection + { + GetMockFormFile("FileDictionary[myFile]", "file1.txt") + }; + + var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); + var binder = new FormFileModelBinder(NullLoggerFactory.Instance); + + // Mimic ParameterBinder overwriting ModelName on top level model then DictionaryModelBinder entering a + // nested context for the KeyValuePair.Value property. In this non-top-level binding case, + // FormFileModelBinder tries ModelName then falls back to add an OriginalModelName prefix. + var bindingContext = DefaultModelBindingContext.CreateBindingContext( + new ActionContext { HttpContext = httpContext }, + Mock.Of(), + new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)), + bindingInfo: null, + modelName: "FileDictionary"); + bindingContext.IsTopLevelObject = false; + bindingContext.ModelName = "[myFile]"; + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + + var entry = bindingContext.ValidationState[bindingContext.Result.Model]; + Assert.False(entry.SuppressValidation); + Assert.Equal("FileDictionary[myFile]", entry.Key); + Assert.Null(entry.Metadata); + } + [Fact] public async Task FormFileModelBinder_ExpectMultipleFiles_BindSuccessful() { @@ -127,8 +315,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders public async Task FormFileModelBinder_ReturnsFailedResult_WhenNamesDoNotMatch() { // Arrange - var formFiles = new FormFileCollection(); - formFiles.Add(GetMockFormFile("different name", "file1.txt")); + var formFiles = new FormFileCollection + { + GetMockFormFile("different name", "file1.txt") + }; var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); var bindingContext = GetBindingContext(typeof(IFormFile), httpContext); var binder = new FormFileModelBinder(NullLoggerFactory.Instance); @@ -147,9 +337,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders public async Task FormFileModelBinder_UsesFieldNameForTopLevelObject(bool isTopLevel, string expected) { // Arrange - var formFiles = new FormFileCollection(); - formFiles.Add(GetMockFormFile("FieldName", "file1.txt")); - formFiles.Add(GetMockFormFile("ModelName", "file1.txt")); + var formFiles = new FormFileCollection + { + GetMockFormFile("FieldName", "file1.txt"), + GetMockFormFile("ModelName", "file1.txt") + }; var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); var bindingContext = GetBindingContext(typeof(IFormFile), httpContext); @@ -173,8 +365,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders public async Task FormFileModelBinder_ReturnsFailedResult_WithEmptyContentDisposition() { // Arrange - var formFiles = new FormFileCollection(); - formFiles.Add(new Mock().Object); + var formFiles = new FormFileCollection + { + new Mock().Object + }; var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); var bindingContext = GetBindingContext(typeof(IFormFile), httpContext); var binder = new FormFileModelBinder(NullLoggerFactory.Instance); @@ -191,8 +385,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders public async Task FormFileModelBinder_ReturnsFailedResult_WithNoFileNameAndZeroLength() { // Arrange - var formFiles = new FormFileCollection(); - formFiles.Add(GetMockFormFile("file", "")); + var formFiles = new FormFileCollection + { + GetMockFormFile("file", "") + }; var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles)); var bindingContext = GetBindingContext(typeof(IFormFile), httpContext); var binder = new FormFileModelBinder(NullLoggerFactory.Instance); @@ -323,5 +519,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private class FileList : List { } + + private class NestedFormFiles + { + public FileList Files { get; } + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs index 30623f3c72..e50098b146 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs @@ -66,7 +66,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); } - public static TheoryData ConvertableTypeData + public static TheoryData ConvertibleTypeData { get { @@ -92,7 +92,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_ReturnsFailure_IfTypeCanBeConverted_AndConversionFails(Type destinationType) { // Arrange @@ -112,7 +112,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [MemberData(nameof(ConvertableTypeData))] + [MemberData(nameof(ConvertibleTypeData))] public async Task BindModel_CreatesError_WhenTypeConversionIsNull(Type destinationType) { // Arrange diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs index 6f3eb0830f..3352890707 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/HasValidatorsValidationMetadataProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/HasValidatorsValidationMetadataProviderTest.cs new file mode 100644 index 0000000000..94ebad7b3b --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/ModelBindingHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/ModelBindingHelperTest.cs index 062b5fddce..eab8c6a860 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/ModelBindingHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/ModelBindingHelperTest.cs @@ -84,7 +84,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, - new DefaultObjectValidator(metadataProvider, new[] { validator })); + new DefaultObjectValidator(metadataProvider, new[] { validator }, new MvcOptions())); // Assert Assert.False(result); @@ -124,7 +124,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, - new DefaultObjectValidator(metadataProvider, new[] { validator })); + new DefaultObjectValidator(metadataProvider, new[] { validator }, new MvcOptions())); // Assert Assert.True(result); @@ -202,7 +202,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, - new DefaultObjectValidator(metadataProvider, new[] { validator }), + new DefaultObjectValidator(metadataProvider, new[] { validator }, new MvcOptions()), propertyFilter); // Assert @@ -278,7 +278,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding TestModelMetadataProvider.CreateDefaultProvider(), GetModelBinderFactory(binderProviders), valueProvider, - new DefaultObjectValidator(metadataProvider, new[] { validator }), + new DefaultObjectValidator(metadataProvider, new[] { validator }, new MvcOptions()), m => m.IncludedProperty, m => m.MyProperty); @@ -329,7 +329,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, - new DefaultObjectValidator(metadataProvider, new[] { validator })); + new DefaultObjectValidator(metadataProvider, new[] { validator }, new MvcOptions())); // Assert // Includes everything. @@ -531,7 +531,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, - new DefaultObjectValidator(metadataProvider, new[] { validator }), + new DefaultObjectValidator(metadataProvider, new[] { validator }, new MvcOptions()), propertyFilter); // Assert @@ -599,7 +599,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, - new DefaultObjectValidator(metadataProvider, new[] { validator })); + new DefaultObjectValidator(metadataProvider, new[] { validator }, new MvcOptions())); // Assert Assert.True(result); @@ -607,7 +607,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding } [Fact] - public async Task TryUpdataModel_ModelTypeDifferentFromModel_Throws() + public async Task TryUpdateModel_ModelTypeDifferentFromModel_Throws() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/ParameterBinderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/ParameterBinderTest.cs index 7a605c4640..9464e6def7 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/ParameterBinderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/ParameterBinderTest.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.DataAnnotations; @@ -15,6 +16,7 @@ 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.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -61,7 +63,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding [Theory] [MemberData(nameof(BindModelAsyncData))] - public async Task BindModelAsync_PassesExpectedBindingInfoAndMetadata_IfPrefixDoesNotMatch( + public async Task ObsoleteBindModelAsync_PassesExpectedBindingInfoAndMetadata_IfPrefixDoesNotMatch( BindingInfo parameterBindingInfo, string metadataBinderModelName, string parameterName, @@ -115,13 +117,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding var controllerContext = GetControllerContext(); // Act & Assert +#pragma warning disable CS0618 // Type or member is obsolete await parameterBinder.BindModelAsync(controllerContext, new SimpleValueProvider(), parameterDescriptor); +#pragma warning restore CS0618 // Type or member is obsolete Assert.True(binderExecuted); } [Fact] - public async Task BindModelAsync_PassesExpectedBindingInfoAndMetadata_IfPrefixMatches() + public async Task ObsoleteBindModelAsync_PassesExpectedBindingInfoAndMetadata_IfPrefixMatches() { // Arrange var expectedModelName = "expectedName"; @@ -173,7 +177,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding var controllerContext = GetControllerContext(); // Act & Assert +#pragma warning disable CS0618 // Type or member is obsolete await argumentBinder.BindModelAsync(controllerContext, valueProvider, parameterDescriptor); +#pragma warning restore CS0618 // Type or member is obsolete Assert.True(binderExecuted); } @@ -493,6 +499,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding } [Fact] + [ReplaceCulture] public async Task BindModelAsync_ForParameter_UsesValidationFromActualModel_WhenDerivedModelIsSet() { // Arrange @@ -515,7 +522,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding Mock.Of(), new DefaultObjectValidator( modelMetadataProvider, - new[] { TestModelValidatorProvider.CreateDefaultProvider() }), + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), _optionsAccessor, NullLoggerFactory.Instance); @@ -569,7 +577,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding Mock.Of(), new DefaultObjectValidator( modelMetadataProvider, - new[] { TestModelValidatorProvider.CreateDefaultProvider() }), + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), _optionsAccessor, NullLoggerFactory.Instance); @@ -601,6 +610,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding } [Fact] + [ReplaceCulture] public async Task BindModelAsync_ForProperty_UsesValidationFromActualModel_WhenDerivedModelIsSet() { // Arrange @@ -622,7 +632,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding Mock.Of(), new DefaultObjectValidator( modelMetadataProvider, - new[] { TestModelValidatorProvider.CreateDefaultProvider() }), + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), _optionsAccessor, NullLoggerFactory.Instance); @@ -675,7 +686,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding Mock.Of(), new DefaultObjectValidator( modelMetadataProvider, - new[] { TestModelValidatorProvider.CreateDefaultProvider() }), + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), _optionsAccessor, NullLoggerFactory.Instance); @@ -706,6 +718,188 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding }); } + // Regression test 1 for aspnet/Mvc#7963. ModelState should never be valid. + [Fact] + public async Task BindModelAsync_ForOverlappingParametersWithSuppressions_InValid_WithValidSecondParameter() + { + // Arrange + var parameterDescriptor = new ParameterDescriptor + { + Name = "patchDocument", + ParameterType = typeof(IJsonPatchDocument), + }; + + var actionContext = GetControllerContext(); + var modelState = actionContext.ModelState; + + // First ModelState key is not empty to match SimpleTypeModelBinder. + modelState.SetModelValue("id", "notAGuid", "notAGuid"); + modelState.AddModelError("id", "This is not valid."); + + var modelMetadataProvider = new TestModelMetadataProvider(); + modelMetadataProvider.ForType().ValidationDetails(v => v.ValidateChildren = false); + var modelMetadata = modelMetadataProvider.GetMetadataForType(typeof(IJsonPatchDocument)); + + var parameterBinder = new ParameterBinder( + modelMetadataProvider, + Mock.Of(), + new DefaultObjectValidator( + modelMetadataProvider, + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), + _optionsAccessor, + NullLoggerFactory.Instance); + + // BodyModelBinder does not update ModelState in success case. + var modelBindingResult = ModelBindingResult.Success(new JsonPatchDocument()); + var modelBinder = CreateMockModelBinder(modelBindingResult); + + // Act + var result = await parameterBinder.BindModelAsync( + actionContext, + modelBinder, + new SimpleValueProvider(), + parameterDescriptor, + modelMetadata, + value: null); + + // Assert + Assert.True(result.IsModelSet); + Assert.False(modelState.IsValid); + Assert.Collection( + modelState, + kvp => + { + Assert.Equal("id", kvp.Key); + Assert.Equal(ModelValidationState.Invalid, kvp.Value.ValidationState); + var error = Assert.Single(kvp.Value.Errors); + Assert.Equal("This is not valid.", error.ErrorMessage); + }); + } + + // Regression test 2 for aspnet/Mvc#7963. ModelState should never be valid. + [Fact] + public async Task BindModelAsync_ForOverlappingParametersWithSuppressions_InValid_WithInValidSecondParameter() + { + // Arrange + var parameterDescriptor = new ParameterDescriptor + { + Name = "patchDocument", + ParameterType = typeof(IJsonPatchDocument), + }; + + var actionContext = GetControllerContext(); + var modelState = actionContext.ModelState; + + // First ModelState key is not empty to match SimpleTypeModelBinder. + modelState.SetModelValue("id", "notAGuid", "notAGuid"); + modelState.AddModelError("id", "This is not valid."); + + // Second ModelState key is empty to match BodyModelBinder. + modelState.AddModelError(string.Empty, "This is also not valid."); + + var modelMetadataProvider = new TestModelMetadataProvider(); + modelMetadataProvider.ForType().ValidationDetails(v => v.ValidateChildren = false); + var modelMetadata = modelMetadataProvider.GetMetadataForType(typeof(IJsonPatchDocument)); + + var parameterBinder = new ParameterBinder( + modelMetadataProvider, + Mock.Of(), + new DefaultObjectValidator( + modelMetadataProvider, + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), + _optionsAccessor, + NullLoggerFactory.Instance); + + var modelBindingResult = ModelBindingResult.Failed(); + var modelBinder = CreateMockModelBinder(modelBindingResult); + + // Act + var result = await parameterBinder.BindModelAsync( + actionContext, + modelBinder, + new SimpleValueProvider(), + parameterDescriptor, + modelMetadata, + value: null); + + // Assert + Assert.False(result.IsModelSet); + Assert.False(modelState.IsValid); + Assert.Collection( + modelState, + kvp => + { + Assert.Empty(kvp.Key); + Assert.Equal(ModelValidationState.Invalid, kvp.Value.ValidationState); + var error = Assert.Single(kvp.Value.Errors); + Assert.Equal("This is also not valid.", error.ErrorMessage); + }, + kvp => + { + Assert.Equal("id", kvp.Key); + Assert.Equal(ModelValidationState.Invalid, kvp.Value.ValidationState); + var error = Assert.Single(kvp.Value.Errors); + Assert.Equal("This is not valid.", error.ErrorMessage); + }); + } + + // Regression test for aspnet/Mvc#8078. Later parameter should not mark entry as valid. + [Fact] + public async Task BindModelAsync_ForOverlappingParameters_InValid_WithInValidFirstParameterAndSecondNull() + { + // Arrange + var parameterDescriptor = new ParameterDescriptor + { + BindingInfo = new BindingInfo + { + BinderModelName = "id", + }, + Name = "identifier", + ParameterType = typeof(string), + }; + + var actionContext = GetControllerContext(); + var modelState = actionContext.ModelState; + + // Mimic ModelStateEntry when first parameter is [FromRoute] int id and request URI is /api/values/notAnInt + modelState.SetModelValue("id", "notAnInt", "notAnInt"); + modelState.AddModelError("id", "This is not valid."); + + var modelMetadataProvider = new TestModelMetadataProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForType(typeof(string)); + var parameterBinder = new ParameterBinder( + modelMetadataProvider, + Mock.Of(), + new DefaultObjectValidator( + modelMetadataProvider, + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), + _optionsAccessor, + NullLoggerFactory.Instance); + + // Mimic result when second parameter is [FromQuery(Name = "id")] string identifier and query is ?id + var modelBindingResult = ModelBindingResult.Success(null); + var modelBinder = CreateMockModelBinder(modelBindingResult); + + // Act + var result = await parameterBinder.BindModelAsync( + actionContext, + modelBinder, + new SimpleValueProvider(), + parameterDescriptor, + modelMetadata, + value: null); + + // Assert + Assert.True(result.IsModelSet); + Assert.False(modelState.IsValid); + var keyValuePair = Assert.Single(modelState); + Assert.Equal("id", keyValuePair.Key); + Assert.Equal(ModelValidationState.Invalid, keyValuePair.Value.ValidationState); + } + private static ControllerContext GetControllerContext() { var services = new ServiceCollection(); @@ -760,7 +954,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding mockModelBinderFactory.Object, new DefaultObjectValidator( mockModelMetadataProvider.Object, - new[] { GetModelValidatorProvider(validator) }), + new[] { GetModelValidatorProvider(validator) }, + new MvcOptions()), optionsAccessor, loggerFactory ?? NullLoggerFactory.Instance); } @@ -813,25 +1008,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding return mockValueProvider.Object; } - private static IModelValidatorProvider CreateMockValidatorProvider(IModelValidator validator = null) - { - var mockValidator = new Mock(); - mockValidator - .Setup(o => o.CreateValidators( - It.IsAny())) - .Callback(context => - { - if (validator != null) - { - foreach (var result in context.Results) - { - result.Validator = validator; - } - } - }); - return mockValidator.Object; - } - private class Person : IEquatable, IEquatable { public string Name { get; set; } @@ -862,9 +1038,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding public string DerivedProperty { get; set; } } - [Required] - private Person PersonProperty { get; set; } - public abstract class FakeModelMetadata : ModelMetadata { public FakeModelMetadata() diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/RouteValueProviderTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/RouteValueProviderTests.cs index 8c8c050ae2..a013595eac 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/RouteValueProviderTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/RouteValueProviderTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Globalization; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; using Xunit; namespace Microsoft.AspNetCore.Mvc.ModelBinding @@ -45,6 +46,44 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding Assert.Equal("test-value", (string)result); } + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void GetValueProvider_ReturnsValue_UsesInvariantCulture() + { + // Arrange + var values = new RouteValueDictionary(new Dictionary + { + { "test-key", new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)) }, + }); + var provider = new RouteValueProvider(BindingSource.Query, values); + + // Act + var result = provider.GetValue("test-key"); + + // Assert + Assert.Equal("10/31/2018 07:37:38 -07:00", (string)result); + } + + [Fact] + public void GetValueProvider_ReturnsValue_UsesSpecifiedCulture() + { + // Arrange + var values = new RouteValueDictionary(new Dictionary + { + { "test-key", new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)) }, + }); + var provider = new RouteValueProvider(BindingSource.Query, values, new CultureInfo("de-CH")); + + // de-CH culture is slightly different on Windows versus other platforms. + var expected = TestPlatformHelper.IsWindows ? "31.10.2018 07:37:38 -07:00" : "31.10.18 07:37:38 -07:00"; + + // Act + var result = provider.GetValue("test-key"); + + // Assert + Assert.Equal(expected, (string)result); + } + [Fact] public void ContainsPrefix_ReturnsNullValue_IfKeyIsPresent() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/TestModelBinderProviderContext.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/TestModelBinderProviderContext.cs index 514e012c76..d0c29dae8d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/TestModelBinderProviderContext.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/TestModelBinderProviderContext.cs @@ -36,13 +36,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding BindingSource = Metadata.BindingSource, PropertyFilterProvider = Metadata.PropertyFilterProvider, }; - Services = GetServices(); + + (Services, MvcOptions) = GetServicesAndOptions(); } public override BindingInfo BindingInfo => _bindingInfo; public override ModelMetadata Metadata { get; } + public MvcOptions MvcOptions { get; } + public override IModelMetadataProvider MetadataProvider { get; } public override IServiceProvider Services { get; } @@ -77,12 +80,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding _binderCreators.Add((m) => m.Equals(metadata) ? binderCreator() : null); } - private static IServiceProvider GetServices() + private static (IServiceProvider, MvcOptions) GetServicesAndOptions() { var services = new ServiceCollection(); services.AddSingleton(); - services.AddSingleton(Options.Create(new MvcOptions())); - return services.BuildServiceProvider(); + + var mvcOptions = new MvcOptions(); + services.AddSingleton(Options.Create(mvcOptions)); + + return (services.BuildServiceProvider(), mvcOptions); } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultModelValidatorProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Validation/DefaultModelValidatorProviderTest.cs similarity index 88% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultModelValidatorProviderTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Validation/DefaultModelValidatorProviderTest.cs index 680d46ff30..bc8e9d4aa4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultModelValidatorProviderTest.cs +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ObjectResultTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ObjectResultTests.cs index 2c0e9ab003..7b818ad97b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ObjectResultTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ObjectResultTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -61,6 +62,38 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(404, actionContext.HttpContext.Response.StatusCode); } + [Fact] + public async Task ObjectResult_ExecuteResultAsync_SetsProblemDetailsStatus() + { + // Arrange + var modelState = new ModelStateDictionary(); + + var details = new ValidationProblemDetails(modelState); + + var result = new ObjectResult(details) + { + StatusCode = StatusCodes.Status422UnprocessableEntity, + Formatters = new FormatterCollection() + { + new NoOpOutputFormatter(), + }, + }; + + var actionContext = new ActionContext() + { + HttpContext = new DefaultHttpContext() + { + RequestServices = CreateServices(), + } + }; + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + Assert.Equal(StatusCodes.Status422UnprocessableEntity, details.Status.Value); + } + private static IServiceProvider CreateServices() { var services = new ServiceCollection(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/PhysicalFileResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/PhysicalFileResultTest.cs index 69dd1a4550..e6173fd070 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/PhysicalFileResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/PhysicalFileResultTest.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ProducesAttributeTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ProducesAttributeTests.cs index 49084a2c91..4c3e9ae7a3 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ProducesAttributeTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ProducesAttributeTests.cs @@ -8,7 +8,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters.Internal; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Primitives; using Moq; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RedirectResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RedirectResultTest.cs index dc37c52ae7..61d583b998 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RedirectResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RedirectResultTest.cs @@ -4,10 +4,10 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -146,13 +146,15 @@ namespace Microsoft.AspNetCore.Mvc var serviceProvider = GetServiceProvider(); httpContext.Setup(o => o.Response) - .Returns(response); + .Returns(response); httpContext.SetupGet(o => o.RequestServices) - .Returns(serviceProvider); + .Returns(serviceProvider); httpContext.SetupGet(o => o.Items) - .Returns(new ItemsDictionary()); + .Returns(new ItemsDictionary()); httpContext.Setup(o => o.Request.PathBase) - .Returns(new PathString(appRoot)); + .Returns(new PathString(appRoot)); + httpContext.SetupGet(h => h.Features) + .Returns(new FeatureCollection()); return httpContext.Object; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RedirectToPageResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RedirectToPageResultTest.cs index 40026e2130..17321fe0f6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RedirectToPageResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RedirectToPageResultTest.cs @@ -7,8 +7,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.Internal; -using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Testing; @@ -87,11 +85,11 @@ namespace Microsoft.AspNetCore.Mvc RequestServices = CreateServices(), }; - var pageContext = new PageContext + var pageContext = new ActionContext { HttpContext = httpContext, RouteData = new RouteData(), - ActionDescriptor = new CompiledPageActionDescriptor(), + ActionDescriptor = new ActionDescriptor(), }; pageContext.RouteData.Values.Add("page", "/A/Redirecting/Page"); @@ -144,7 +142,7 @@ namespace Microsoft.AspNetCore.Mvc RequestServices = CreateServices(), }; - var pageContext = new PageContext + var pageContext = new ActionContext { HttpContext = httpContext, RouteData = new RouteData(), diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RequestFormLimitsAttributeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RequestFormLimitsAttributeTest.cs index 8b8cbfa48d..df9eae7c9c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RequestFormLimitsAttributeTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RequestFormLimitsAttributeTest.cs @@ -21,10 +21,10 @@ namespace Microsoft.AspNetCore.Mvc // Act & Assert foreach (var property in formOptionsProperties) { - var formLimiAttributeProperty = formLimitsAttributeProperties + var formLimitAttributeProperty = formLimitsAttributeProperties .Where(pi => property.Name == pi.Name && pi.PropertyType == property.PropertyType) .SingleOrDefault(); - Assert.NotNull(formLimiAttributeProperty); + Assert.NotNull(formLimitAttributeProperty); } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RequireHttpsAttributeTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RequireHttpsAttributeTests.cs index bac3672432..e1bd339e4e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RequireHttpsAttributeTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/RequireHttpsAttributeTests.cs @@ -196,7 +196,7 @@ namespace Microsoft.AspNetCore.Mvc [InlineData(null, false)] [InlineData(true, false)] [InlineData(false, true)] - public void OnAuthorization_RedirectsToHttpsEndpoint_WithSpecifiedStatusCodeAndrequireHttpsPermanentOption(bool? permanent, bool requireHttpsPermanent) + public void OnAuthorization_RedirectsToHttpsEndpoint_WithSpecifiedStatusCodeAndRequireHttpsPermanentOption(bool? permanent, bool requireHttpsPermanent) { var requestContext = new DefaultHttpContext(); requestContext.RequestServices = CreateServices(null, requireHttpsPermanent); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ConsumesMatcherPolicyTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ConsumesMatcherPolicyTest.cs new file mode 100644 index 0000000000..b0a59ce678 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ConsumesMatcherPolicyTest.cs @@ -0,0 +1,237 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.AspNetCore.Routing.Patterns; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + public class ConsumesMatcherPolicyTest + { + [Fact] + public void AppliesToEndpoints_EndpointWithoutMetadata_ReturnsFalse() + { + // Arrange + var endpoints = new[] { CreateEndpoint("/", null), }; + + var policy = CreatePolicy(); + + // Act + var result = policy.AppliesToEndpoints(endpoints); + + // Assert + Assert.False(result); + } + + [Fact] + public void AppliesToEndpoints_EndpointWithoutContentTypes_ReturnsFalse() + { + // Arrange + var endpoints = new[] + { + CreateEndpoint("/", new ConsumesMetadata(Array.Empty())), + }; + + var policy = CreatePolicy(); + + // Act + var result = policy.AppliesToEndpoints(endpoints); + + // Assert + Assert.False(result); + } + + [Fact] + public void AppliesToEndpoints_EndpointHasContentTypes_ReturnsTrue() + { + // Arrange + var endpoints = new[] + { + CreateEndpoint("/", new ConsumesMetadata(Array.Empty())), + CreateEndpoint("/", new ConsumesMetadata(new[] { "application/json", })), + }; + + var policy = CreatePolicy(); + + // Act + var result = policy.AppliesToEndpoints(endpoints); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetEdges_GroupsByContentType() + { + // Arrange + var endpoints = new[] + { + // These are arrange in an order that we won't actually see in a product scenario. It's done + // this way so we can verify that ordering is preserved by GetEdges. + CreateEndpoint("/", new ConsumesMetadata(new[] { "application/json", "application/*+json", })), + CreateEndpoint("/", new ConsumesMetadata(Array.Empty())), + CreateEndpoint("/", new ConsumesMetadata(new[] { "application/xml", "application/*+xml", })), + CreateEndpoint("/", new ConsumesMetadata(new[] { "application/*", })), + CreateEndpoint("/", new ConsumesMetadata(new[]{ "*/*", })), + }; + + var policy = CreatePolicy(); + + // Act + var edges = policy.GetEdges(endpoints); + + // Assert + Assert.Collection( + edges.OrderBy(e => e.State), + e => + { + Assert.Equal("*/*", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[4], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/*", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[3], endpoints[4], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/*+json", e.State); + Assert.Equal(new[] { endpoints[0], endpoints[1], endpoints[3], endpoints[4], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/*+xml", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[2], endpoints[3], endpoints[4], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/json", e.State); + Assert.Equal(new[] { endpoints[0], endpoints[1], endpoints[3], endpoints[4], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/xml", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[2], endpoints[3], endpoints[4], }, e.Endpoints.ToArray()); + }); + } + + [Fact] // See explanation in GetEdges for how this case is different + public void GetEdges_GroupsByContentType_CreatesHttp405Endpoint() + { + // Arrange + var endpoints = new[] + { + // These are arrange in an order that we won't actually see in a product scenario. It's done + // this way so we can verify that ordering is preserved by GetEdges. + CreateEndpoint("/", new ConsumesMetadata(new[] { "application/json", "application/*+json", })), + CreateEndpoint("/", new ConsumesMetadata(new[] { "application/xml", "application/*+xml", })), + CreateEndpoint("/", new ConsumesMetadata(new[] { "application/*", })), + }; + + var policy = CreatePolicy(); + + // Act + var edges = policy.GetEdges(endpoints); + + // Assert + Assert.Collection( + edges.OrderBy(e => e.State), + e => + { + Assert.Equal("*/*", e.State); + Assert.Equal(ConsumesMatcherPolicy.Http415EndpointDisplayName, Assert.Single(e.Endpoints).DisplayName); + }, + e => + { + Assert.Equal("application/*", e.State); + Assert.Equal(new[] { endpoints[2], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/*+json", e.State); + Assert.Equal(new[] { endpoints[0], endpoints[2], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/*+xml", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[2], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/json", e.State); + Assert.Equal(new[] { endpoints[0], endpoints[2], }, e.Endpoints.ToArray()); + }, + e => + { + Assert.Equal("application/xml", e.State); + Assert.Equal(new[] { endpoints[1], endpoints[2], }, e.Endpoints.ToArray()); + }); + + } + + [Theory] + [InlineData("image/png", 1)] + [InlineData("application/foo", 2)] + [InlineData("text/xml", 3)] + [InlineData("application/product+json", 6)] // application/json will match this + [InlineData("application/product+xml", 7)] // application/xml will match this + [InlineData("application/json", 6)] + [InlineData("application/xml", 7)] + public void BuildJumpTable_SortsEdgesByPriority(string contentType, int expected) + { + // Arrange + var edges = new PolicyJumpTableEdge[] + { + // In reverse order of how they should be processed + new PolicyJumpTableEdge("*/*", 1), + new PolicyJumpTableEdge("application/*", 2), + new PolicyJumpTableEdge("text/*", 3), + new PolicyJumpTableEdge("application/*+xml", 4), + new PolicyJumpTableEdge("application/*+json", 5), + new PolicyJumpTableEdge("application/json", 6), + new PolicyJumpTableEdge("application/xml", 7), + }; + + var policy = CreatePolicy(); + + var jumpTable = policy.BuildJumpTable(-1, edges); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = contentType; + + // Act + var actual = jumpTable.GetDestination(httpContext); + + // Assert + Assert.Equal(expected, actual); + } + + private static RouteEndpoint CreateEndpoint(string template, ConsumesMetadata consumesMetadata) + { + var metadata = new List(); + if (consumesMetadata != null) + { + metadata.Add(consumesMetadata); + } + + return new RouteEndpoint( + (context) => Task.CompletedTask, + RoutePatternFactory.Parse(template), + 0, + new EndpointMetadataCollection(metadata), + $"test: {template} - {string.Join(", ", consumesMetadata?.ContentTypes ?? Array.Empty())}"); + } + + private static ConsumesMatcherPolicy CreatePolicy() + { + return new ConsumesMatcherPolicy(); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ControllerLinkGeneratorExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ControllerLinkGeneratorExtensionsTest.cs new file mode 100644 index 0000000000..a07cad51cb --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/ControllerLinkGeneratorExtensionsTest.cs @@ -0,0 +1,224 @@ +// 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 System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.ObjectPool; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class ControllerLinkGeneratorExtensionsTest + { + [Fact] + public void GetPathByAction_WithHttpContext_PromotesAmbientValues() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { controller = "Home", }); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var path = linkGenerator.GetPathByAction( + httpContext, + action: "Index", + values: new RouteValueDictionary(new { query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByAction_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var path = linkGenerator.GetPathByAction( + action: "Index", + controller: "Home", + values: new RouteValueDictionary(new { query = "some?query" }), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByAction_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var path = linkGenerator.GetPathByAction( + httpContext, + action: "Index", + controller: "Home", + values: new RouteValueDictionary(new { query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByAction_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var path = linkGenerator.GetUriByAction( + action: "Index", + controller: "Home", + values: new RouteValueDictionary(new { query = "some?query" }), + "http", + new HostString("example.com"), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByAction_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "Home/Index/{id}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + var endpoint2 = CreateEndpoint( + "Home/Index/{id?}", + defaults: new { controller = "Home", action = "Index", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "Index", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { controller = "Home", action = "Index", }); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new HostString("example.com"); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var uri = linkGenerator.GetUriByAction( + httpContext, + values: new RouteValueDictionary(new { query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/Home/Index/?query=some%3Fquery#Fragment?", uri); + } + + private RouteEndpoint CreateEndpoint( + string template, + object defaults = null, + object requiredValues = null, + int order = 0, + object[] metadata = null) + { + return new RouteEndpoint( + (httpContext) => Task.CompletedTask, + RoutePatternFactory.Parse(template, defaults, parameterPolicies: null), + order, + new EndpointMetadataCollection(metadata ?? Array.Empty()), + null); + } + + private IServiceProvider CreateServices(IEnumerable endpoints) + { + if (endpoints == null) + { + endpoints = Enumerable.Empty(); + } + + var services = new ServiceCollection(); + services.AddOptions(); + services.AddLogging(); + services.AddRouting(); + services + .AddSingleton() + .AddSingleton(UrlEncoder.Default); + services.TryAddEnumerable(ServiceDescriptor.Singleton(new DefaultEndpointDataSource(endpoints))); + return services.BuildServiceProvider(); + } + + private LinkGenerator CreateLinkGenerator(params Endpoint[] endpoints) + { + var services = CreateServices(endpoints); + return services.GetRequiredService(); + } + + private HttpContext CreateHttpContext(object ambientValues = null) + { + var httpContext = new DefaultHttpContext(); + + var feature = new EndpointSelectorContext + { + RouteValues = new RouteValueDictionary(ambientValues) + }; + + httpContext.Features.Set(feature); + httpContext.Features.Set(feature); + return httpContext; + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/EndpointRoutingUrlHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/EndpointRoutingUrlHelperTest.cs new file mode 100644 index 0000000000..facb4e07e1 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/EndpointRoutingUrlHelperTest.cs @@ -0,0 +1,329 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + public class EndpointRoutingUrlHelperTest : UrlHelperTestBase + { + [Fact] + public void RouteUrl_WithRouteName_GeneratesUrl_UsingDefaults() + { + // Arrange + var endpoint1 = CreateEndpoint( + "api/orders/{id}", + defaults: new { controller = "Orders", action = "GetById" }, + requiredValues: new { controller = "Orders", action = "GetById" }, + routeName: "OrdersApi"); + var endpoint2 = CreateEndpoint( + "api/orders", + defaults: new { controller = "Orders", action = "GetAll" }, + requiredValues: new { controller = "Orders", action = "GetAll" }, + routeName: "OrdersApi"); + var urlHelper = CreateUrlHelper(new[] { endpoint1, endpoint2 }); + + // Act + var url = urlHelper.RouteUrl( + routeName: "OrdersApi", + values: new { }); + + // Assert + Assert.Equal("/" + endpoint2.RoutePattern.RawText, url); + } + + [Fact] + public void RouteUrl_WithRouteName_UsesAmbientValues() + { + // Arrange + var endpoint1 = CreateEndpoint( + "api/orders/{id}", + defaults: new { controller = "Orders", action = "GetById" }, + requiredValues: new { controller = "Orders", action = "GetById" }, + routeName: "OrdersApi"); + var endpoint2 = CreateEndpoint( + "api/orders", + defaults: new { controller = "Orders", action = "GetAll" }, + requiredValues: new { controller = "Orders", action = "GetAll" }, + routeName: "OrdersApi"); + var urlHelper = CreateUrlHelper(new[] { endpoint1, endpoint2 }); + + // Set the endpoint feature and current context just as a normal request to MVC app would be + var endpointFeature = new EndpointSelectorContext(); + urlHelper.ActionContext.HttpContext.Features.Set(endpointFeature); + urlHelper.ActionContext.HttpContext.Features.Set(endpointFeature); + endpointFeature.Endpoint = endpoint1; + endpointFeature.RouteValues = new RouteValueDictionary + { + ["controller"] = "Orders", + ["action"] = "GetById", + ["id"] = "500" + }; + + // Act + var url = urlHelper.RouteUrl( + routeName: "OrdersApi", + values: new { }); + + // Assert + Assert.Equal("/api/orders/500", url); + } + + [Fact] + public void RouteUrl_WithRouteName_UsesSuppliedValue_OverridingAmbientValue() + { + // Arrange + var endpoint1 = CreateEndpoint( + "api/orders/{id}", + defaults: new { controller = "Orders", action = "GetById" }, + requiredValues: new { controller = "Orders", action = "GetById" }, + routeName: "OrdersApi"); + var endpoint2 = CreateEndpoint( + "api/orders", + defaults: new { controller = "Orders", action = "GetAll" }, + requiredValues: new { controller = "Orders", action = "GetAll" }, + routeName: "OrdersApi"); + var urlHelper = CreateUrlHelper(new[] { endpoint1, endpoint2 }); + urlHelper.ActionContext.RouteData.Values["id"] = "500"; + + // Act + var url = urlHelper.RouteUrl( + routeName: "OrdersApi", + values: new { id = "10" }); + + // Assert + Assert.Equal("/api/orders/10", url); + } + + [Fact] + public void RouteUrl_DoesNotGenerateLink_ToEndpointsWithSuppressLinkGeneration() + { + // Arrange + var endpoint = CreateEndpoint( + "Home/Index", + defaults: new { controller = "Home", action = "Index" }, + requiredValues: new { controller = "Home", action = "Index" }, + metadataCollection: new EndpointMetadataCollection(new[] { new SuppressLinkGenerationMetadata() })); + var urlHelper = CreateUrlHelper(new[] { endpoint }); + + // Act + var url = urlHelper.RouteUrl(new { controller = "Home", action = "Index" }); + + // Assert + Assert.Null(url); + } + + protected override IUrlHelper CreateUrlHelper(string appRoot, string host, string protocol) + { + return CreateUrlHelper(Enumerable.Empty(), appRoot, host, protocol); + } + + protected override IUrlHelper CreateUrlHelperWithDefaultRoutes(string appRoot, string host, string protocol) + { + return CreateUrlHelper(GetDefaultEndpoints(), appRoot, host, protocol); + } + + protected override IUrlHelper CreateUrlHelperWithDefaultRoutes( + string appRoot, + string host, + string protocol, + string routeName, + string template) + { + var endpoints = GetDefaultEndpoints(); + endpoints.Add(new RouteEndpoint( + httpContext => Task.CompletedTask, + RoutePatternFactory.Parse(template), + 0, + EndpointMetadataCollection.Empty, + null)); + return CreateUrlHelper(endpoints, appRoot, host, protocol); + } + + protected override IUrlHelper CreateUrlHelper(ActionContext actionContext) + { + var httpContext = actionContext.HttpContext; + httpContext.Features.Set(new EndpointSelectorContext() + { + Endpoint = new Endpoint( + context => Task.CompletedTask, + EndpointMetadataCollection.Empty, + null) + }); + + var urlHelperFactory = httpContext.RequestServices.GetRequiredService(); + var urlHelper = urlHelperFactory.GetUrlHelper(actionContext); + Assert.IsType(urlHelper); + return urlHelper; + } + + protected override IServiceProvider CreateServices() + { + return CreateServices(Enumerable.Empty()); + } + + protected override IUrlHelper CreateUrlHelper( + string appRoot, + string host, + string protocol, + string routeName, + string template, + object defaults) + { + var endpoint = GetEndpoint(routeName, template, new RouteValueDictionary(defaults)); + var services = CreateServices(new[] { endpoint }); + var httpContext = CreateHttpContext(services, appRoot: "", host: null, protocol: null); + var actionContext = CreateActionContext(httpContext); + return CreateUrlHelper(actionContext); + } + + private IUrlHelper CreateUrlHelper(IEnumerable endpoints, ActionContext actionContext = null) + { + var serviceProvider = CreateServices(endpoints); + var httpContext = CreateHttpContext(serviceProvider, null, null, "http"); + actionContext = actionContext ?? CreateActionContext(httpContext); + return CreateUrlHelper(actionContext); + } + + private IUrlHelper CreateUrlHelper( + IEnumerable endpoints, + string appRoot, + string host, + string protocol) + { + var serviceProvider = CreateServices(endpoints); + var httpContext = CreateHttpContext(serviceProvider, appRoot, host, protocol); + var actionContext = CreateActionContext(httpContext); + return CreateUrlHelper(actionContext); + } + + private List GetDefaultEndpoints() + { + var endpoints = new List(); + endpoints.Add( + CreateEndpoint( + "home/newaction/{id}", + defaults: new { id = "defaultid", controller = "home", action = "newaction" }, + requiredValues: new { controller = "home", action = "newaction" }, + order: 1)); + endpoints.Add( + CreateEndpoint( + "home/contact/{id}", + defaults: new { id = "defaultid", controller = "home", action = "contact" }, + requiredValues: new { controller = "home", action = "contact" }, + order: 2)); + endpoints.Add( + CreateEndpoint( + "home2/newaction/{id}", + defaults: new { id = "defaultid", controller = "home2", action = "newaction" }, + requiredValues: new { controller = "home2", action = "newaction" }, + order: 3)); + endpoints.Add( + CreateEndpoint( + "home2/contact/{id}", + defaults: new { id = "defaultid", controller = "home2", action = "contact" }, + requiredValues: new { controller = "home2", action = "contact" }, + order: 4)); + endpoints.Add( + CreateEndpoint( + "home3/contact/{id}", + defaults: new { id = "defaultid", controller = "home3", action = "contact" }, + requiredValues: new { controller = "home3", action = "contact" }, + order: 5)); + endpoints.Add( + CreateEndpoint( + "named/home/newaction/{id}", + defaults: new { id = "defaultid", controller = "home", action = "newaction" }, + requiredValues: new { controller = "home", action = "newaction" }, + order: 6, + routeName: "namedroute")); + endpoints.Add( + CreateEndpoint( + "named/home2/newaction/{id}", + defaults: new { id = "defaultid", controller = "home2", action = "newaction" }, + requiredValues: new { controller = "home2", action = "newaction" }, + order: 7, + routeName: "namedroute")); + endpoints.Add( + CreateEndpoint( + "named/home/contact/{id}", + defaults: new { id = "defaultid", controller = "home", action = "contact" }, + requiredValues: new { controller = "home", action = "contact" }, + order: 8, + routeName: "namedroute")); + endpoints.Add( + CreateEndpoint( + "any/url", + defaults: new { }, + requiredValues: new { }, + order: 9, + routeName: "MyRouteName")); + endpoints.Add( + CreateEndpoint( + "api/orders/{id}", + defaults: new { controller = "Orders", action = "GetById" }, + requiredValues: new { controller = "Orders", action = "GetById" }, + order: 10, + routeName: "OrdersApi")); + return endpoints; + } + + private RouteEndpoint CreateEndpoint( + string template, + object defaults = null, + object requiredValues = null, + int order = 0, + string routeName = null, + EndpointMetadataCollection metadataCollection = null) + { + if (metadataCollection == null) + { + metadataCollection = new EndpointMetadataCollection( + new RouteValuesAddressMetadata(routeName, new RouteValueDictionary(requiredValues))); + } + + return new RouteEndpoint( + (httpContext) => Task.CompletedTask, + RoutePatternFactory.Parse(template, defaults, parameterPolicies: null), + order, + metadataCollection, + null); + } + + private IServiceProvider CreateServices(IEnumerable endpoints) + { + if (endpoints == null) + { + endpoints = Enumerable.Empty(); + } + + var services = GetCommonServices(); + services.AddRouting(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton(new DefaultEndpointDataSource(endpoints))); + services.TryAddSingleton(); + return services.BuildServiceProvider(); + } + + private RouteEndpoint GetEndpoint(string name, string template, RouteValueDictionary defaults) + { + return new RouteEndpoint( + c => Task.CompletedTask, + RoutePatternFactory.Parse(template, defaults, parameterPolicies: null), + 0, + EndpointMetadataCollection.Empty, + null); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/KnownRouteValueConstraintTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/KnownRouteValueConstraintTests.cs index e6ee1638f6..103b4ad14b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/KnownRouteValueConstraintTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/KnownRouteValueConstraintTests.cs @@ -2,7 +2,6 @@ // 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.Http; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -10,6 +9,8 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; using Moq; using Xunit; @@ -17,7 +18,47 @@ namespace Microsoft.AspNetCore.Mvc.Routing { public class KnownRouteValueConstraintTests { +#pragma warning disable CS0618 // Type or member is obsolete private readonly IRouteConstraint _constraint = new KnownRouteValueConstraint(); +#pragma warning restore CS0618 // Type or member is obsolete + + [Fact] + public void ResolveFromServices_InjectsServiceProvider_HttpContextNotNeeded() + { + // Arrange + var actionDescriptor = CreateActionDescriptor("testArea", + "testController", + "testAction"); + actionDescriptor.RouteValues.Add("randomKey", "testRandom"); + var descriptorCollectionProvider = CreateActionDescriptorCollectionProvider(actionDescriptor); + + var services = new ServiceCollection(); + services.AddRouting(); + services.AddSingleton(descriptorCollectionProvider); + + var routeOptionsSetup = new MvcCoreRouteOptionsSetup(); + services.Configure(routeOptionsSetup.Configure); + + var serviceProvider = services.BuildServiceProvider(); + + var inlineConstraintResolver = serviceProvider.GetRequiredService(); + var constraint = inlineConstraintResolver.ResolveConstraint("exists"); + + var values = new RouteValueDictionary() + { + { "area", "testArea" }, + { "controller", "testController" }, + { "action", "testAction" }, + { "randomKey", "testRandom" } + }; + + // Act + var knownRouteValueConstraint = Assert.IsType(constraint); + var match = knownRouteValueConstraint.Match(httpContext: null, route: null, "area", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(match); + } [Theory] [InlineData("area", RouteDirection.IncomingRequest)] @@ -55,8 +96,8 @@ namespace Microsoft.AspNetCore.Mvc.Routing { // Arrange var actionDescriptor = CreateActionDescriptor("testArea", - "testController", - "testAction"); + "testController", + "testAction"); actionDescriptor.RouteValues.Add("randomKey", "testRandom"); var httpContext = GetHttpContext(actionDescriptor); var route = Mock.Of(); @@ -115,8 +156,8 @@ namespace Microsoft.AspNetCore.Mvc.Routing public void RouteValue_IsNotAString_MatchFails(RouteDirection direction) { var actionDescriptor = CreateActionDescriptor("testArea", - controller: null, - action: null); + controller: null, + action: null); var httpContext = GetHttpContext(actionDescriptor); var route = Mock.Of(); var values = new RouteValueDictionary() @@ -157,7 +198,86 @@ namespace Microsoft.AspNetCore.Mvc.Routing ex.Message); } - private static HttpContext GetHttpContext(ActionDescriptor actionDescriptor) + [Theory] + [InlineData("area", RouteDirection.IncomingRequest)] + [InlineData("controller", RouteDirection.IncomingRequest)] + [InlineData("action", RouteDirection.IncomingRequest)] + [InlineData("randomKey", RouteDirection.IncomingRequest)] + [InlineData("area", RouteDirection.UrlGeneration)] + [InlineData("controller", RouteDirection.UrlGeneration)] + [InlineData("action", RouteDirection.UrlGeneration)] + [InlineData("randomKey", RouteDirection.UrlGeneration)] + public void ServiceInjected_RouteKey_Exists_MatchSucceeds(string keyName, RouteDirection direction) + { + // Arrange + var actionDescriptor = CreateActionDescriptor("testArea", + "testController", + "testAction"); + actionDescriptor.RouteValues.Add("randomKey", "testRandom"); + + var provider = CreateActionDescriptorCollectionProvider(actionDescriptor); + + var constraint = new KnownRouteValueConstraint(provider); + + var values = new RouteValueDictionary() + { + { "area", "testArea" }, + { "controller", "testController" }, + { "action", "testAction" }, + { "randomKey", "testRandom" } + }; + + // Act + var match = constraint.Match(httpContext: null, route: null, keyName, values, direction); + + // Assert + Assert.True(match); + } + + [Theory] + [InlineData(RouteDirection.IncomingRequest)] + [InlineData(RouteDirection.UrlGeneration)] + [ReplaceCulture("de-CH", "de-CH")] + public void ServiceInjected_RouteKey_Exists_UsesInvariantCulture(RouteDirection direction) + { + // Arrange + var actionDescriptor = CreateActionDescriptor("testArea", "testController", "testAction"); + actionDescriptor.RouteValues.Add("randomKey", "10/31/2018 07:37:38 -07:00"); + + var provider = CreateActionDescriptorCollectionProvider(actionDescriptor); + + var constraint = new KnownRouteValueConstraint(provider); + + var values = new RouteValueDictionary() + { + { "area", "testArea" }, + { "controller", "testController" }, + { "action", "testAction" }, + { "randomKey", new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)) }, + }; + + // Act + var match = constraint.Match(httpContext: null, route: null, "randomKey", values, direction); + + // Assert + Assert.True(match); + } + + private static HttpContext GetHttpContext(ActionDescriptor actionDescriptor, bool setupRequestServices = true) + { + var descriptorCollectionProvider = CreateActionDescriptorCollectionProvider(actionDescriptor); + + var context = new Mock(); + if (setupRequestServices) + { + context.Setup(o => o.RequestServices + .GetService(typeof(IActionDescriptorCollectionProvider))) + .Returns(descriptorCollectionProvider); + } + return context.Object; + } + + private static IActionDescriptorCollectionProvider CreateActionDescriptorCollectionProvider(ActionDescriptor actionDescriptor) { var actionProvider = new Mock(MockBehavior.Strict); @@ -173,15 +293,10 @@ namespace Microsoft.AspNetCore.Mvc.Routing .Setup(p => p.OnProvidersExecuted(It.IsAny())) .Verifiable(); - var descriptorCollectionProvider = new ActionDescriptorCollectionProvider( + var descriptorCollectionProvider = new DefaultActionDescriptorCollectionProvider( new[] { actionProvider.Object }, Enumerable.Empty()); - - var context = new Mock(); - context.Setup(o => o.RequestServices - .GetService(typeof(IActionDescriptorCollectionProvider))) - .Returns(descriptorCollectionProvider); - return context.Object; + return descriptorCollectionProvider; } private static ActionDescriptor CreateActionDescriptor(string area, string controller, string action) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/PageLinkGeneratorExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/PageLinkGeneratorExtensionsTest.cs new file mode 100644 index 0000000000..c0b84528e0 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/PageLinkGeneratorExtensionsTest.cs @@ -0,0 +1,222 @@ +// 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 System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.ObjectPool; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class PageLinkGeneratorExtensionsTest + { + [Fact] + public void GetPathByPage_WithHttpContext_PromotesAmbientValues() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers/{handler?}", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { page = "/About", id = 17, }); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var path = linkGenerator.GetPathByPage( + httpContext, + values: new RouteValueDictionary(new { id = 18, query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/About/18/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByPage_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers/{handler?}", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var path = linkGenerator.GetPathByPage( + page: "/Admin/ManageUsers", + handler: "Delete", + values: new RouteValueDictionary(new { user = "jamesnk", query = "some?query" }), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/Admin/ManageUsers/Delete/?user=jamesnk&query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByPage_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { page = "/Admin/ManageUsers", handler = "DeleteUser", }); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var path = linkGenerator.GetPathByPage( + httpContext, + page: "/About", + values: new RouteValueDictionary(new { id = 19, query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/About/19/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByPage_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var path = linkGenerator.GetUriByPage( + page: "/About", + handler: null, + values: new RouteValueDictionary(new { id = 19, query = "some?query" }), + "http", + new HostString("example.com"), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/About/19/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByPage_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = CreateEndpoint( + "About/{id}", + defaults: new { page = "/About", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/About", })) }); + var endpoint2 = CreateEndpoint( + "Admin/ManageUsers", + defaults: new { page = "/Admin/ManageUsers", }, + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { page = "/Admin/ManageUsers", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(new { page = "/Admin/ManageUsers", }); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new HostString("example.com"); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + // Act + var uri = linkGenerator.GetUriByPage( + httpContext, + values: new RouteValueDictionary(new { query = "some?query" }), + fragment: new FragmentString("#Fragment?"), + options: new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/Admin/ManageUsers/?query=some%3Fquery#Fragment?", uri); + } + + private RouteEndpoint CreateEndpoint( + string template, + object defaults = null, + object requiredValues = null, + int order = 0, + object[] metadata = null) + { + return new RouteEndpoint( + (httpContext) => Task.CompletedTask, + RoutePatternFactory.Parse(template, defaults, parameterPolicies: null), + order, + new EndpointMetadataCollection(metadata ?? Array.Empty()), + null); + } + + private IServiceProvider CreateServices(IEnumerable endpoints) + { + if (endpoints == null) + { + endpoints = Enumerable.Empty(); + } + + var services = new ServiceCollection(); + services.AddOptions(); + services.AddLogging(); + services.AddRouting(); + services + .AddSingleton() + .AddSingleton(UrlEncoder.Default); + services.TryAddEnumerable(ServiceDescriptor.Singleton(new DefaultEndpointDataSource(endpoints))); + return services.BuildServiceProvider(); + } + + private LinkGenerator CreateLinkGenerator(params Endpoint[] endpoints) + { + var services = CreateServices(endpoints); + return services.GetRequiredService(); + } + + private HttpContext CreateHttpContext(object ambientValues = null) + { + var httpContext = new DefaultHttpContext(); + + var feature = new EndpointSelectorContext + { + RouteValues = new RouteValueDictionary(ambientValues) + }; + + httpContext.Features.Set(feature); + httpContext.Features.Set(feature); + return httpContext; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperBaseTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperBaseTest.cs new file mode 100644 index 0000000000..9bb3f54043 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperBaseTest.cs @@ -0,0 +1,171 @@ +// 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 System.Text.Encodings.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ObjectPool; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + public class UrlHelperBaseTest + { + public static TheoryData GeneratePathFromRoute_HandlesLeadingAndTrailingSlashesData => + new TheoryData + { + { null, "", "/" }, + { null, "/", "/" }, + { null, "Hello", "/Hello" }, + { null, "/Hello", "/Hello" }, + { "/", "", "/" }, + { "/", "hello", "/hello" }, + { "/", "/hello", "/hello" }, + { "/hello", "", "/hello" }, + { "/hello/", "", "/hello/" }, + { "/hello", "/", "/hello/" }, + { "/hello/", "world", "/hello/world" }, + { "/hello/", "/world", "/hello/world" }, + { "/hello/", "/world 123", "/hello/world 123" }, + { "/hello/", "/world%20123", "/hello/world%20123" }, + }; + + [Theory] + [MemberData(nameof(GeneratePathFromRoute_HandlesLeadingAndTrailingSlashesData))] + public void AppendPathAndFragment_HandlesLeadingAndTrailingSlashes( + string appBase, + string virtualPath, + string expected) + { + // Arrange + var services = CreateServices(); + var httpContext = CreateHttpContext(services, appBase, host: null, protocol: null); + var builder = new StringBuilder(); + + // Act + UrlHelperBase.AppendPathAndFragment(builder, httpContext.Request.PathBase, virtualPath, string.Empty); + + // Assert + Assert.Equal(expected, builder.ToString()); + } + + [Theory] + [MemberData(nameof(GeneratePathFromRoute_HandlesLeadingAndTrailingSlashesData))] + public void AppendPathAndFragment_AppendsFragments( + string appBase, + string virtualPath, + string expected) + { + // Arrange + var fragmentValue = "fragment-value"; + expected += $"#{fragmentValue}"; + var services = CreateServices(); + var httpContext = CreateHttpContext(services, appBase, host: null, protocol: null); + var builder = new StringBuilder(); + + // Act + UrlHelperBase.AppendPathAndFragment(builder, httpContext.Request.PathBase, virtualPath, fragmentValue); + + // Assert + Assert.Equal(expected, builder.ToString()); + } + + [Theory] + [InlineData(null, null, null, "/", null, "/")] + [InlineData(null, null, null, "/Hello", null, "/Hello")] + [InlineData(null, null, null, "Hello", null, "/Hello")] + [InlineData("/", null, null, "", null, "/")] + [InlineData("/hello/", null, null, "/world", null, "/hello/world")] + [InlineData("/hello/", "https", "myhost", "/world", "fragment-value", "https://myhost/hello/world#fragment-value")] + public void GenerateUrl_FastAndSlowPathsReturnsExpected( + string appBase, + string protocol, + string host, + string virtualPath, + string fragment, + string expected) + { + // Arrange + var services = CreateServices(); + var httpContext = CreateHttpContext(services, appBase, host, protocol); + var actionContext = CreateActionContext(httpContext); + var urlHelper = new TestUrlHelper(actionContext); + + // Act + var url = urlHelper.GenerateUrl(protocol, host, virtualPath, fragment); + + // Assert + Assert.Equal(expected, url); + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddLogging(); + services.AddRouting(); + services + .AddSingleton() + .AddSingleton(UrlEncoder.Default); + + return services.BuildServiceProvider(); + } + + private static HttpContext CreateHttpContext( + IServiceProvider services, + string appRoot, + string host, + string protocol) + { + appRoot = string.IsNullOrEmpty(appRoot) ? string.Empty : appRoot; + host = string.IsNullOrEmpty(host) ? "localhost" : host; + + var context = new DefaultHttpContext(); + context.RequestServices = services; + context.Request.PathBase = new PathString(appRoot); + context.Request.Host = new HostString(host); + context.Request.Scheme = protocol; + return context; + } + + private static ActionContext CreateActionContext(HttpContext context) + { + return new ActionContext(context, new RouteData(), new ActionDescriptor()); + } + + private class TestUrlHelper : UrlHelperBase + { + public TestUrlHelper(ActionContext actionContext) : + base(actionContext) + { + } + + public override string Action(UrlActionContext actionContext) + { + throw new NotImplementedException(); + } + + public override string RouteUrl(UrlRouteContext routeContext) + { + throw new NotImplementedException(); + } + + public new string GenerateUrl( + string protocol, + string host, + string virtualPath, + string fragment) + { + return base.GenerateUrl( + protocol, + host, + virtualPath, + fragment); + } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperExtensionsTest.cs new file mode 100644 index 0000000000..b63c66e0fe --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperExtensionsTest.cs @@ -0,0 +1,638 @@ +// 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.Abstractions; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Core.Test.Routing +{ + public class UrlHelperExtensionsTest + { + [Fact] + public void Page_WithName_Works() + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData + { + Values = + { + { "page", "ambient-page" }, + } + }; + var actionContext = new ActionContext + { + RouteData = routeData, + }; + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("/TestPage"); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("/TestPage", value.Value); + }); + Assert.Null(actual.Host); + Assert.Null(actual.Protocol); + Assert.Null(actual.Fragment); + } + + public static TheoryData Page_WithNameAndRouteValues_WorksData + { + get => new TheoryData + { + { new { id = 10 } }, + { + new Dictionary + { + ["id"] = 10, + } + }, + { + new RouteValueDictionary + { + ["id"] = 10, + } + }, + }; + } + + [Theory] + [MemberData(nameof(Page_WithNameAndRouteValues_WorksData))] + public void Page_WithNameAndRouteValues_Works(object values) + { + // Arrange + UrlRouteContext actual = null; + var urlHelper = CreateMockUrlHelper(); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("/TestPage", values); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("id", value.Key); + Assert.Equal(10, value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("/TestPage", value.Value); + }); + Assert.Null(actual.Host); + Assert.Null(actual.Protocol); + Assert.Null(actual.Fragment); + } + + [Fact] + public void Page_WithNameRouteValuesAndProtocol_Works() + { + // Arrange + UrlRouteContext actual = null; + var urlHelper = CreateMockUrlHelper(); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("/TestPage", pageHandler: null, values: new { id = 13 }, protocol: "https"); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("id", value.Key); + Assert.Equal(13, value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("/TestPage", value.Value); + }); + Assert.Equal("https", actual.Protocol); + Assert.Null(actual.Host); + Assert.Null(actual.Fragment); + } + + [Fact] + public void Page_WithNameRouteValuesProtocolAndHost_Works() + { + // Arrange + UrlRouteContext actual = null; + var urlHelper = CreateMockUrlHelper(); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("/TestPage", pageHandler: null, values: new { id = 13 }, protocol: "https", host: "mytesthost"); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("id", value.Key); + Assert.Equal(13, value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("/TestPage", value.Value); + }); + Assert.Equal("https", actual.Protocol); + Assert.Equal("mytesthost", actual.Host); + Assert.Null(actual.Fragment); + } + + [Fact] + public void Page_WithNameRouteValuesProtocolHostAndFragment_Works() + { + // Arrange + UrlRouteContext actual = null; + var urlHelper = CreateMockUrlHelper(); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("/TestPage", "test-handler", new { id = 13 }, "https", "mytesthost", "#toc"); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("id", value.Key); + Assert.Equal(13, value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("/TestPage", value.Value); + }, + value => + { + Assert.Equal("handler", value.Key); + Assert.Equal("test-handler", value.Value); + }); + Assert.Equal("https", actual.Protocol); + Assert.Equal("mytesthost", actual.Host); + Assert.Equal("#toc", actual.Fragment); + } + + [Fact] + public void Page_UsesAmbientRouteValue_WhenPageIsNull() + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData + { + Values = + { + { "page", "ambient-page" }, + } + }; + var actionContext = new ActionContext + { + RouteData = routeData, + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + string page = null; + urlHelper.Object.Page(page, new { id = 13 }); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("id", value.Key); + Assert.Equal(13, value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("ambient-page", value.Value); + }); + } + + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void Page_UsesAmbientRouteValueAndInvariantCulture_WhenPageIsNotNull() + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData + { + Values = + { + { "page", new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)) }, + } + }; + var actionContext = new ActionContext + { + ActionDescriptor = new ActionDescriptor + { + RouteValues = new Dictionary + { + { "page", "10/31/2018 07:37:38 -07:00" }, + }, + }, + RouteData = routeData, + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("New Page", new { id = 13 }); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("id", value.Key); + Assert.Equal(13, value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("10/31/New Page", value.Value); + }); + } + + [Fact] + public void Page_SetsHandlerToNull_IfValueIsNotSpecifiedInRouteValues() + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData + { + Values = + { + { "page", "ambient-page" }, + { "handler", "ambient-handler" }, + } + }; + var actionContext = new ActionContext + { + RouteData = routeData, + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + string page = null; + urlHelper.Object.Page(page, new { id = 13 }); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("id", value.Key); + Assert.Equal(13, value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("ambient-page", value.Value); + }, + value => + { + Assert.Equal("handler", value.Key); + Assert.Null(value.Value); + }); + } + + [Fact] + public void Page_UsesExplicitlySpecifiedHandlerValue() + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData + { + Values = + { + { "page", "ambient-page" }, + { "handler", "ambient-handler" }, + } + }; + var actionContext = new ActionContext + { + RouteData = routeData, + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + string page = null; + urlHelper.Object.Page(page, "exact-handler", new { handler = "route-value-handler" }); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("handler", value.Key); + Assert.Equal("exact-handler", value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("ambient-page", value.Value); + }); + } + + [Fact] + public void Page_UsesValueFromRouteValueIfPageHandlerIsNotExplicitlySpecified() + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData + { + Values = + { + { "page", "ambient-page" }, + { "handler", "ambient-handler" }, + } + }; + var actionContext = new ActionContext + { + RouteData = routeData, + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + string page = null; + urlHelper.Object.Page(page, pageHandler: null, values: new { handler = "route-value-handler" }); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("handler", value.Key); + Assert.Equal("route-value-handler", value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("ambient-page", value.Value); + }); + } + + [Theory] + [InlineData("Sibling", "/Dir1/Dir2/Sibling")] + [InlineData("Dir3/Sibling", "/Dir1/Dir2/Dir3/Sibling")] + [InlineData("Dir4/Dir5/Index", "/Dir1/Dir2/Dir4/Dir5/Index")] + public void Page_CalculatesPathRelativeToViewEnginePath_WhenNotRooted(string pageName, string expected) + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData(); + var actionContext = GetActionContextForPage("/Dir1/Dir2/About"); + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page(pageName); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("page", value.Key); + Assert.Equal(expected, value.Value); + }); + } + + [Fact] + public void Page_CalculatesPathRelativeToViewEnginePath_ForIndexPagePaths() + { + // Arrange + var expected = "/Dir1/Dir2/Sibling"; + UrlRouteContext actual = null; + var actionContext = GetActionContextForPage("/Dir1/Dir2/"); + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("Sibling"); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("page", value.Key); + Assert.Equal(expected, value.Value); + }); + } + + [Fact] + public void Page_CalculatesPathRelativeToViewEnginePath_WhenNotRooted_ForPageAtRoot() + { + // Arrange + var expected = "/SiblingName"; + UrlRouteContext actual = null; + var routeData = new RouteData(); + var actionContext = new ActionContext + { + ActionDescriptor = new ActionDescriptor + { + RouteValues = new Dictionary + { + { "page", "/Home" }, + }, + }, + RouteData = new RouteData + { + Values = + { + [ "page" ] = "/Home" + }, + }, + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("SiblingName"); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("page", value.Key); + Assert.Equal(expected, value.Value); + }); + } + + [Fact] + public void Page_Throws_IfRouteValueDoesNotIncludePageKey() + { + // Arrange + var expected = "SiblingName"; + UrlRouteContext actual = null; + var routeData = new RouteData(); + var actionContext = new ActionContext + { + RouteData = new RouteData(), + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act & Assert + var ex = Assert.Throws(() => urlHelper.Object.Page(expected)); + Assert.Equal( + $"The relative page path '{expected}' can only be used while executing a Razor Page. " + + "Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. " + + "If you are using LinkGenerator then you must provide the current HttpContext to use relative pages.", + ex.Message); + } + + [Fact] + public void Page_UsesAreaValueFromRouteValueIfSpecified() + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData + { + Values = + { + { "page", "ambient-page" }, + { "area", "ambient-area" }, + } + }; + var actionContext = new ActionContext + { + RouteData = routeData, + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + string page = null; + urlHelper.Object.Page(page, values: new { area = "specified-area" }); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values).OrderBy(v => v.Key), + value => + { + Assert.Equal("area", value.Key); + Assert.Equal("specified-area", value.Value); + }, + value => + { + Assert.Equal("page", value.Key); + Assert.Equal("ambient-page", value.Value); + }); + } + + private static Mock CreateMockUrlHelper(ActionContext context = null) + { + if (context == null) + { + context = GetActionContextForPage("/Page"); + } + + var urlHelper = new Mock(); + urlHelper.SetupGet(h => h.ActionContext) + .Returns(context); + return urlHelper; + } + + private static ActionContext GetActionContextForPage(string page) + { + return new ActionContext + { + ActionDescriptor = new ActionDescriptor + { + RouteValues = new Dictionary + { + { "page", page }, + }, + }, + RouteData = new RouteData + { + Values = + { + [ "page" ] = page + }, + }, + }; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs index b2754f56d3..2d7e4a0f15 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs @@ -2,1729 +2,93 @@ // 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 System.Text; -using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.ObjectPool; using Moq; using Xunit; namespace Microsoft.AspNetCore.Mvc.Routing { - public class UrlHelperTest + public class UrlHelperTest : UrlHelperTestBase { - [Theory] - [InlineData(null, null, null)] - [InlineData("/myapproot", null, null)] - [InlineData("", "/Home/About", "/Home/About")] - [InlineData("/myapproot", "/test", "/test")] - public void Content_ReturnsContentPath_WhenItDoesNotStartWithToken( - string appRoot, - string contentPath, - string expectedPath) + protected override IServiceProvider CreateServices() { - // Arrange - var httpContext = CreateHttpContext(CreateServices(), appRoot); - var actionContext = CreateActionContext(httpContext); - var urlHelper = CreateUrlHelper(actionContext); - - // Act - var path = urlHelper.Content(contentPath); - - // Assert - Assert.Equal(expectedPath, path); - } - - [Theory] - [InlineData(null, "~/Home/About", "/Home/About")] - [InlineData("/", "~/Home/About", "/Home/About")] - [InlineData("/", "~/", "/")] - [InlineData("/myapproot", "~/", "/myapproot/")] - [InlineData("", "~/Home/About", "/Home/About")] - [InlineData("/", "~", "/")] - [InlineData("/myapproot", "~/Content/bootstrap.css", "/myapproot/Content/bootstrap.css")] - public void Content_ReturnsAppRelativePath_WhenItStartsWithToken( - string appRoot, - string contentPath, - string expectedPath) - { - // Arrange - var httpContext = CreateHttpContext(CreateServices(), appRoot); - var actionContext = CreateActionContext(httpContext); - var urlHelper = CreateUrlHelper(actionContext); - - // Act - var path = urlHelper.Content(contentPath); - - // Assert - Assert.Equal(expectedPath, path); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void IsLocalUrl_ReturnsFalseOnEmpty(string url) - { - // Arrange - var helper = CreateUrlHelper(); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("/foo.html")] - [InlineData("/www.example.com")] - [InlineData("/")] - public void IsLocalUrl_AcceptsRootedUrls(string url) - { - // Arrange - var helper = CreateUrlHelper(); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData("~/")] - [InlineData("~/foo.html")] - public void IsLocalUrl_AcceptsApplicationRelativeUrls(string url) - { - // Arrange - var helper = CreateUrlHelper(); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData("foo.html")] - [InlineData("../foo.html")] - [InlineData("fold/foo.html")] - public void IsLocalUrl_RejectsRelativeUrls(string url) - { - // Arrange - var helper = CreateUrlHelper(); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("http:/foo.html")] - [InlineData("hTtP:foo.html")] - [InlineData("http:/www.example.com")] - [InlineData("HtTpS:/www.example.com")] - public void IsLocalUrl_RejectValidButUnsafeRelativeUrls(string url) - { - // Arrange - var helper = CreateUrlHelper(); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("http://www.mysite.com/appDir/foo.html")] - [InlineData("http://WWW.MYSITE.COM")] - public void IsLocalUrl_RejectsUrlsOnTheSameHost(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("http://localhost/foobar.html")] - [InlineData("http://127.0.0.1/foobar.html")] - public void IsLocalUrl_RejectsUrlsOnLocalHost(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("https://www.mysite.com/")] - public void IsLocalUrl_RejectsUrlsOnTheSameHostButDifferentScheme(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("http://www.example.com")] - [InlineData("https://www.example.com")] - [InlineData("hTtP://www.example.com")] - [InlineData("HtTpS://www.example.com")] - public void IsLocalUrl_RejectsUrlsOnDifferentHost(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("http://///www.example.com/foo.html")] - [InlineData("https://///www.example.com/foo.html")] - [InlineData("HtTpS://///www.example.com/foo.html")] - [InlineData("http:///www.example.com/foo.html")] - [InlineData("http:////www.example.com/foo.html")] - public void IsLocalUrl_RejectsUrlsWithTooManySchemeSeparatorCharacters(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("//www.example.com")] - [InlineData("//www.example.com?")] - [InlineData("//www.example.com:80")] - [InlineData("//www.example.com/foobar.html")] - [InlineData("///www.example.com")] - [InlineData("//////www.example.com")] - public void IsLocalUrl_RejectsUrlsWithMissingSchemeName(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("http:\\\\www.example.com")] - [InlineData("http:\\\\www.example.com\\")] - [InlineData("/\\")] - [InlineData("/\\foo")] - public void IsLocalUrl_RejectsInvalidUrls(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("~//www.example.com")] - [InlineData("~//www.example.com?")] - [InlineData("~//www.example.com:80")] - [InlineData("~//www.example.com/foobar.html")] - [InlineData("~///www.example.com")] - [InlineData("~//////www.example.com")] - public void IsLocalUrl_RejectsTokenUrlsWithMissingSchemeName(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("~/\\")] - [InlineData("~/\\foo")] - public void IsLocalUrl_RejectsInvalidTokenUrls(string url) - { - // Arrange - var helper = CreateUrlHelper("www.mysite.com"); - - // Act - var result = helper.IsLocalUrl(url); - - // Assert - Assert.False(result); - } - - [Fact] - public void RouteUrlWithDictionary() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - values: new RouteValueDictionary( - new - { - Action = "newaction", - Controller = "home2", - id = "someid" - })); - - // Assert - Assert.Equal("/app/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithEmptyHostName() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new RouteValueDictionary( - new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }), - protocol: "http", - host: string.Empty); - - // Assert - Assert.Equal("http://localhost/app/named/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithEmptyProtocol() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new RouteValueDictionary( - new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }), - protocol: string.Empty, - host: "foo.bar.com"); - - // Assert - Assert.Equal("http://foo.bar.com/app/named/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithNullProtocol() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new RouteValueDictionary( - new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }), - protocol: null, - host: "foo.bar.com"); - - // Assert - Assert.Equal("http://foo.bar.com/app/named/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithNullProtocolAndNullHostName() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new RouteValueDictionary( - new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }), - protocol: null, - host: null); - - // Assert - Assert.Equal("/app/named/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithObjectProperties() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl(new { Action = "newaction", Controller = "home2", id = "someid" }); - - // Assert - Assert.Equal("/app/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithProtocol() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }, - protocol: "https"); - - // Assert - Assert.Equal("https://localhost/app/named/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrl_WithUnicodeHost_DoesNotPunyEncodeTheHost() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }, - protocol: "https", - host: "pingüino"); - - // Assert - Assert.Equal("https://pingüino/app/named/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithRouteNameAndDefaults() - { - // Arrange - var services = CreateServices(); - var routeCollection = GetRouter(services, "MyRouteName", "any/url"); - var urlHelper = CreateUrlHelper("/app", routeCollection); - - // Act - var url = urlHelper.RouteUrl("MyRouteName"); - - // Assert - Assert.Equal("/app/any/url", url); - } - - [Fact] - public void RouteUrlWithRouteNameAndDictionary() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new RouteValueDictionary( - new - { - Action = "newaction", - Controller = "home2", - id = "someid" - })); - - // Assert - Assert.Equal("/app/named/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithRouteNameAndObjectProperties() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }); - - // Assert - Assert.Equal("/app/named/home2/newaction/someid", url); - } - - [Fact] - public void RouteUrlWithUrlRouteContext_ReturnsExpectedResult() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - var routeContext = new UrlRouteContext() - { - RouteName = "namedroute", - Values = new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }, - Fragment = "somefragment", - Host = "remotetown", - Protocol = "ftp" - }; - - // Act - var url = urlHelper.RouteUrl(routeContext); - - // Assert - Assert.Equal("ftp://remotetown/app/named/home2/newaction/someid#somefragment", url); - } - - [Fact] - public void RouteUrlWithAllParameters_ReturnsExpectedResult() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.RouteUrl( - routeName: "namedroute", - values: new - { - Action = "newaction", - Controller = "home2", - id = "someid" - }, - fragment: "somefragment", - host: "remotetown", - protocol: "https"); - - // Assert - Assert.Equal("https://remotetown/app/named/home2/newaction/someid#somefragment", url); - } - - [Fact] - public void UrlAction_RouteValuesAsDictionary_CaseSensitive() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // We're using a dictionary with a case-sensitive comparer and loading it with data - // using casings differently from the route. This should still successfully generate a link. - var dictionary = new Dictionary(); - var id = "suppliedid"; - var isprint = "true"; - dictionary["ID"] = id; - dictionary["isprint"] = isprint; - - // Act - var url = urlHelper.Action( - action: "contact", - controller: "home", - values: dictionary); - - // Assert - Assert.Equal(2, dictionary.Count); - Assert.Same(id, dictionary["ID"]); - Assert.Same(isprint, dictionary["isprint"]); - Assert.Equal("/app/home/contact/suppliedid?isprint=true", url); - } - - [Fact] - public void UrlAction_WithUnicodeHost_DoesNotPunyEncodeTheHost() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.Action( - action: "contact", - controller: "home", - values: null, - protocol: "http", - host: "pingüino"); - - // Assert - Assert.Equal("http://pingüino/app/home/contact", url); - } - - [Fact] - public void UrlRouteUrl_RouteValuesAsDictionary_CaseSensitive() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // We're using a dictionary with a case-sensitive comparer and loading it with data - // using casings differently from the route. This should still successfully generate a link. - var dict = new Dictionary(); - var action = "contact"; - var controller = "home"; - var id = "suppliedid"; - - dict["ACTION"] = action; - dict["Controller"] = controller; - dict["ID"] = id; - - // Act - var url = urlHelper.RouteUrl(routeName: "namedroute", values: dict); - - // Assert - Assert.Equal(3, dict.Count); - Assert.Same(action, dict["ACTION"]); - Assert.Same(controller, dict["Controller"]); - Assert.Same(id, dict["ID"]); - Assert.Equal("/app/named/home/contact/suppliedid", url); - } - - [Fact] - public void UrlActionWithUrlActionContext_ReturnsExpectedResult() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - var actionContext = new UrlActionContext() - { - Action = "contact", - Controller = "home3", - Values = new { id = "idone" }, - Protocol = "ftp", - Host = "remotelyhost", - Fragment = "somefragment" - }; - - // Act - var url = urlHelper.Action(actionContext); - - // Assert - Assert.Equal("ftp://remotelyhost/app/home3/contact/idone#somefragment", url); - } - - [Fact] - public void UrlActionWithAllParameters_ReturnsExpectedResult() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.Action( - controller: "home3", - action: "contact", - values: null, - protocol: "https", - host: "remotelyhost", - fragment: "somefragment"); - - // Assert - Assert.Equal("https://remotelyhost/app/home3/contact#somefragment", url); - } - - [Fact] - public void LinkWithAllParameters_ReturnsExpectedResult() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.Link( - "namedroute", - new - { - Action = "newaction", - Controller = "home", - id = "someid" - }); - - // Assert - Assert.Equal("http://localhost/app/named/home/newaction/someid", url); - } - - [Fact] - public void LinkWithNullRouteName_ReturnsExpectedResult() - { - // Arrange - var services = CreateServices(); - var urlHelper = CreateUrlHelperWithRouteCollection(services, "/app"); - - // Act - var url = urlHelper.Link( - null, - new - { - Action = "newaction", - Controller = "home", - id = "someid" - }); - - // Assert - Assert.Equal("http://localhost/app/home/newaction/someid", url); - } - - [Fact] - public void LinkWithDefaultsAndNullRouteValues_ReturnsExpectedResult() - { - // Arrange - var services = CreateServices(); - var routeCollection = GetRouter(services, "MyRouteName", "any/url"); - var urlHelper = CreateUrlHelper("/app", routeCollection); - - // Act - var url = urlHelper.Link("MyRouteName", null); - - // Assert - Assert.Equal("http://localhost/app/any/url", url); - } - - [Fact] - public void LinkWithCustomHostAndProtocol_ReturnsExpectedResult() - { - // Arrange - var services = CreateServices(); - var routeCollection = GetRouter(services, "MyRouteName", "any/url"); - var urlHelper = CreateUrlHelper("myhost", "https", routeCollection); - - // Act - var url = urlHelper.Link( - "namedroute", - new - { - Action = "newaction", - Controller = "home", - id = "someid" - }); - - // Assert - Assert.Equal("https://myhost/named/home/newaction/someid", url); - } - - // Regression test for aspnet/Mvc#2859 - [Fact] - public void Action_RouteValueInvalidation_DoesNotAffectActionAndController() - { - // Arrange - var services = CreateServices(); - var routeBuilder = CreateRouteBuilder(services); - - routeBuilder.MapRoute( - "default", - "{first}/{controller}/{action}", - new { second = "default", controller = "default", action = "default" }); - - var actionContext = new ActionContext() - { - HttpContext = new DefaultHttpContext() - { - RequestServices = services, - }, - }; - - actionContext.RouteData = new RouteData(); - actionContext.RouteData.Values.Add("first", "a"); - actionContext.RouteData.Values.Add("controller", "Store"); - actionContext.RouteData.Values.Add("action", "Buy"); - actionContext.RouteData.Routers.Add(routeBuilder.Build()); - - var urlHelper = CreateUrlHelper(actionContext); - - // Act - // - // In this test the 'first' route value has changed, meaning that *normally* the - // 'controller' value could not be used. However 'controller' and 'action' are treated - // specially by UrlHelper. - var url = urlHelper.Action("Checkout", new { first = "b" }); - - // Assert - Assert.NotNull(url); - Assert.Equal("/b/Store/Checkout", url); - } - - // Regression test for aspnet/Mvc#2859 - [Fact] - public void Action_RouteValueInvalidation_AffectsOtherRouteValues() - { - // Arrange - var services = CreateServices(); - var routeBuilder = CreateRouteBuilder(services); - - routeBuilder.MapRoute( - "default", - "{first}/{second}/{controller}/{action}", - new { second = "default", controller = "default", action = "default" }); - - var actionContext = new ActionContext() - { - HttpContext = new DefaultHttpContext() - { - RequestServices = services, - }, - }; - - actionContext.RouteData = new RouteData(); - actionContext.RouteData.Values.Add("first", "a"); - actionContext.RouteData.Values.Add("second", "x"); - actionContext.RouteData.Values.Add("controller", "Store"); - actionContext.RouteData.Values.Add("action", "Buy"); - actionContext.RouteData.Routers.Add(routeBuilder.Build()); - - var urlHelper = CreateUrlHelper(actionContext); - - // Act - // - // In this test the 'first' route value has changed, meaning that *normally* the - // 'controller' value could not be used. However 'controller' and 'action' are treated - // specially by UrlHelper. - // - // 'second' gets no special treatment, and picks up its default value instead. - var url = urlHelper.Action("Checkout", new { first = "b" }); - - // Assert - Assert.NotNull(url); - Assert.Equal("/b/default/Store/Checkout", url); - } - - // Regression test for aspnet/Mvc#2859 - [Fact] - public void Action_RouteValueInvalidation_DoesNotAffectActionAndController_ActionPassedInRouteValues() - { - // Arrange - var services = CreateServices(); - var routeBuilder = CreateRouteBuilder(services); - - routeBuilder.MapRoute( - "default", - "{first}/{controller}/{action}", - new { second = "default", controller = "default", action = "default" }); - - var actionContext = new ActionContext() - { - HttpContext = new DefaultHttpContext() - { - RequestServices = services, - }, - }; - - actionContext.RouteData = new RouteData(); - actionContext.RouteData.Values.Add("first", "a"); - actionContext.RouteData.Values.Add("controller", "Store"); - actionContext.RouteData.Values.Add("action", "Buy"); - actionContext.RouteData.Routers.Add(routeBuilder.Build()); - - var urlHelper = CreateUrlHelper(actionContext); - - // Act - // - // In this test the 'first' route value has changed, meaning that *normally* the - // 'controller' value could not be used. However 'controller' and 'action' are treated - // specially by UrlHelper. - var url = urlHelper.Action(action: null, values: new { first = "b", action = "Checkout" }); - - // Assert - Assert.NotNull(url); - Assert.Equal("/b/Store/Checkout", url); - } - - public static TheoryData GeneratePathFromRoute_HandlesLeadingAndTrailingSlashesData => - new TheoryData - { - { null, "", "/" }, - { null, "/", "/" }, - { null, "Hello", "/Hello" }, - { null, "/Hello", "/Hello" }, - { "/", "", "/" }, - { "/", "hello", "/hello" }, - { "/", "/hello", "/hello" }, - { "/hello", "", "/hello" }, - { "/hello/", "", "/hello/" }, - { "/hello", "/", "/hello/" }, - { "/hello/", "world", "/hello/world" }, - { "/hello/", "/world", "/hello/world" }, - { "/hello/", "/world 123", "/hello/world 123" }, - { "/hello/", "/world%20123", "/hello/world%20123" }, - }; - - [Theory] - [MemberData(nameof(GeneratePathFromRoute_HandlesLeadingAndTrailingSlashesData))] - public void AppendPathAndFragment_HandlesLeadingAndTrailingSlashes( - string appBase, - string virtualPath, - string expected) - { - // Arrange - var router = Mock.Of(); - var pathData = new VirtualPathData(router, virtualPath) - { - VirtualPath = virtualPath - }; - var urlHelper = CreateUrlHelper(appBase, router); - var builder = new StringBuilder(); - - // Act - urlHelper.AppendPathAndFragment(builder, pathData, string.Empty); - - // Assert - Assert.Equal(expected, builder.ToString()); - } - - [Theory] - [MemberData(nameof(GeneratePathFromRoute_HandlesLeadingAndTrailingSlashesData))] - public void AppendPathAndFragment_AppendsFragments( - string appBase, - string virtualPath, - string expected) - { - // Arrange - var fragmentValue = "fragment-value"; - expected += $"#{fragmentValue}"; - var router = Mock.Of(); - var pathData = new VirtualPathData(router, virtualPath) - { - VirtualPath = virtualPath - }; - var urlHelper = CreateUrlHelper(appBase, router); - var builder = new StringBuilder(); - - // Act - urlHelper.AppendPathAndFragment(builder, pathData, fragmentValue); - - // Assert - Assert.Equal(expected, builder.ToString()); - } - - [Theory] - [InlineData(null, null, null, "/", null, "/")] - [InlineData(null, null, null, "/Hello", null, "/Hello")] - [InlineData(null, null, null, "Hello", null, "/Hello")] - [InlineData("/", null, null, "", null, "/")] - [InlineData("/hello/", null, null, "/world", null, "/hello/world")] - [InlineData("/hello/", "https", "myhost", "/world", "fragment-value", "https://myhost/hello/world#fragment-value")] - public void GenerateUrl_FastAndSlowPathsReturnsExpected( - string appBase, - string protocol, - string host, - string virtualPath, - string fragment, - string expected) - { - // Arrange - var router = Mock.Of(); - var pathData = new VirtualPathData(router, virtualPath) - { - VirtualPath = virtualPath - }; - var urlHelper = CreateUrlHelper(appBase, router); - - // Act - var url = urlHelper.GenerateUrl(protocol, host, pathData, fragment); - - // Assert - Assert.Equal(expected, url); - } - - [Fact] - public void GetUrlHelper_ReturnsSameInstance_IfAlreadyPresent() - { - // Arrange - var expectedUrlHelper = CreateUrlHelper(); - var httpContext = new Mock(); - var mockItems = new Dictionary - { - { typeof(IUrlHelper), expectedUrlHelper } - }; - httpContext.Setup(h => h.Items).Returns(mockItems); - - var actionContext = CreateActionContext(httpContext.Object, Mock.Of()); - var urlHelperFactory = new UrlHelperFactory(); - - // Act - var urlHelper = urlHelperFactory.GetUrlHelper(actionContext); - - // Assert - Assert.Same(expectedUrlHelper, urlHelper); - } - - [Fact] - public void GetUrlHelper_CreatesNewInstance_IfNotAlreadyPresent() - { - // Arrange - var httpContext = new Mock(); - httpContext.Setup(h => h.Items).Returns(new Dictionary()); - - var actionContext = CreateActionContext(httpContext.Object, Mock.Of()); - var urlHelperFactory = new UrlHelperFactory(); - - // Act - var urlHelper = urlHelperFactory.GetUrlHelper(actionContext); - - // Assert - Assert.NotNull(urlHelper); - Assert.Same(urlHelper, actionContext.HttpContext.Items[typeof(IUrlHelper)] as IUrlHelper); - } - - [Fact] - public void GetUrlHelper_CreatesNewInstance_IfExpectedTypeIsNotPresent() - { - // Arrange - var httpContext = new Mock(); - var mockItems = new Dictionary - { - { typeof(IUrlHelper), null } - }; - httpContext.Setup(h => h.Items).Returns(mockItems); - - var actionContext = CreateActionContext(httpContext.Object, Mock.Of()); - var urlHelperFactory = new UrlHelperFactory(); - - // Act - var urlHelper = urlHelperFactory.GetUrlHelper(actionContext); - - // Assert - Assert.NotNull(urlHelper); - Assert.Same(urlHelper, actionContext.HttpContext.Items[typeof(IUrlHelper)] as IUrlHelper); - } - - [Fact] - public void Page_WithName_Works() - { - // Arrange - UrlRouteContext actual = null; - var routeData = new RouteData - { - Values = - { - { "page", "ambient-page" }, - } - }; - var actionContext = new ActionContext - { - RouteData = routeData, - }; - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - urlHelper.Object.Page("/TestPage"); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("/TestPage", value.Value); - }); - Assert.Null(actual.Host); - Assert.Null(actual.Protocol); - Assert.Null(actual.Fragment); - } - - public static TheoryData Page_WithNameAndRouteValues_WorksData - { - get => new TheoryData - { - { new { id = 10 } }, - { - new Dictionary - { - ["id"] = 10, - } - }, - { - new RouteValueDictionary - { - ["id"] = 10, - } - }, - }; - } - - [Theory] - [MemberData(nameof(Page_WithNameAndRouteValues_WorksData))] - public void Page_WithNameAndRouteValues_Works(object values) - { - // Arrange - UrlRouteContext actual = null; - var urlHelper = CreateMockUrlHelper(); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - urlHelper.Object.Page("/TestPage", values); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("id", value.Key); - Assert.Equal(10, value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("/TestPage", value.Value); - }); - Assert.Null(actual.Host); - Assert.Null(actual.Protocol); - Assert.Null(actual.Fragment); - } - - [Fact] - public void Page_WithNameRouteValuesAndProtocol_Works() - { - // Arrange - UrlRouteContext actual = null; - var urlHelper = CreateMockUrlHelper(); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - urlHelper.Object.Page("/TestPage", pageHandler: null, values: new { id = 13 }, protocol: "https"); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("id", value.Key); - Assert.Equal(13, value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("/TestPage", value.Value); - }); - Assert.Equal("https", actual.Protocol); - Assert.Null(actual.Host); - Assert.Null(actual.Fragment); - } - - [Fact] - public void Page_WithNameRouteValuesProtocolAndHost_Works() - { - // Arrange - UrlRouteContext actual = null; - var urlHelper = CreateMockUrlHelper(); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - urlHelper.Object.Page("/TestPage", pageHandler: null, values: new { id = 13 }, protocol: "https", host: "mytesthost"); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("id", value.Key); - Assert.Equal(13, value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("/TestPage", value.Value); - }); - Assert.Equal("https", actual.Protocol); - Assert.Equal("mytesthost", actual.Host); - Assert.Null(actual.Fragment); - } - - [Fact] - public void Page_WithNameRouteValuesProtocolHostAndFragment_Works() - { - // Arrange - UrlRouteContext actual = null; - var urlHelper = CreateMockUrlHelper(); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - urlHelper.Object.Page("/TestPage", "test-handler", new { id = 13 }, "https", "mytesthost", "#toc"); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("id", value.Key); - Assert.Equal(13, value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("/TestPage", value.Value); - }, - value => - { - Assert.Equal("handler", value.Key); - Assert.Equal("test-handler", value.Value); - }); - Assert.Equal("https", actual.Protocol); - Assert.Equal("mytesthost", actual.Host); - Assert.Equal("#toc", actual.Fragment); - } - - [Fact] - public void Page_UsesAmbientRouteValue_WhenPageIsNull() - { - // Arrange - UrlRouteContext actual = null; - var routeData = new RouteData - { - Values = - { - { "page", "ambient-page" }, - } - }; - var actionContext = new ActionContext - { - RouteData = routeData, - }; - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - string page = null; - urlHelper.Object.Page(page, new { id = 13 }); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("id", value.Key); - Assert.Equal(13, value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("ambient-page", value.Value); - }); - } - - [Fact] - public void Page_SetsHandlerToNull_IfValueIsNotSpecifiedInRouteValues() - { - // Arrange - UrlRouteContext actual = null; - var routeData = new RouteData - { - Values = - { - { "page", "ambient-page" }, - { "handler", "ambient-handler" }, - } - }; - var actionContext = new ActionContext - { - RouteData = routeData, - }; - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - string page = null; - urlHelper.Object.Page(page, new { id = 13 }); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("id", value.Key); - Assert.Equal(13, value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("ambient-page", value.Value); - }, - value => - { - Assert.Equal("handler", value.Key); - Assert.Null(value.Value); - }); - } - - [Fact] - public void Page_UsesExplicitlySpecifiedHandlerValue() - { - // Arrange - UrlRouteContext actual = null; - var routeData = new RouteData - { - Values = - { - { "page", "ambient-page" }, - { "handler", "ambient-handler" }, - } - }; - var actionContext = new ActionContext - { - RouteData = routeData, - }; - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - string page = null; - urlHelper.Object.Page(page, "exact-handler", new { handler = "route-value-handler" }); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("handler", value.Key); - Assert.Equal("exact-handler", value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("ambient-page", value.Value); - }); - } - - [Fact] - public void Page_UsesValueFromRouteValueIfPageHandlerIsNotExplicitySpecified() - { - // Arrange - UrlRouteContext actual = null; - var routeData = new RouteData - { - Values = - { - { "page", "ambient-page" }, - { "handler", "ambient-handler" }, - } - }; - var actionContext = new ActionContext - { - RouteData = routeData, - }; - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - string page = null; - urlHelper.Object.Page(page, pageHandler: null, values: new { handler = "route-value-handler" }); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("handler", value.Key); - Assert.Equal("route-value-handler", value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("ambient-page", value.Value); - }); - } - - [Theory] - [InlineData("Sibling", "/Dir1/Dir2/Sibling")] - [InlineData("Dir3/Sibling", "/Dir1/Dir2/Dir3/Sibling")] - [InlineData("Dir4/Dir5/Index", "/Dir1/Dir2/Dir4/Dir5/Index")] - public void Page_CalculatesPathRelativeToViewEnginePath_WhenNotRooted(string pageName, string expected) - { - // Arrange - UrlRouteContext actual = null; - var routeData = new RouteData(); - var actionContext = GetActionContextForPage("/Dir1/Dir2/About"); - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - urlHelper.Object.Page(pageName); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("page", value.Key); - Assert.Equal(expected, value.Value); - }); - } - - [Fact] - public void Page_CalculatesPathRelativeToViewEnginePath_ForIndexPagePaths() - { - // Arrange - var expected = "/Dir1/Dir2/Sibling"; - UrlRouteContext actual = null; - var actionContext = GetActionContextForPage("/Dir1/Dir2/"); - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - urlHelper.Object.Page("Sibling"); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("page", value.Key); - Assert.Equal(expected, value.Value); - }); - } - - [Fact] - public void Page_CalculatesPathRelativeToViewEnginePath_WhenNotRooted_ForPageAtRoot() - { - // Arrange - var expected = "/SiblingName"; - UrlRouteContext actual = null; - var routeData = new RouteData(); - var actionContext = new ActionContext - { - ActionDescriptor = new ActionDescriptor - { - RouteValues = new Dictionary - { - { "page", "/Home" }, - }, - }, - RouteData = new RouteData - { - Values = - { - [ "page" ] = "/Home" - }, - }, - }; - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - urlHelper.Object.Page("SiblingName"); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values), - value => - { - Assert.Equal("page", value.Key); - Assert.Equal(expected, value.Value); - }); - } - - [Fact] - public void Page_Throws_IfRouteValueDoesNotIncludePageKey() - { - // Arrange - var expected = "SiblingName"; - UrlRouteContext actual = null; - var routeData = new RouteData(); - var actionContext = new ActionContext - { - RouteData = new RouteData(), - }; - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act & Assert - var ex = Assert.Throws(() => urlHelper.Object.Page(expected)); - Assert.Equal($"The relative page path '{expected}' can only be used while executing a Razor Page. " + - "Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page.", ex.Message); - } - - [Fact] - public void Page_UsesAreaValueFromRouteValueIfSpecified() - { - // Arrange - UrlRouteContext actual = null; - var routeData = new RouteData - { - Values = - { - { "page", "ambient-page" }, - { "area", "ambient-area" }, - } - }; - var actionContext = new ActionContext - { - RouteData = routeData, - }; - - var urlHelper = CreateMockUrlHelper(actionContext); - urlHelper.Setup(h => h.RouteUrl(It.IsAny())) - .Callback((UrlRouteContext context) => actual = context); - - // Act - string page = null; - urlHelper.Object.Page(page, values: new { area = "specified-area" }); - - // Assert - urlHelper.Verify(); - Assert.NotNull(actual); - Assert.Null(actual.RouteName); - Assert.Collection(Assert.IsType(actual.Values).OrderBy(v => v.Key), - value => - { - Assert.Equal("area", value.Key); - Assert.Equal("specified-area", value.Value); - }, - value => - { - Assert.Equal("page", value.Key); - Assert.Equal("ambient-page", value.Value); - }); - } - - private static Mock CreateMockUrlHelper(ActionContext context = null) - { - if (context == null) - { - context = GetActionContextForPage("/Page"); - } - - var urlHelper = new Mock(); - urlHelper.SetupGet(h => h.ActionContext) - .Returns(context); - return urlHelper; - } - - private static HttpContext CreateHttpContext( - IServiceProvider services, - string appRoot) - { - var context = new DefaultHttpContext(); - context.RequestServices = services; - - context.Request.PathBase = new PathString(appRoot); - context.Request.Host = new HostString("localhost"); - - return context; - } - - private static ActionContext CreateActionContext(HttpContext context) - { - return CreateActionContext(context, (new Mock()).Object); - } - - private static ActionContext CreateActionContext(HttpContext context, IRouter router) - { - var routeData = new RouteData(); - routeData.Routers.Add(router); - - return new ActionContext(context, routeData, new ActionDescriptor()); - } - - private static UrlHelper CreateUrlHelper() - { - var services = CreateServices(); - var context = CreateHttpContext(services, string.Empty); - var actionContext = CreateActionContext(context); - - return new UrlHelper(actionContext); - } - - private static UrlHelper CreateUrlHelper(ActionContext context) - { - return new UrlHelper(context); - } - - private static UrlHelper CreateUrlHelper(string host) - { - var services = CreateServices(); - var context = CreateHttpContext(services, string.Empty); - context.Request.Host = new HostString(host); - - var actionContext = CreateActionContext(context); - - return new UrlHelper(actionContext); - } - - private static UrlHelper CreateUrlHelper(string host, string protocol, IRouter router) - { - var services = CreateServices(); - var context = CreateHttpContext(services, string.Empty); - context.Request.Host = new HostString(host); - context.Request.Scheme = protocol; - - var actionContext = CreateActionContext(context, router); - - return new UrlHelper(actionContext); - } - - private static TestUrlHelper CreateUrlHelper(string appBase, IRouter router) - { - var services = CreateServices(); - var context = CreateHttpContext(services, appBase); - var actionContext = CreateActionContext(context, router); - - return new TestUrlHelper(actionContext); - } - - private static UrlHelper CreateUrlHelperWithRouteCollection( - IServiceProvider services, - string appPrefix) - { - var routeCollection = GetRouter(services); - return CreateUrlHelper(appPrefix, routeCollection); - } - - private static IRouter GetRouter(IServiceProvider services) - { - return GetRouter(services, "mockRoute", "/mockTemplate"); - } - - private static IServiceProvider CreateServices() - { - var services = new ServiceCollection(); - services.AddOptions(); - services.AddLogging(); - services.AddRouting(); - services - .AddSingleton() - .AddSingleton(UrlEncoder.Default); - + var services = GetCommonServices(); return services.BuildServiceProvider(); } - private static IRouteBuilder CreateRouteBuilder(IServiceProvider services) + protected override IUrlHelper CreateUrlHelper(string appRoot, string host, string protocol) { - var app = new Mock(); - app - .SetupGet(a => a.ApplicationServices) - .Returns(services); - - return new RouteBuilder(app.Object) - { - DefaultHandler = new PassThroughRouter(), - }; + var services = CreateServices(); + var httpContext = CreateHttpContext(services, appRoot, host, protocol); + var actionContext = CreateActionContext(httpContext); + var defaultRoutes = GetDefaultRoutes(services); + actionContext.RouteData.Routers.Add(defaultRoutes); + return new UrlHelper(actionContext); } - private static IRouter GetRouter( + protected override IUrlHelper CreateUrlHelperWithDefaultRoutes( + string appRoot, + string host, + string protocol, + string routeName, + string template) + { + var services = CreateServices(); + var httpContext = CreateHttpContext(services, appRoot, host, protocol); + var actionContext = CreateActionContext(httpContext); + var router = GetDefaultRoutes(services, routeName, template); + actionContext.RouteData.Routers.Add(router); + return CreateUrlHelper(actionContext); + } + + protected override IUrlHelper CreateUrlHelper(ActionContext actionContext) + { + return new UrlHelper(actionContext); + } + + protected override IUrlHelper CreateUrlHelperWithDefaultRoutes(string appRoot, string host, string protocol) + { + var services = CreateServices(); + var context = CreateHttpContext(services, appRoot, host, protocol); + + var router = GetDefaultRoutes(services); + var actionContext = CreateActionContext(context); + actionContext.RouteData.Routers.Add(router); + + return CreateUrlHelper(actionContext); + } + + protected override IUrlHelper CreateUrlHelper( + string appRoot, + string host, + string protocol, + string routeName, + string template, + object defaults) + { + var services = CreateServices(); + var routeBuilder = CreateRouteBuilder(services); + routeBuilder.MapRoute( + routeName, + template, + defaults); + var router = routeBuilder.Build(); + var httpContext = CreateHttpContext(services, appRoot, host, protocol); + var actionContext = CreateActionContext(httpContext); + actionContext.RouteData.Routers.Add(router); + return CreateUrlHelper(actionContext); + } + + private static IRouter GetDefaultRoutes(IServiceProvider services) + { + return GetDefaultRoutes(services, "mockRoute", "/mockTemplate"); + } + + private static IRouter GetDefaultRoutes( IServiceProvider services, string mockRouteName, string mockTemplateValue) @@ -1737,6 +101,11 @@ namespace Microsoft.AspNetCore.Mvc.Routing .Returns(context => null); routeBuilder.DefaultHandler = target.Object; + routeBuilder.MapRoute( + "OrdersApi", + "api/orders/{id}", + new RouteValueDictionary(new { controller = "Orders", action = "GetById" })); + routeBuilder.MapRoute( string.Empty, "{controller}/{action}/{id}", @@ -1756,24 +125,16 @@ namespace Microsoft.AspNetCore.Mvc.Routing return routeBuilder.Build(); } - private static ActionContext GetActionContextForPage(string page) + private static IRouteBuilder CreateRouteBuilder(IServiceProvider services) { - return new ActionContext + var app = new Mock(); + app + .SetupGet(a => a.ApplicationServices) + .Returns(services); + + return new RouteBuilder(app.Object) { - ActionDescriptor = new ActionDescriptor - { - RouteValues = new Dictionary - { - { "page", page }, - }, - }, - RouteData = new RouteData - { - Values = - { - [ "page" ] = page - }, - }, + DefaultHandler = new PassThroughRouter(), }; } @@ -1790,22 +151,5 @@ namespace Microsoft.AspNetCore.Mvc.Routing return Task.FromResult(false); } } - - private class TestUrlHelper : UrlHelper - { - public TestUrlHelper(ActionContext actionContext) : - base(actionContext) - { - - } - public new string GenerateUrl(string protocol, string host, VirtualPathData pathData, string fragment) - { - return base.GenerateUrl( - protocol, - host, - pathData, - fragment); - } - } } } \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTestBase.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTestBase.cs new file mode 100644 index 0000000000..d6dfe08822 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTestBase.cs @@ -0,0 +1,1036 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ObjectPool; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + public abstract class UrlHelperTestBase + { + [Theory] + [InlineData(null, null, null)] + [InlineData("/myapproot", null, null)] + [InlineData("", "/Home/About", "/Home/About")] + [InlineData("/myapproot", "/test", "/test")] + public void Content_ReturnsContentPath_WhenItDoesNotStartWithToken( + string appRoot, + string contentPath, + string expectedPath) + { + // Arrange + var urlHelper = CreateUrlHelper(appRoot); + + // Act + var path = urlHelper.Content(contentPath); + + // Assert + Assert.Equal(expectedPath, path); + } + + [Theory] + [InlineData(null, "~/Home/About", "/Home/About")] + [InlineData("/", "~/Home/About", "/Home/About")] + [InlineData("/", "~/", "/")] + [InlineData("/myapproot", "~/", "/myapproot/")] + [InlineData("", "~/Home/About", "/Home/About")] + [InlineData("/", "~", "/")] + [InlineData("/myapproot", "~/Content/bootstrap.css", "/myapproot/Content/bootstrap.css")] + public void Content_ReturnsAppRelativePath_WhenItStartsWithToken( + string appRoot, + string contentPath, + string expectedPath) + { + // Arrange + var urlHelper = CreateUrlHelper(appRoot); + + // Act + var path = urlHelper.Content(contentPath); + + // Assert + Assert.Equal(expectedPath, path); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsLocalUrl_ReturnsFalseOnEmpty(string url) + { + // Arrange + var helper = CreateUrlHelper(); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("/foo.html")] + [InlineData("/www.example.com")] + [InlineData("/")] + public void IsLocalUrl_AcceptsRootedUrls(string url) + { + // Arrange + var helper = CreateUrlHelper(); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("~/")] + [InlineData("~/foo.html")] + public void IsLocalUrl_AcceptsApplicationRelativeUrls(string url) + { + // Arrange + var helper = CreateUrlHelper(); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("foo.html")] + [InlineData("../foo.html")] + [InlineData("fold/foo.html")] + public void IsLocalUrl_RejectsRelativeUrls(string url) + { + // Arrange + var helper = CreateUrlHelper(); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("http:/foo.html")] + [InlineData("hTtP:foo.html")] + [InlineData("http:/www.example.com")] + [InlineData("HtTpS:/www.example.com")] + public void IsLocalUrl_RejectValidButUnsafeRelativeUrls(string url) + { + // Arrange + var helper = CreateUrlHelper(); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("http://www.mysite.com/appDir/foo.html")] + [InlineData("http://WWW.MYSITE.COM")] + public void IsLocalUrl_RejectsUrlsOnTheSameHost(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("http://localhost/foobar.html")] + [InlineData("http://127.0.0.1/foobar.html")] + public void IsLocalUrl_RejectsUrlsOnLocalHost(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("https://www.mysite.com/")] + public void IsLocalUrl_RejectsUrlsOnTheSameHostButDifferentScheme(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("http://www.example.com")] + [InlineData("https://www.example.com")] + [InlineData("hTtP://www.example.com")] + [InlineData("HtTpS://www.example.com")] + public void IsLocalUrl_RejectsUrlsOnDifferentHost(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("http://///www.example.com/foo.html")] + [InlineData("https://///www.example.com/foo.html")] + [InlineData("HtTpS://///www.example.com/foo.html")] + [InlineData("http:///www.example.com/foo.html")] + [InlineData("http:////www.example.com/foo.html")] + public void IsLocalUrl_RejectsUrlsWithTooManySchemeSeparatorCharacters(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("//www.example.com")] + [InlineData("//www.example.com?")] + [InlineData("//www.example.com:80")] + [InlineData("//www.example.com/foobar.html")] + [InlineData("///www.example.com")] + [InlineData("//////www.example.com")] + public void IsLocalUrl_RejectsUrlsWithMissingSchemeName(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("http:\\\\www.example.com")] + [InlineData("http:\\\\www.example.com\\")] + [InlineData("/\\")] + [InlineData("/\\foo")] + public void IsLocalUrl_RejectsInvalidUrls(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("~//www.example.com")] + [InlineData("~//www.example.com?")] + [InlineData("~//www.example.com:80")] + [InlineData("~//www.example.com/foobar.html")] + [InlineData("~///www.example.com")] + [InlineData("~//////www.example.com")] + public void IsLocalUrl_RejectsTokenUrlsWithMissingSchemeName(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("~/\\")] + [InlineData("~/\\foo")] + public void IsLocalUrl_RejectsInvalidTokenUrls(string url) + { + // Arrange + var helper = CreateUrlHelper(appRoot: string.Empty, host: "www.mysite.com", protocol: null); + + // Act + var result = helper.IsLocalUrl(url); + + // Assert + Assert.False(result); + } + + [Fact] + public void RouteUrlWithDictionary() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + values: new RouteValueDictionary( + new + { + Action = "newaction", + Controller = "home2", + id = "someid" + })); + + // Assert + Assert.Equal("/app/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithEmptyHostName() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new RouteValueDictionary( + new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }), + protocol: "http", + host: string.Empty); + + // Assert + Assert.Equal("http://localhost/app/named/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithEmptyProtocol() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new RouteValueDictionary( + new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }), + protocol: string.Empty, + host: "foo.bar.com"); + + // Assert + Assert.Equal("http://foo.bar.com/app/named/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithNullProtocol() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new RouteValueDictionary( + new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }), + protocol: null, + host: "foo.bar.com"); + + // Assert + Assert.Equal("http://foo.bar.com/app/named/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithNullProtocolAndNullHostName() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new RouteValueDictionary( + new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }), + protocol: null, + host: null); + + // Assert + Assert.Equal("/app/named/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithObjectProperties() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl(new { Action = "newaction", Controller = "home2", id = "someid" }); + + // Assert + Assert.Equal("/app/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithProtocol() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }, + protocol: "https"); + + // Assert + Assert.Equal("https://localhost/app/named/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrl_WithUnicodeHost_DoesNotPunyEncodeTheHost() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }, + protocol: "https", + host: "pingüino"); + + // Assert + Assert.Equal("https://pingüino/app/named/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrl_GeneratesUrl_WithRouteName_UsingDefaultValues_WhenExplicitOrAmbientValues_NotPresent() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "OrdersApi", + values: new { id = "500" }); + + // Assert + Assert.Equal("/app/api/orders/500", url); + } + + [Fact] + public void RouteUrl_WithRouteName_DoesNotGenerateUrl_WhenRequiredValueForParameter_NotPresent() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "OrdersApi", + values: new { }); + + // Assert + Assert.Null(url); + } + + [Fact] + public void RouteUrlWithRouteNameAndDictionary() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new RouteValueDictionary( + new + { + Action = "newaction", + Controller = "home2", + id = "someid" + })); + + // Assert + Assert.Equal("/app/named/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithRouteNameAndObjectProperties() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }); + + // Assert + Assert.Equal("/app/named/home2/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithUrlRouteContext_ReturnsExpectedResult() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + var routeContext = new UrlRouteContext() + { + RouteName = "namedroute", + Values = new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }, + Fragment = "somefragment", + Host = "remotetown", + Protocol = "ftp" + }; + + // Act + var url = urlHelper.RouteUrl(routeContext); + + // Assert + Assert.Equal("ftp://remotetown/app/named/home2/newaction/someid#somefragment", url); + } + + [Fact] + public void RouteUrlWithAllParameters_ReturnsExpectedResult() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.RouteUrl( + routeName: "namedroute", + values: new + { + Action = "newaction", + Controller = "home2", + id = "someid" + }, + fragment: "somefragment", + host: "remotetown", + protocol: "https"); + + // Assert + Assert.Equal("https://remotetown/app/named/home2/newaction/someid#somefragment", url); + } + + [Fact] + public void UrlAction_RouteValuesAsDictionary_CaseSensitive() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // We're using a dictionary with a case-sensitive comparer and loading it with data + // using casings differently from the route. This should still successfully generate a link. + var dictionary = new Dictionary(); + var id = "suppliedid"; + var isprint = "true"; + dictionary["ID"] = id; + dictionary["isprint"] = isprint; + + // Act + var url = urlHelper.Action( + action: "contact", + controller: "home", + values: dictionary); + + // Assert + Assert.Equal(2, dictionary.Count); + Assert.Same(id, dictionary["ID"]); + Assert.Same(isprint, dictionary["isprint"]); + Assert.Equal("/app/home/contact/suppliedid?isprint=true", url); + } + + [Fact] + public void UrlAction_WithUnicodeHost_DoesNotPunyEncodeTheHost() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.Action( + action: "contact", + controller: "home", + values: null, + protocol: "http", + host: "pingüino"); + + // Assert + Assert.Equal("http://pingüino/app/home/contact", url); + } + + [Fact] + public void UrlRouteUrl_RouteValuesAsDictionary_CaseSensitive() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // We're using a dictionary with a case-sensitive comparer and loading it with data + // using casings differently from the route. This should still successfully generate a link. + var dict = new Dictionary(); + var action = "contact"; + var controller = "home"; + var id = "suppliedid"; + + dict["ACTION"] = action; + dict["Controller"] = controller; + dict["ID"] = id; + + // Act + var url = urlHelper.RouteUrl(routeName: "namedroute", values: dict); + + // Assert + Assert.Equal(3, dict.Count); + Assert.Same(action, dict["ACTION"]); + Assert.Same(controller, dict["Controller"]); + Assert.Same(id, dict["ID"]); + Assert.Equal("/app/named/home/contact/suppliedid", url); + } + + [Fact] + public void UrlActionWithUrlActionContext_ReturnsExpectedResult() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + var actionContext = new UrlActionContext() + { + Action = "contact", + Controller = "home3", + Values = new { id = "idone" }, + Protocol = "ftp", + Host = "remotelyhost", + Fragment = "somefragment" + }; + + // Act + var url = urlHelper.Action(actionContext); + + // Assert + Assert.Equal("ftp://remotelyhost/app/home3/contact/idone#somefragment", url); + } + + [Fact] + public void UrlActionWithAllParameters_ReturnsExpectedResult() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.Action( + controller: "home3", + action: "contact", + values: null, + protocol: "https", + host: "remotelyhost", + fragment: "somefragment"); + + // Assert + Assert.Equal("https://remotelyhost/app/home3/contact#somefragment", url); + } + + [Fact] + public void LinkWithAllParameters_ReturnsExpectedResult() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.Link( + "namedroute", + new + { + Action = "newaction", + Controller = "home", + id = "someid" + }); + + // Assert + Assert.Equal("http://localhost/app/named/home/newaction/someid", url); + } + + [Fact] + public void LinkWithNullRouteName_ReturnsExpectedResult() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes(); + + // Act + var url = urlHelper.Link( + null, + new + { + Action = "newaction", + Controller = "home", + id = "someid" + }); + + // Assert + Assert.Equal("http://localhost/app/home/newaction/someid", url); + } + + [Fact] + public void RouteUrlWithRouteNameAndDefaults() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes( + "/app", + host: null, + protocol: null, + routeName: "MyRouteName", + template: "any/url"); + + // Act + var url = urlHelper.RouteUrl("MyRouteName"); + + // Assert + Assert.Equal("/app/any/url", url); + } + + [Fact] + public void LinkWithDefaultsAndNullRouteValues_ReturnsExpectedResult() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes( + "/app", + host: null, + protocol: null, + routeName: "MyRouteName", + template: "any/url"); + + // Act + var url = urlHelper.Link("MyRouteName", null); + + // Assert + Assert.Equal("http://localhost/app/any/url", url); + } + + [Fact] + public void LinkWithCustomHostAndProtocol_ReturnsExpectedResult() + { + // Arrange + var urlHelper = CreateUrlHelperWithDefaultRoutes( + string.Empty, + "myhost", + "https", + routeName: "MyRouteName", + template: "any/url"); + + // Act + var url = urlHelper.Link( + "namedroute", + new + { + Action = "newaction", + Controller = "home", + id = "someid" + }); + + // Assert + Assert.Equal("https://myhost/named/home/newaction/someid", url); + } + + [Fact] + public void GetUrlHelper_ReturnsSameInstance_IfAlreadyPresent() + { + // Arrange + var expectedUrlHelper = CreateUrlHelper(); + var httpContext = new Mock(); + httpContext.SetupGet(h => h.Features).Returns(new FeatureCollection()); + var mockItems = new Dictionary + { + { typeof(IUrlHelper), expectedUrlHelper } + }; + httpContext.Setup(h => h.Items).Returns(mockItems); + + var actionContext = CreateActionContext(httpContext.Object); + var urlHelperFactory = new UrlHelperFactory(); + + // Act + var urlHelper = urlHelperFactory.GetUrlHelper(actionContext); + + // Assert + Assert.Same(expectedUrlHelper, urlHelper); + } + + [Fact] + public void GetUrlHelper_CreatesNewInstance_IfNotAlreadyPresent() + { + // Arrange + var httpContext = new Mock(); + httpContext.SetupGet(h => h.Features).Returns(new FeatureCollection()); + httpContext.Setup(h => h.Items).Returns(new Dictionary()); + + var actionContext = CreateActionContext(httpContext.Object); + var urlHelperFactory = new UrlHelperFactory(); + + // Act + var urlHelper = urlHelperFactory.GetUrlHelper(actionContext); + + // Assert + Assert.NotNull(urlHelper); + Assert.Same(urlHelper, actionContext.HttpContext.Items[typeof(IUrlHelper)] as IUrlHelper); + } + + [Fact] + public void GetUrlHelper_CreatesNewInstance_IfExpectedTypeIsNotPresent() + { + // Arrange + var httpContext = new Mock(); + httpContext.SetupGet(h => h.Features).Returns(new FeatureCollection()); + var mockItems = new Dictionary + { + { typeof(IUrlHelper), null } + }; + httpContext.Setup(h => h.Items).Returns(mockItems); + + var actionContext = CreateActionContext(httpContext.Object); + var urlHelperFactory = new UrlHelperFactory(); + + // Act + var urlHelper = urlHelperFactory.GetUrlHelper(actionContext); + + // Assert + Assert.NotNull(urlHelper); + Assert.Same(urlHelper, actionContext.HttpContext.Items[typeof(IUrlHelper)] as IUrlHelper); + } + + // Regression test for aspnet/Mvc#2859 + [Fact] + public void Action_RouteValueInvalidation_DoesNotAffectActionAndController() + { + // Arrange + var urlHelper = CreateUrlHelper( + appRoot: "", + host: null, + protocol: null, + "default", + "{first}/{controller}/{action}", + new { second = "default", controller = "default", action = "default" }); + + var routeData = urlHelper.ActionContext.RouteData; + routeData.Values.Add("first", "a"); + routeData.Values.Add("controller", "Store"); + routeData.Values.Add("action", "Buy"); + + // Act + // + // In this test the 'first' route value has changed, meaning that *normally* the + // 'controller' value could not be used. However 'controller' and 'action' are treated + // specially by UrlHelper. + var url = urlHelper.Action("Checkout", new { first = "b" }); + + // Assert + Assert.NotNull(url); + Assert.Equal("/b/Store/Checkout", url); + } + + // Regression test for aspnet/Mvc#2859 + [Fact] + public void Action_RouteValueInvalidation_AffectsOtherRouteValues() + { + // Arrange + var urlHelper = CreateUrlHelper( + appRoot: "", + host: null, + protocol: null, + "default", + "{first}/{second}/{controller}/{action}", + new { second = "default", controller = "default", action = "default" }); + + var routeData = urlHelper.ActionContext.RouteData; + routeData.Values.Add("first", "a"); + routeData.Values.Add("second", "x"); + routeData.Values.Add("controller", "Store"); + routeData.Values.Add("action", "Buy"); + + // Act + // + // In this test the 'first' route value has changed, meaning that *normally* the + // 'controller' value could not be used. However 'controller' and 'action' are treated + // specially by UrlHelper. + // + // 'second' gets no special treatment, and picks up its default value instead. + var url = urlHelper.Action("Checkout", new { first = "b" }); + + // Assert + Assert.NotNull(url); + Assert.Equal("/b/default/Store/Checkout", url); + } + + // Regression test for aspnet/Mvc#2859 + [Fact] + public void Action_RouteValueInvalidation_DoesNotAffectActionAndController_ActionPassedInRouteValues() + { + // Arrange + var urlHelper = CreateUrlHelper( + appRoot: "", + host: null, + protocol: null, + "default", + "{first}/{controller}/{action}", + new { second = "default", controller = "default", action = "default" }); + + var routeData = urlHelper.ActionContext.RouteData; + routeData.Values.Add("first", "a"); + routeData.Values.Add("controller", "Store"); + routeData.Values.Add("action", "Buy"); + + // Act + // + // In this test the 'first' route value has changed, meaning that *normally* the + // 'controller' value could not be used. However 'controller' and 'action' are treated + // specially by UrlHelper. + var url = urlHelper.Action(action: null, values: new { first = "b", action = "Checkout" }); + + // Assert + Assert.NotNull(url); + Assert.Equal("/b/Store/Checkout", url); + } + + protected abstract IServiceProvider CreateServices(); + + protected abstract IUrlHelper CreateUrlHelper(ActionContext actionContext); + + protected abstract IUrlHelper CreateUrlHelperWithDefaultRoutes( + string appRoot, + string host, + string protocol); + + protected abstract IUrlHelper CreateUrlHelperWithDefaultRoutes( + string appRoot, + string host, + string protocol, + string routeName, + string template); + + protected abstract IUrlHelper CreateUrlHelper( + string appRoot, + string host, + string protocol, + string routeName, + string template, + object defaults); + + protected virtual IUrlHelper CreateUrlHelper(string appRoot, string host, string protocol) + { + appRoot = string.IsNullOrEmpty(appRoot) ? string.Empty : appRoot; + host = string.IsNullOrEmpty(host) ? "localhost" : host; + + var services = CreateServices(); + var httpContext = CreateHttpContext(services, appRoot, host, protocol); + + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + return CreateUrlHelper(actionContext); + } + + protected virtual ActionContext CreateActionContext(HttpContext httpContext, RouteData routeData = null) + { + routeData = routeData ?? new RouteData(); + return new ActionContext(httpContext, routeData, new ActionDescriptor()); + } + + protected virtual HttpContext CreateHttpContext( + IServiceProvider services, + string appRoot, + string host, + string protocol) + { + appRoot = string.IsNullOrEmpty(appRoot) ? string.Empty : appRoot; + host = string.IsNullOrEmpty(host) ? "localhost" : host; + + var context = new DefaultHttpContext(); + context.RequestServices = services; + context.Request.PathBase = new PathString(appRoot); + context.Request.Host = new HostString(host); + context.Request.Scheme = protocol; + return context; + } + + protected IServiceCollection GetCommonServices() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddLogging(); + services.AddRouting(); + services + .AddSingleton() + .AddSingleton(UrlEncoder.Default); + return services; + } + + private IUrlHelper CreateUrlHelper(string appRoot = "") + { + return CreateUrlHelper(appRoot, host: null, protocol: null); + } + + private IUrlHelper CreateUrlHelperWithDefaultRoutes() + { + return CreateUrlHelperWithDefaultRoutes(appRoot: "/app", host: null, protocol: null); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ServiceFilterAttributeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ServiceFilterAttributeTest.cs new file mode 100644 index 0000000000..e21e55018c --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ServiceFilterAttributeTest.cs @@ -0,0 +1,62 @@ +// 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.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc +{ + public class ServiceFilterAttributeTest + { + [Fact] + public void CreateService_GetsFilterFromServiceProvider() + { + // Arrange + var expected = new TestFilter(); + var serviceProvider = new ServiceCollection() + .AddSingleton(expected) + .BuildServiceProvider(); + + var serviceFilter = new ServiceFilterAttribute(typeof(TestFilter)); + + // Act + var filter = serviceFilter.CreateInstance(serviceProvider); + + // Assert + Assert.Same(expected, filter); + } + + [Fact] + public void CreateService_UnwrapsFilterFactory() + { + // Arrange + var serviceProvider = new ServiceCollection() + .AddSingleton(new TestFilterFactory()) + .BuildServiceProvider(); + + var serviceFilter = new ServiceFilterAttribute(typeof(TestFilterFactory)); + + // Act + var filter = serviceFilter.CreateInstance(serviceProvider); + + // Assert + Assert.IsType(filter); + } + + public class TestFilter : IFilterMetadata + { + } + + public class TestFilterFactory : IFilterFactory + { + public bool IsReusable => throw new NotImplementedException(); + + public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) + { + return new TestFilter(); + } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/TypeFilterAttributeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/TypeFilterAttributeTest.cs new file mode 100644 index 0000000000..27d407b837 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/TypeFilterAttributeTest.cs @@ -0,0 +1,116 @@ +// 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.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc +{ + public class TypeFilterAttributeTest + { + [Fact] + public void CreateService_TypeActivatesImplementationType() + { + // Arrange + var value = "Some value"; + var uri = new Uri("http://www.asp.net"); + var serviceProvider = new ServiceCollection() + .AddSingleton(value) + .AddSingleton(uri) + .BuildServiceProvider(); + + var typeFilter = new TypeFilterAttribute(typeof(TestFilter)); + + // Act + var filter = typeFilter.CreateInstance(serviceProvider); + + // Assert + var testFilter = Assert.IsType(filter); + Assert.Same(value, testFilter.Value); + Assert.Same(uri, testFilter.Uri); + } + + [Fact] + public void CreateService_UsesArguments() + { + // Arrange + var value = "Some value"; + var uri = new Uri("http://www.asp.net"); + var serviceProvider = new ServiceCollection() + .AddSingleton("Value in DI") + .AddSingleton(uri) + .BuildServiceProvider(); + + var typeFilter = new TypeFilterAttribute(typeof(TestFilter)) + { + Arguments = new[] { value, } + }; + + // Act + var filter = typeFilter.CreateInstance(serviceProvider); + + // Assert + var testFilter = Assert.IsType(filter); + Assert.Same(value, testFilter.Value); + Assert.Same(uri, testFilter.Uri); + } + + [Fact] + public void CreateService_UnwrapsFilterFactory() + { + // Arrange + var value = "Some value"; + var uri = new Uri("http://www.asp.net"); + var serviceProvider = new ServiceCollection() + .AddSingleton("Value in DI") + .AddSingleton(uri) + .BuildServiceProvider(); + + var typeFilter = new TypeFilterAttribute(typeof(TestFilterFactory)) + { + Arguments = new[] { value, } + }; + + // Act + var filter = typeFilter.CreateInstance(serviceProvider); + + // Assert + var testFilter = Assert.IsType(filter); + Assert.Same(value, testFilter.Value); + Assert.Same(uri, testFilter.Uri); + } + + public class TestFilter : IFilterMetadata + { + public TestFilter(string value, Uri uri) + { + Value = value; + Uri = uri; + } + + public string Value { get; } + public Uri Uri { get; } + } + + public class TestFilterFactory : IFilterFactory + { + private readonly string _value; + private readonly Uri _uri; + + public TestFilterFactory(string value, Uri uri) + { + _value = value; + _uri = uri; + } + + public bool IsReusable => throw new NotImplementedException(); + + public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) + { + return new TestFilter(_value, _uri); + } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ValidationProblemDetailsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ValidationProblemDetailsTest.cs index af4f0d9a34..9c8f296d60 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ValidationProblemDetailsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/ValidationProblemDetailsTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.ModelBinding; using Xunit; @@ -73,5 +74,34 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(new[] { "The input was not valid." }, item.Value); }); } + + [Fact] + public void Constructor_CopiesPassedInDictionary() + { + // Arrange + var errors = new Dictionary + { + ["key1"] = new[] { "error1", "error2" }, + ["key2"] = new[] { "error3", }, + }; + + // Act + var problemDescription = new ValidationProblemDetails(errors); + + // Assert + Assert.Equal("One or more validation errors occurred.", problemDescription.Title); + Assert.Collection( + problemDescription.Errors, + item => + { + Assert.Equal("key1", item.Key); + Assert.Equal(new[] { "error1", "error2" }, item.Value); + }, + item => + { + Assert.Equal("key2", item.Key); + Assert.Equal(new[] { "error3" }, item.Value); + }); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/VirtualFileResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/VirtualFileResultTest.cs index 21a20fb637..e9aed292ab 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/VirtualFileResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.Test/VirtualFileResultTest.cs @@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/ActivityReplacer.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/ActivityReplacer.cs new file mode 100644 index 0000000000..f7bc9d8193 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/ActivityReplacer.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; +using System.Diagnostics; + +namespace Microsoft.AspNetCore.Mvc +{ + public class ActivityReplacer : IDisposable + { + private readonly Activity _activity; + + public ActivityReplacer() + { + _activity = new Activity("Test"); + _activity.Start(); + } + + public void Dispose() + { + Debug.Assert(Activity.Current == _activity); + _activity.Stop(); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/CommonFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/CommonFilterTest.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/CommonFilterTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/CommonFilterTest.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/CommonResourceInvokerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/CommonResourceInvokerTest.cs similarity index 99% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/CommonResourceInvokerTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/CommonResourceInvokerTest.cs index 86d2f27071..ed4e80c7ff 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/CommonResourceInvokerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/CommonResourceInvokerTest.cs @@ -1555,7 +1555,7 @@ namespace Microsoft.AspNetCore.Mvc var invoker = CreateInvoker( new IFilterMetadata[] { - resourceFilter1.Object, // This filter should see the result retured from resourceFilter2 + resourceFilter1.Object, // This filter should see the result returned from resourceFilter2 resourceFilter2.Object, // This filter will short circuit resourceFilter3.Object, // This shouldn't run - it will throw if it does exceptionFilter.Object, // This shouldn't run - it will throw if it does @@ -1603,7 +1603,7 @@ namespace Microsoft.AspNetCore.Mvc var invoker = CreateInvoker( new IFilterMetadata[] { - resourceFilter1.Object, // This filter should see the result retured from resourceFilter2 + resourceFilter1.Object, // This filter should see the result returned from resourceFilter2 resourceFilter2.Object, // This filter will short circuit resourceFilter3.Object, // This shouldn't run - it will throw if it does exceptionFilter.Object, // This shouldn't run - it will throw if it does @@ -1653,7 +1653,7 @@ namespace Microsoft.AspNetCore.Mvc var invoker = CreateInvoker( new IFilterMetadata[] { - resourceFilter1.Object, // This filter should see the result retured from resourceFilter2 + resourceFilter1.Object, // This filter should see the result returned from resourceFilter2 resourceFilter2.Object, resourceFilter3.Object, // This shouldn't run - it will throw if it does resultFilter.Object // This shouldn't run - it will throw if it does diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/LinkBuilder.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/LinkBuilder.cs new file mode 100644 index 0000000000..94d7a90f4c --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/LinkBuilder.cs @@ -0,0 +1,47 @@ +// 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; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Mvc +{ + public class LinkBuilder + { + public LinkBuilder(string url) + { + Url = url; + + Values = new Dictionary + { + { "link", string.Empty } + }; + } + + public string Url { get; set; } + + public Dictionary Values { get; set; } + + public LinkBuilder To(object values) + { + var dictionary = new RouteValueDictionary(values); + foreach (var kvp in dictionary) + { + Values.Add("link_" + kvp.Key, kvp.Value); + } + + return this; + } + + public override string ToString() + { + return Url + "?" + string.Join("&", Values.Select(kvp => kvp.Key + "=" + kvp.Value)); + } + + public static implicit operator string(LinkBuilder builder) + { + return builder.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/MediaTypeAssert.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/MediaTypeAssert.cs similarity index 83% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/MediaTypeAssert.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/MediaTypeAssert.cs index bfd7576836..a6d16fc6f7 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/MediaTypeAssert.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/MediaTypeAssert.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Xunit.Sdk; -namespace Microsoft.AspNetCore.Mvc.TestCommon +namespace Microsoft.AspNetCore.Mvc { public class MediaTypeAssert { @@ -35,11 +35,8 @@ namespace Microsoft.AspNetCore.Mvc.TestCommon throw new EqualException(left.ToString(), right.ToString()); } - MediaTypeHeaderValue leftMediaType = null; - MediaTypeHeaderValue rightMediaType = null; - - if (!MediaTypeHeaderValue.TryParse(left.Value, out leftMediaType) || - !MediaTypeHeaderValue.TryParse(right.Value, out rightMediaType) || + if (!MediaTypeHeaderValue.TryParse(left.Value, out var leftMediaType) || + !MediaTypeHeaderValue.TryParse(right.Value, out var rightMediaType) || !leftMediaType.Equals(rightMediaType)) { throw new EqualException(left.ToString(), right.ToString()); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/Microsoft.AspNetCore.Mvc.Core.TestCommon.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/Microsoft.AspNetCore.Mvc.Core.TestCommon.csproj new file mode 100644 index 0000000000..053be86a52 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/Microsoft.AspNetCore.Mvc.Core.TestCommon.csproj @@ -0,0 +1,18 @@ + + + + $(StandardTestTfms) + + + + + + + + + + + + + + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/NonSeekableReadableStream.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/NonSeekableReadableStream.cs similarity index 97% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/NonSeekableReadableStream.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/NonSeekableReadableStream.cs index b729dc6b68..b5ccd405ac 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/NonSeekableReadableStream.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/NonSeekableReadableStream.cs @@ -6,7 +6,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; -namespace Microsoft.AspNetCore.Mvc.TestCommon +namespace Microsoft.AspNetCore.Mvc { public class NonSeekableReadStream : Stream { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/RoutingResult.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/RoutingResult.cs new file mode 100644 index 0000000000..3a72c75fae --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/RoutingResult.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.Collections.Generic; + +namespace Microsoft.AspNetCore.Mvc +{ + // See TestResponseGenerator for the code that generates this data. + public class RoutingResult + { + public string[] ExpectedUrls { get; set; } + + public string ActualUrl { get; set; } + + public Dictionary RouteValues { get; set; } + + public string RouteName { get; set; } + + public string Action { get; set; } + + public string Controller { get; set; } + + public string Link { get; set; } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/SimpleValueProvider.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/SimpleValueProvider.cs similarity index 96% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/SimpleValueProvider.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/SimpleValueProvider.cs index a5e4fac206..cf0a95240c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/SimpleValueProvider.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/SimpleValueProvider.cs @@ -37,8 +37,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding public ValueProviderResult GetValue(string key) { - object rawValue; - if (TryGetValue(key, out rawValue)) + if (TryGetValue(key, out var rawValue)) { if (rawValue != null && rawValue.GetType().IsArray) { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/SimpleValueProviderFactory.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/SimpleValueProviderFactory.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/SimpleValueProviderFactory.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/SimpleValueProviderFactory.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestClientModelValidatorProvider.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestClientModelValidatorProvider.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestClientModelValidatorProvider.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestClientModelValidatorProvider.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestHttpRequestStreamReaderFactory.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestHttpRequestStreamReaderFactory.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestHttpRequestStreamReaderFactory.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestHttpRequestStreamReaderFactory.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestHttpResponseStreamWriterFactory.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestHttpResponseStreamWriterFactory.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestHttpResponseStreamWriterFactory.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestHttpResponseStreamWriterFactory.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelBinderFactory.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelBinderFactory.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelBinderFactory.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelBinderFactory.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelMetadataProvider.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelMetadataProvider.cs similarity index 86% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelMetadataProvider.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelMetadataProvider.cs index 73fe0c4c6b..684875d565 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelMetadataProvider.cs +++ b/src/Mvc/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; @@ -16,6 +17,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding { public class TestModelMetadataProvider : DefaultModelMetadataProvider { + private static DataAnnotationsMetadataProvider CreateDefaultDataAnnotationsProvider(IStringLocalizerFactory stringLocalizerFactory) + { + var options = Options.Create(new MvcDataAnnotationsLocalizationOptions()); + options.Value.DataAnnotationLocalizerProvider = (modelType, localizerFactory) => localizerFactory.Create(modelType); + + return new DataAnnotationsMetadataProvider(options, stringLocalizerFactory); + } + // Creates a provider with all the defaults - includes data annotations public static ModelMetadataProvider CreateDefaultProvider(IStringLocalizerFactory stringLocalizerFactory = null) { @@ -23,13 +32,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding { new DefaultBindingMetadataProvider(), new DefaultValidationMetadataProvider(), - new DataAnnotationsMetadataProvider( - Options.Create(new MvcDataAnnotationsLocalizationOptions()), - stringLocalizerFactory), + CreateDefaultDataAnnotationsProvider(stringLocalizerFactory), new DataMemberRequiredBindingMetadataProvider(), }; - MvcCoreMvcOptionsSetup.ConfigureAdditionalModelMetadataDetailsProvider(detailsProviders); + 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())); @@ -47,10 +57,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding new DataMemberRequiredBindingMetadataProvider(), }; - MvcCoreMvcOptionsSetup.ConfigureAdditionalModelMetadataDetailsProvider(detailsProviders); + MvcCoreMvcOptionsSetup.ConfigureAdditionalModelMetadataDetailsProviders(detailsProviders); 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())); } @@ -175,7 +188,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding { private List> _bindingActions = new List>(); private List> _displayActions = new List>(); - private List> _valiationActions = new List>(); + private List> _validationActions = new List>(); private readonly ModelMetadataIdentity _key; @@ -210,7 +223,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding { if (_key.Equals(context.Key)) { - foreach (var action in _valiationActions) + foreach (var action in _validationActions) { action(context.ValidationMetadata); } @@ -231,7 +244,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding public IMetadataBuilder ValidationDetails(Action action) { - _valiationActions.Add(action); + _validationActions.Add(action); return this; } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelValidatorProvider.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelValidatorProvider.cs similarity index 72% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelValidatorProvider.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelValidatorProvider.cs index 96d714ee24..4dab51dc37 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelValidatorProvider.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/TestModelValidatorProvider.cs @@ -3,8 +3,7 @@ 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; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation @@ -12,15 +11,18 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation public class TestModelValidatorProvider : CompositeModelValidatorProvider { // Creates a provider with all the defaults - includes data annotations - public static CompositeModelValidatorProvider CreateDefaultProvider() + public static CompositeModelValidatorProvider CreateDefaultProvider(IStringLocalizerFactory stringLocalizerFactory = null) { + var options = Options.Create(new MvcDataAnnotationsLocalizationOptions()); + options.Value.DataAnnotationLocalizerProvider = (modelType, localizerFactory) => localizerFactory.Create(modelType); + var providers = new IModelValidatorProvider[] { new DefaultModelValidatorProvider(), new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), - Options.Create(new MvcDataAnnotationsLocalizationOptions()), - stringLocalizerFactory: null) + options, + stringLocalizerFactory) }; return new TestModelValidatorProvider(providers); @@ -31,4 +33,4 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation { } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/ValidationAttributeUtil.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/ValidationAttributeUtil.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/ValidationAttributeUtil.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Core.TestCommon/ValidationAttributeUtil.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Cors.Test/Internal/CorsApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Cors.Test/Internal/CorsApplicationModelProviderTest.cs index 3b9ab90213..4543e2e8cd 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Cors.Test/Internal/CorsApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Cors.Test/Internal/CorsApplicationModelProviderTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Cors; @@ -10,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -36,6 +38,8 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] @@ -55,10 +59,12 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] - public void CreateControllerModel_CustomCorsFilter_ReplacesHttpConstraints() + public void CreateControllerModel_CustomCorsFilter_EnablesCorsPreflight() { // Arrange var corsProvider = new CorsApplicationModelProvider(); @@ -73,6 +79,8 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] @@ -92,6 +100,8 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] @@ -111,10 +121,12 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] - public void BuildActionModel_CustomCorsAuthorizationFilterOnAction_ReplacesHttpConstraints() + public void BuildActionModel_CustomCorsAuthorizationFilterOnAction_EnablesCorsPreflight() { // Arrange var corsProvider = new CorsApplicationModelProvider(); @@ -129,10 +141,12 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] - public void CreateControllerModel_EnableCorsGloballyReplacesHttpMethodConstraints() + public void CreateControllerModel_EnableCorsGloballyEnablesCorsPreflight() { // Arrange var corsProvider = new CorsApplicationModelProvider(); @@ -150,10 +164,12 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] - public void CreateControllerModel_DisableCorsGloballyReplacesHttpMethodConstraints() + public void CreateControllerModel_DisableCorsGloballyEnablesCorsPreflight() { // Arrange var corsProvider = new CorsApplicationModelProvider(); @@ -169,10 +185,12 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] - public void CreateControllerModel_CustomCorsFilterGloballyReplacesHttpMethodConstraints() + public void CreateControllerModel_CustomCorsFilterGloballyEnablesCorsPreflight() { // Arrange var corsProvider = new CorsApplicationModelProvider(); @@ -188,6 +206,8 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.True(httpMethodMetadata.AcceptCorsPreflight); } [Fact] @@ -206,6 +226,8 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var selector = Assert.Single(action.Selectors); var constraint = Assert.Single(selector.ActionConstraints, c => c is HttpMethodActionConstraint); Assert.IsNotType(constraint); + var httpMethodMetadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.False(httpMethodMetadata.AcceptCorsPreflight); } private static ApplicationModelProviderContext GetProviderContext(Type controllerType) @@ -213,7 +235,7 @@ namespace Microsoft.AspNetCore.Mvc.Cors.Internal var context = new ApplicationModelProviderContext(new[] { controllerType.GetTypeInfo() }); var provider = new DefaultApplicationModelProvider( Options.Create(new MvcOptions()), - TestModelMetadataProvider.CreateDefaultProvider()); + new EmptyModelMetadataProvider()); provider.OnProvidersExecuting(context); return context; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Cors.Test/Microsoft.AspNetCore.Mvc.Cors.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Cors.Test/Microsoft.AspNetCore.Mvc.Cors.Test.csproj index f73ee9967c..59269389e9 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Cors.Test/Microsoft.AspNetCore.Mvc.Cors.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Cors.Test/Microsoft.AspNetCore.Mvc.Cors.Test.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) @@ -6,10 +6,8 @@ - + - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/DataAnnotationsModelValidatorProviderTest.cs similarity index 79% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorProviderTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/DataAnnotationsModelValidatorProviderTest.cs index e0a156e8b1..4889e7c4da 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorProviderTest.cs +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/CompareAttributeAdapterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/CompareAttributeAdapterTest.cs index 8466b511fb..ab3c4d0b5d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/CompareAttributeAdapterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/CompareAttributeAdapterTest.cs @@ -1,10 +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 System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Microsoft.AspNetCore.Testing.xunit; using Microsoft.Extensions.Localization; @@ -26,16 +26,14 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var attribute = new CompareAttribute("OtherProperty"); var adapter = new CompareAttributeAdapter(attribute, stringLocalizer: null); - // Mono issue - https://github.com/aspnet/External/issues/19 - var expectedMessage = PlatformNormalizer.NormalizeContent( - "'MyPropertyDisplayName' and 'OtherPropertyDisplayName' do not match."); + var expectedMessage = "'MyPropertyDisplayName' and 'OtherPropertyDisplayName' do not match."; var actionContext = new ActionContext(); var context = new ClientModelValidationContext( actionContext, metadata, metadataProvider, - new AttributeDictionary()); + new Dictionary()); // Act adapter.AddValidation(context); @@ -78,7 +76,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal actionContext, metadata, metadataProvider, - new AttributeDictionary()); + new Dictionary()); // Act adapter.AddValidation(context); @@ -106,15 +104,14 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var attribute = new CompareAttribute("OtherProperty"); var adapter = new CompareAttributeAdapter(attribute, stringLocalizer: null); - // Mono issue - https://github.com/aspnet/External/issues/19 - var expectedMessage = PlatformNormalizer.NormalizeContent("'MyProperty' and 'OtherProperty' do not match."); + var expectedMessage = "'MyProperty' and 'OtherProperty' do not match."; var actionContext = new ActionContext(); var context = new ClientModelValidationContext( actionContext, metadata, metadataProvider, - new AttributeDictionary()); + new Dictionary()); // Act adapter.AddValidation(context); @@ -151,7 +148,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal actionContext, metadata, metadataProvider, - new AttributeDictionary()); + new Dictionary()); // Act adapter.AddValidation(context); @@ -191,7 +188,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal actionContext, metadata, metadataProvider, - new AttributeDictionary()); + new Dictionary()); // Act adapter.AddValidation(context); @@ -224,7 +221,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal actionContext, metadata, metadataProvider, - new AttributeDictionary()); + new Dictionary()); context.Attributes.Add("data-val", "original"); context.Attributes.Add("data-val-equalto", "original"); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsMetadataProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsMetadataProviderTest.cs index ac90d1f155..0f18010965 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsMetadataProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsMetadataProviderTest.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Testing; -using Microsoft.Extensions.Internal; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using Moq; @@ -18,6 +17,12 @@ using Xunit; namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal { + public enum TestEnum + { + [Display(Name = "DisplayNameValue")] + DisplayNameValue + } + public class DataAnnotationsMetadataProviderTest { // Includes attributes with a 'simple' effect on display details. @@ -270,6 +275,94 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal Assert.Equal("DisplayNameAttributeValue", context.DisplayMetadata.DisplayName()); } + [Fact] + public void CreateDisplayMetadata_DisplayNameAttribute_OnEnum_CompatSwitchWorks() + { + // Arrange + var unsharedLocalizer = new Mock(MockBehavior.Strict); + unsharedLocalizer + .Setup(s => s["DisplayNameValue"]) + .Returns(new LocalizedString("DisplaynameValue", "didn't use shared")); + + var sharedLocalizer = new Mock(MockBehavior.Strict); + sharedLocalizer + .Setup(s => s["DisplayNameValue"]) + .Returns(() => new LocalizedString("DisplayNameValue", "used shared")); + + var stringLocalizerFactoryMock = new Mock(MockBehavior.Strict); + stringLocalizerFactoryMock + .Setup(s => s.Create(typeof(TestEnum))) + .Returns(() => unsharedLocalizer.Object); + stringLocalizerFactoryMock + .Setup(s => s.Create(typeof(EmptyClass))) + .Returns(() => sharedLocalizer.Object); + + var localizationOptions = Options.Create(new MvcDataAnnotationsLocalizationOptions()); + localizationOptions.Value.AllowDataAnnotationsLocalizationForEnumDisplayAttributes = false; + localizationOptions.Value.DataAnnotationLocalizerProvider = (type, stringLocalizerFactory) => + { + return stringLocalizerFactory.Create(typeof(EmptyClass)); + }; + + var provider = new DataAnnotationsMetadataProvider( + localizationOptions, + stringLocalizerFactory: stringLocalizerFactoryMock.Object); + + var displayName = new DisplayNameAttribute("DisplayNameValue"); + + var attributes = new Attribute[] { displayName }; + var key = ModelMetadataIdentity.ForType(typeof(TestEnum)); + var context = new DisplayMetadataProviderContext(key, GetModelAttributes(attributes)); + + // Act + provider.CreateDisplayMetadata(context); + + // Assert + Assert.Collection(context.DisplayMetadata.EnumGroupedDisplayNamesAndValues, + (e) => Assert.Equal("didn't use shared", e.Key.Name)); + } + + [Fact] + public void CreateDisplayMetadata_DisplayNameAttribute_OnEnum_CompatShimOn() + { + // Arrange + var sharedLocalizer = new Mock(MockBehavior.Strict); + sharedLocalizer + .Setup(s => s["DisplayNameValue"]) + .Returns(new LocalizedString("DisplayNameValue", "Name from DisplayNameAttribute")); + + var stringLocalizerFactoryMock = new Mock(MockBehavior.Strict); + stringLocalizerFactoryMock + .Setup(s => s.Create(typeof(EmptyClass))) + .Returns(() => sharedLocalizer.Object); + + var localizationOptions = Options.Create(new MvcDataAnnotationsLocalizationOptions()); + localizationOptions.Value.AllowDataAnnotationsLocalizationForEnumDisplayAttributes = true; + localizationOptions.Value.DataAnnotationLocalizerProvider = (type, stringLocalizerFactory) => + { + return stringLocalizerFactory.Create(typeof(EmptyClass)); + }; + + var provider = new DataAnnotationsMetadataProvider( + localizationOptions, + stringLocalizerFactory: stringLocalizerFactoryMock.Object); + + var displayName = new DisplayNameAttribute("DisplayNameValue"); + + var attributes = new Attribute[] { displayName }; + var key = ModelMetadataIdentity.ForType(typeof(TestEnum)); + var context = new DisplayMetadataProviderContext(key, GetModelAttributes(attributes)); + + // Act + provider.CreateDisplayMetadata(context); + + // Assert + Assert.Collection(context.DisplayMetadata.EnumGroupedDisplayNamesAndValues, (e) => + { + Assert.Equal("Name from DisplayNameAttribute", e.Key.Name); + }); + } + [Fact] public void CreateDisplayMetadata_DisplayNameAttribute_LocalizesDisplayName() { @@ -568,16 +661,13 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal stringLocalizerFactoryMock .Setup(f => f.Create(It.IsAny())) .Returns(stringLocalizer.Object); - var options = Options.Create(new MvcDataAnnotationsLocalizationOptions()); options.Value.DataAnnotationLocalizerProvider = (type, stringLocalizerFactory) => { return stringLocalizerFactory.Create(type); }; - var provider = new DataAnnotationsMetadataProvider( - options, - stringLocalizerFactoryMock.Object); + var provider = new DataAnnotationsMetadataProvider(options, stringLocalizerFactoryMock.Object); var display = new DisplayAttribute() { @@ -594,13 +684,13 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal provider.CreateDisplayMetadata(context); // Assert - using (new CultureReplacer("en-US", "en-US")) + using(new CultureReplacer("en-US", "en-US")) { Assert.Equal("name from localizer en-US", context.DisplayMetadata.DisplayName()); Assert.Equal("description from localizer en-US", context.DisplayMetadata.Description()); Assert.Equal("prompt from localizer en-US", context.DisplayMetadata.Placeholder()); } - using (new CultureReplacer("fr-FR", "fr-FR")) + using(new CultureReplacer("fr-FR", "fr-FR")) { Assert.Equal("name from localizer fr-FR", context.DisplayMetadata.DisplayName()); Assert.Equal("description from localizer fr-FR", context.DisplayMetadata.Description()); @@ -840,14 +930,14 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal .Setup(s => s[It.IsAny()]) .Returns((index) => new LocalizedString(index, index + " value")); - var stringLocalizerFactory = new Mock(MockBehavior.Strict); - stringLocalizerFactory + var stringLocalizerFactoryMock = new Mock(MockBehavior.Strict); + stringLocalizerFactoryMock .Setup(f => f.Create(It.IsAny())) .Returns(stringLocalizer.Object); - var provider = new DataAnnotationsMetadataProvider( - Options.Create(new MvcDataAnnotationsLocalizationOptions()), - stringLocalizerFactory.Object); + var options = Options.Create(new MvcDataAnnotationsLocalizationOptions()); + options.Value.DataAnnotationLocalizerProvider = (modelType, stringLocalizerFactory) => stringLocalizerFactory.Create(modelType); + var provider = new DataAnnotationsMetadataProvider(options, stringLocalizerFactoryMock.Object); // Act provider.CreateDisplayMetadata(context); @@ -1036,12 +1126,12 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal // Assert var groupTwo = Assert.Single(enumNameAndGroup, e => e.Value.Equals("2", StringComparison.Ordinal)); - using (new CultureReplacer("en-US", "en-US")) + using(new CultureReplacer("en-US", "en-US")) { Assert.Equal("Loc_Two_Name", groupTwo.Key.Name); } - using (new CultureReplacer("fr-FR", "fr-FR")) + using(new CultureReplacer("fr-FR", "fr-FR")) { Assert.Equal("Loc_Two_Name", groupTwo.Key.Name); } @@ -1056,12 +1146,12 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal // Assert var groupTwo = Assert.Single(enumNameAndGroup, e => e.Value.Equals("2", StringComparison.Ordinal)); - using (new CultureReplacer("en-US", "en-US")) + using(new CultureReplacer("en-US", "en-US")) { Assert.Equal("Loc_Two_Name en-US", groupTwo.Key.Name); } - using (new CultureReplacer("fr-FR", "fr-FR")) + using(new CultureReplacer("fr-FR", "fr-FR")) { Assert.Equal("Loc_Two_Name fr-FR", groupTwo.Key.Name); } @@ -1076,12 +1166,12 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal // Assert var groupThree = Assert.Single(enumNameAndGroup, e => e.Value.Equals("3", StringComparison.Ordinal)); - using (new CultureReplacer("en-US", "en-US")) + using(new CultureReplacer("en-US", "en-US")) { Assert.Equal("type three name en-US", groupThree.Key.Name); } - using (new CultureReplacer("fr-FR", "fr-FR")) + using(new CultureReplacer("fr-FR", "fr-FR")) { Assert.Equal("type three name fr-FR", groupThree.Key.Name); } @@ -1096,12 +1186,12 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var groupThree = Assert.Single(enumNameAndGroup, e => e.Value.Equals("3", StringComparison.Ordinal)); // Assert - using (new CultureReplacer("en-US", "en-US")) + using(new CultureReplacer("en-US", "en-US")) { Assert.Equal("type three name en-US", groupThree.Key.Name); } - using (new CultureReplacer("fr-FR", "fr-FR")) + using(new CultureReplacer("fr-FR", "fr-FR")) { Assert.Equal("type three name fr-FR", groupThree.Key.Name); } @@ -1151,6 +1241,38 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal Assert.Equal(initialValue, context.ValidationMetadata.IsRequired); } + [Fact] + public void CreateValidationMetadata_WillAddValidationAttributes_From_ValidationProviderAttribute() + { + // Arrange + var provider = new DataAnnotationsMetadataProvider( + Options.Create(new MvcDataAnnotationsLocalizationOptions()), + stringLocalizerFactory: null); + var validationProviderAttribute = new FooCompositeValidationAttribute( + attributes: new List + { + new RequiredAttribute(), + new StringLengthAttribute(5) + }); + + var attributes = new Attribute[] { new EmailAddressAttribute(), validationProviderAttribute }; + var key = ModelMetadataIdentity.ForProperty(typeof(string), "Length", typeof(string)); + var context = new ValidationMetadataProviderContext(key, GetModelAttributes(new object[0], attributes)); + + // Act + provider.CreateValidationMetadata(context); + + // Assert + var expected = new List + { + new EmailAddressAttribute(), + new RequiredAttribute(), + new StringLengthAttribute(5) + }; + + Assert.Equal(expected, actual: context.ValidationMetadata.ValidatorMetadata); + } + // [Required] has no effect on IsBindingRequired [Theory] [InlineData(true)] @@ -1269,8 +1391,11 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal .Setup(factory => factory.Create(typeof(EnumWithLocalizedDisplayNames))) .Returns(stringLocalizer.Object); + var options = Options.Create(new MvcDataAnnotationsLocalizationOptions()); + options.Value.DataAnnotationLocalizerProvider = (modelType, localizerFactory) => localizerFactory.Create(modelType); + return new DataAnnotationsMetadataProvider( - Options.Create(new MvcDataAnnotationsLocalizationOptions()), + options, useStringLocalizer ? stringLocalizerFactory.Object : null); } @@ -1304,7 +1429,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal public bool Equals(KeyValuePair x, KeyValuePair y) { - using (new CultureReplacer(string.Empty, string.Empty)) + using(new CultureReplacer(string.Empty, string.Empty)) { return x.Key.Name.Equals(y.Key.Name, StringComparison.Ordinal) && x.Key.Group.Equals(y.Key.Group, StringComparison.Ordinal); @@ -1313,14 +1438,9 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal public int GetHashCode(KeyValuePair obj) { - using (new CultureReplacer(string.Empty, string.Empty)) + using(new CultureReplacer(string.Empty, string.Empty)) { - var hashcode = HashCodeCombiner.Start(); - - hashcode.Add(obj.Key.Name); - hashcode.Add(obj.Key.Group); - - return hashcode.CombinedHash; + return obj.Key.GetHashCode(); } } } @@ -1457,5 +1577,20 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal public string Name { get; private set; } } + + private class FooCompositeValidationAttribute : ValidationProviderAttribute + { + private IEnumerable _attributes; + + public FooCompositeValidationAttribute(IEnumerable attributes) + { + _attributes = attributes; + } + + public override IEnumerable GetValidationAttributes() + { + return _attributes; + } + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorTest.cs index 17669fd0d4..b67c769f57 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsModelValidatorTest.cs @@ -262,7 +262,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal } public static TheoryData, IEnumerable> - Valdate_ReturnsExpectedResults_Data + Validate_ReturnsExpectedResults_Data { get { @@ -327,8 +327,8 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal } [Theory] - [MemberData(nameof(Valdate_ReturnsExpectedResults_Data))] - public void Valdate_ReturnsExpectedResults( + [MemberData(nameof(Validate_ReturnsExpectedResults_Data))] + public void Validate_ReturnsExpectedResults( string errorMessage, IEnumerable memberNames, IEnumerable expectedResults) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/FileExtensionsAttributeAdapterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/FileExtensionsAttributeAdapterTest.cs index 0cdb65c847..0bf66c965c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/FileExtensionsAttributeAdapterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/FileExtensionsAttributeAdapterTest.cs @@ -1,9 +1,9 @@ // 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 Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Localization; using Moq; @@ -31,7 +31,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation var expectedErrorMessage = string.Format(attribute.ErrorMessage, nameof(Profile.PhotoFileName), formattedExtensions); var adapter = new FileExtensionsAttributeAdapter(attribute, stringLocalizer: null); - var context = new ClientModelValidationContext(new ActionContext(), metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(new ActionContext(), metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -82,7 +82,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation var expectedErrorMessage = string.Format(attribute.ErrorMessage, nameof(Profile.PhotoFileName), formattedExtensions); var adapter = new FileExtensionsAttributeAdapter(attribute, stringLocalizer: null); - var context = new ClientModelValidationContext(new ActionContext(), metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(new ActionContext(), metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -117,7 +117,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation .Returns(new LocalizedString(attribute.ErrorMessage, expectedErrorMessage)); var adapter = new FileExtensionsAttributeAdapter(attribute, stringLocalizer: stringLocalizer.Object); - var context = new ClientModelValidationContext(new ActionContext(), metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(new ActionContext(), metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -142,7 +142,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation attribute.ErrorMessage = "{0} expects only the following extensions: {1}"; var adapter = new FileExtensionsAttributeAdapter(attribute, stringLocalizer: null); - var context = new ClientModelValidationContext(new ActionContext(), metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(new ActionContext(), metadata, provider, new Dictionary()); context.Attributes.Add("data-val", "original"); context.Attributes.Add("data-val-fileextensions", "original"); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/MaxLengthAttributeAdapterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/MaxLengthAttributeAdapterTest.cs index 3b08753d71..b20d0a4518 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/MaxLengthAttributeAdapterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/MaxLengthAttributeAdapterTest.cs @@ -1,10 +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 System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Localization; using Moq; @@ -35,7 +35,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new MaxLengthAttributeAdapter(attribute, stringLocalizer: stringLocalizer.Object); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -62,7 +62,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var expectedMessage = attribute.FormatErrorMessage("Length"); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -91,7 +91,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new MaxLengthAttributeAdapter(attribute, stringLocalizer: null); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -124,7 +124,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new MaxLengthAttributeAdapter(attribute, stringLocalizer.Object); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -151,7 +151,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var expectedMessage = attribute.FormatErrorMessage("Length"); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); context.Attributes.Add("data-val", "original"); context.Attributes.Add("data-val-maxlength", "original"); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/MinLengthAttributeAdapterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/MinLengthAttributeAdapterTest.cs index 56d6d42efe..a559e00e3c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/MinLengthAttributeAdapterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/MinLengthAttributeAdapterTest.cs @@ -1,10 +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 System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Localization; using Moq; @@ -35,7 +35,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new MinLengthAttributeAdapter(attribute, stringLocalizer: stringLocalizer.Object); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -62,7 +62,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var expectedMessage = attribute.FormatErrorMessage("Length"); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -90,7 +90,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var expectedMessage = "Array must have at least 2 items."; var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -116,7 +116,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new MinLengthAttributeAdapter(attribute, stringLocalizer: null); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); context.Attributes.Add("data-val", "original"); context.Attributes.Add("data-val-minlength", "original"); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelValidationResultComparer.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelValidationResultComparer.cs index 289f30ec35..aafd2d3e60 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelValidationResultComparer.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelValidationResultComparer.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal { @@ -34,11 +33,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal throw new ArgumentNullException(nameof(obj)); } - var hashCodeCombiner = HashCodeCombiner.Start(); - hashCodeCombiner.Add(obj.MemberName, StringComparer.Ordinal); - hashCodeCombiner.Add(obj.Message, StringComparer.Ordinal); - - return hashCodeCombiner.CombinedHash; + return obj.MemberName.GetHashCode(); } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/NumericClientModelValidatorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/NumericClientModelValidatorTest.cs index 71e0b6adfb..581ccbf672 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/NumericClientModelValidatorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/NumericClientModelValidatorTest.cs @@ -1,10 +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 System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Xunit; @@ -23,7 +23,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new NumericClientModelValidator(); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); var expectedMessage = "The field DisplayId must be a number."; @@ -57,7 +57,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new NumericClientModelValidator(); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -89,7 +89,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new NumericClientModelValidator(); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -115,7 +115,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new NumericClientModelValidator(); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -138,7 +138,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new NumericClientModelValidator(); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); context.Attributes.Add("data-val", "original"); context.Attributes.Add("data-val-number", "original"); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/RangeAttributeAdapterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/RangeAttributeAdapterTest.cs index bbf5cf076b..626e915d0c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/RangeAttributeAdapterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/RangeAttributeAdapterTest.cs @@ -1,9 +1,9 @@ // 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 Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Localization; using Moq; @@ -29,7 +29,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation var adapter = new RangeAttributeAdapter(attribute, stringLocalizer: null); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -65,7 +65,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation var adapter = new RangeAttributeAdapter(attribute, stringLocalizer: stringLocalizer.Object); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -93,7 +93,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation var adapter = new RangeAttributeAdapter(attribute, stringLocalizer: null); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); context.Attributes.Add("data-val", "original"); context.Attributes.Add("data-val-range", "original"); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/RequiredAttributeAdapterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/RequiredAttributeAdapterTest.cs index 6dec6abfa7..3ea14c9586 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/RequiredAttributeAdapterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/RequiredAttributeAdapterTest.cs @@ -1,10 +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 System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Localization; using Moq; @@ -36,7 +36,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new RequiredAttributeAdapter(attribute, stringLocalizer: stringLocalizer.Object); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new RequiredAttributeAdapter(attribute, stringLocalizer: null); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -86,7 +86,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new RequiredAttributeAdapter(attribute, stringLocalizer: null); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); context.Attributes.Add("data-val", "original"); context.Attributes.Add("data-val-required", "original"); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/StringLengthAttributeAdapterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/StringLengthAttributeAdapterTest.cs index 87c820dfe8..fdf8bf9186 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/StringLengthAttributeAdapterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/StringLengthAttributeAdapterTest.cs @@ -1,10 +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 System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Localization; using Moq; @@ -36,7 +36,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var adapter = new StringLengthAttributeAdapter(attribute, stringLocalizer: stringLocalizer.Object); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -47,7 +47,6 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, kvp => { Assert.Equal("data-val-length", kvp.Key); Assert.Equal(expectedMessage, kvp.Value); }, kvp => { Assert.Equal("data-val-length-max", kvp.Key); Assert.Equal("8", kvp.Value); }); - } [Fact] @@ -64,7 +63,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var expectedMessage = attribute.FormatErrorMessage("Length"); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -91,7 +90,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var expectedMessage = attribute.FormatErrorMessage("Length"); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -119,7 +118,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var expectedMessage = attribute.FormatErrorMessage("Length"); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); // Act adapter.AddValidation(context); @@ -145,7 +144,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal var expectedMessage = attribute.FormatErrorMessage("Length"); var actionContext = new ActionContext(); - var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new Dictionary()); context.Attributes.Add("data-val", "original"); context.Attributes.Add("data-val-length", "original"); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/TestResources.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/TestResources.cs index 4d02719fa8..86f52aacbd 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/TestResources.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/TestResources.cs @@ -2,7 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Globalization; -using Microsoft.AspNetCore.Mvc.DataAnnotations.Test; +using Resources = Microsoft.AspNetCore.Mvc.DataAnnotations.Test.Resources; namespace Microsoft.AspNetCore.Mvc.ModelBinding { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ValidationAttributeAdapterOfTAttributeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ValidationAttributeAdapterOfTAttributeTest.cs index e616795fcf..3febb5f4e5 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ValidationAttributeAdapterOfTAttributeTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ValidationAttributeAdapterOfTAttributeTest.cs @@ -39,16 +39,16 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal adapter.GetErrorMessage(validationContext); // Assert - Assert.True(attribute.Formated); + Assert.True(attribute.Formatted); } public class TestValidationAttribute : ValidationAttribute { - public bool Formated = false; + public bool Formatted = false; public override string FormatErrorMessage(string name) { - Formated = true; + Formatted = true; return base.FormatErrorMessage(name); } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test.csproj index 84e189a38e..14f4f05b2f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test.csproj @@ -1,14 +1,12 @@ - + $(StandardTestTfms) - + - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Internal/JsonResultExecutorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Internal/JsonResultExecutorTest.cs index 81452cc818..8803474ccd 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Internal/JsonResultExecutorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Internal/JsonResultExecutorTest.cs @@ -5,6 +5,7 @@ using System; using System.Buffers; using System.IO; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -14,6 +15,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; +using Moq; using Newtonsoft.Json; using Xunit; @@ -30,7 +32,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal var context = GetActionContext(); var result = new JsonResult(new { foo = "abcd" }); - var executor = CreateExcutor(); + var executor = CreateExecutor(); // Act await executor.ExecuteAsync(context, result); @@ -51,7 +53,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal var result = new JsonResult(new { foo = "abcd" }); result.ContentType = "text/json"; - var executor = CreateExcutor(); + var executor = CreateExecutor(); // Act await executor.ExecuteAsync(context, result); @@ -75,7 +77,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal { Encoding = Encoding.ASCII }.ToString(); - var executor = CreateExcutor(); + var executor = CreateExecutor(); // Act await executor.ExecuteAsync(context, result); @@ -97,7 +99,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal context.HttpContext.Response.ContentType = expectedContentType; var result = new JsonResult(new { foo = "abcd" }); - var executor = CreateExcutor(); + var executor = CreateExecutor(); // Act await executor.ExecuteAsync(context, result); @@ -122,7 +124,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal context.HttpContext.Response.ContentType = responseContentType; var result = new JsonResult(new { foo = "abcd" }); - var executor = CreateExcutor(); + var executor = CreateExecutor(); // Act await executor.ExecuteAsync(context, result); @@ -147,7 +149,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal serializerSettings.Formatting = Formatting.Indented; var result = new JsonResult(new { foo = "abcd" }, serializerSettings); - var executor = CreateExcutor(); + var executor = CreateExecutor(); // Act await executor.ExecuteAsync(context, result); @@ -165,7 +167,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal var expected = Encoding.UTF8.GetBytes("{\"name\":\"Robert\""); var context = GetActionContext(); var result = new JsonResult(new ModelWithSerializationError()); - var executor = CreateExcutor(); + var executor = CreateExecutor(); // Act try @@ -190,7 +192,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal var expected = "Executing JsonResult, writing value of type 'System.String'."; var context = GetActionContext(); var logger = new StubLogger(); - var executer = CreateExcutor(logger); + var executer = CreateExecutor(logger); var result = new JsonResult("result_value"); // Act @@ -207,7 +209,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal var expected = "Executing JsonResult, writing value of type 'null'."; var context = GetActionContext(); var logger = new StubLogger(); - var executer = CreateExcutor(logger); + var executer = CreateExecutor(logger); var result = new JsonResult(null); // Act @@ -217,7 +219,67 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal Assert.Equal(expected, logger.MostRecentMessage); } - private static JsonResultExecutor CreateExcutor(ILogger logger = null) + [Fact] + public async Task ExecuteAsync_WritesToTheResponseStream_WhenContentIsLargerThanBuffer() + { + // Arrange + var writeLength = 2 * TestHttpResponseStreamWriterFactory.DefaultBufferSize + 4; + var text = new string('a', writeLength); + var expectedWriteCallCount = Math.Ceiling((double)writeLength / TestHttpResponseStreamWriterFactory.DefaultBufferSize); + + var stream = new Mock(); + stream.SetupGet(s => s.CanWrite).Returns(true); + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = stream.Object; + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + var result = new JsonResult(text); + var executor = CreateExecutor(); + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + // HttpResponseStreamWriter buffers content up to the buffer size (16k). When writes exceed the buffer size, it'll perform a synchronous + // write to the response stream. + stream.Verify(s => s.Write(It.IsAny(), It.IsAny(), TestHttpResponseStreamWriterFactory.DefaultBufferSize), Times.Exactly(2)); + + // Remainder buffered content is written asynchronously as part of the FlushAsync. + stream.Verify(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + + // Dispose does not call Flush + stream.Verify(s => s.Flush(), Times.Never()); + } + + [Theory] + [InlineData(5)] + [InlineData(TestHttpResponseStreamWriterFactory.DefaultBufferSize - 30)] + public async Task ExecuteAsync_DoesNotWriteSynchronouslyToTheResponseBody_WhenContentIsSmallerThanBufferSize(int writeLength) + { + // Arrange + var text = new string('a', writeLength); + + var stream = new Mock(); + stream.SetupGet(s => s.CanWrite).Returns(true); + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = stream.Object; + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + var result = new JsonResult(text); + var executor = CreateExecutor(); + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + // HttpResponseStreamWriter buffers content up to the buffer size (16k) and will asynchronously write content to the response as part + // of the FlushAsync call if the content written to it is smaller than the buffer size. + // This test verifies that no synchronous writes are performed in this scenario. + stream.Verify(s => s.Flush(), Times.Never()); + stream.Verify(s => s.Write(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + } + + private static JsonResultExecutor CreateExecutor(ILogger logger = null) { return new JsonResultExecutor( new TestHttpResponseStreamWriterFactory(), diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs index 0441961c2a..30868308ad 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs @@ -11,7 +11,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.ObjectPool; @@ -255,7 +254,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [InlineData("application/some.entity+json;v=2", true)] [InlineData("application/some.entity+xml", false)] [InlineData("application/some.entity+*", false)] - [InlineData("text/some.entity+json", false)] + [InlineData("text/some.entity+json", true)] [InlineData("", false)] [InlineData(null, false)] [InlineData("invalid", false)] diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchInputFormatterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchInputFormatterTest.cs index dc097cd5c4..16961e2866 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchInputFormatterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchInputFormatterTest.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.ObjectPool; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchOperationsArrayProviderTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchOperationsArrayProviderTests.cs index e3c9c4215b..ed140a72e2 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchOperationsArrayProviderTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonPatchOperationsArrayProviderTests.cs @@ -17,8 +17,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json public void OnProvidersExecuting_FindsJsonPatchDocuments_ProvidesOperationsArray() { // Arrange - var metadataprovider = new TestModelMetadataProvider(); - var provider = new JsonPatchOperationsArrayProvider(metadataprovider); + var metadataProvider = new TestModelMetadataProvider(); + var provider = new JsonPatchOperationsArrayProvider(metadataProvider); var jsonPatchParameterDescription = new ApiParameterDescription { Type = typeof(JsonPatchDocument) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test.csproj index 4ace996ce7..6536b2ef40 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test.csproj @@ -1,15 +1,13 @@ - + $(StandardTestTfms) - + + - - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/MvcJsonOptionsExtensionsTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/MvcJsonOptionsExtensionsTests.cs new file mode 100644 index 0000000000..ab1c28fd3b --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/MvcJsonOptionsExtensionsTests.cs @@ -0,0 +1,275 @@ +// 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; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.Formatters.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Microsoft.Extensions.DependencyInjection +{ + public class MvcJsonOptionsExtensionsTests + { + [Fact] + public void UseCamelCasing_WillSet_CamelCasingStrategy_NameStrategy() + { + // Arrange + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = new DefaultContractResolver() + { + NamingStrategy = new DefaultNamingStrategy() + }; + var expected = typeof(CamelCaseNamingStrategy); + + // Act + options.UseCamelCasing(processDictionaryKeys: true); + var resolver = options.SerializerSettings.ContractResolver as DefaultContractResolver; + var actual = resolver.NamingStrategy; + + // Assert + Assert.IsType(expected, actual); + } + + [Fact] + public void UseCamelCasing_WillNot_OverrideSpecifiedNames() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + + var annotatedFoo = new AnnotatedFoo() + { + HelloWorld = "Hello" + }; + var expected = "{\"HELLO-WORLD\":\"Hello\"}"; + + // Act + var actual = SerializeToJson(options, value: annotatedFoo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_WillChange_PropertyNames() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var foo = new { TestName = "TestFoo", TestValue = 10 }; + var expected = "{\"testName\":\"TestFoo\",\"testValue\":10}"; + + // Act + var actual = SerializeToJson(options, value: foo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_WillChangeFirstPartBeforeSeparator_InPropertyName() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var foo = new { TestFoo_TestValue = "Test" }; + var expected = "{\"testFoo_TestValue\":\"Test\"}"; + + // Act + var actual = SerializeToJson(options, value: foo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_ProcessDictionaryKeys_WillChange_DictionaryKeys_IfTrue() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var dictionary = new Dictionary + { + ["HelloWorld"] = 1, + ["HELLOWORLD"] = 2 + }; + var expected = "{\"helloWorld\":1,\"helloworld\":2}"; + + // Act + var actual = SerializeToJson(options, value: dictionary); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_ProcessDictionaryKeys_WillChangeFirstPartBeforeSeparator_InDictionaryKey_IfTrue() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var dictionary = new Dictionary() + { + ["HelloWorld_HelloWorld"] = 1 + }; + + var expected = "{\"helloWorld_HelloWorld\":1}"; + + // Act + var actual = SerializeToJson(options, value: dictionary); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_ProcessDictionaryKeys_WillNotChangeDictionaryKeys_IfFalse() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseCamelCasing(processDictionaryKeys: false); + var dictionary = new Dictionary + { + ["HelloWorld"] = 1, + ["HELLO-WORLD"] = 2 + }; + var expected = "{\"HelloWorld\":1,\"HELLO-WORLD\":2}"; + + // Act + var actual = SerializeToJson(options, value: dictionary); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseMemberCasing_WillNotChange_OverrideSpecifiedNames() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseMemberCasing(); + var annotatedFoo = new AnnotatedFoo() + { + HelloWorld = "Hello" + }; + var expected = "{\"HELLO-WORLD\":\"Hello\"}"; + + // Act + var actual = SerializeToJson(options, value: annotatedFoo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseMemberCasing_WillSet_DefaultNamingStrategy_AsNamingStrategy() + { + // Arrange + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + }; + var expected = typeof(DefaultNamingStrategy); + + // Act + options.UseMemberCasing(); + var resolver = options.SerializerSettings.ContractResolver as DefaultContractResolver; + var actual = resolver.NamingStrategy; + + // Assert + Assert.IsType(expected, actual); + } + + [Fact] + public void UseMemberCasing_WillNotChange_PropertyNames() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseMemberCasing(); + var foo = new { fooName = "Test", FooValue = "Value" }; + var expected = "{\"fooName\":\"Test\",\"FooValue\":\"Value\"}"; + + // Act + var actual = SerializeToJson(options, value: foo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseMemberCasing_WillNotChange_DictionaryKeys() + { + // Arrange + var options = CreateDefaultMvcJsonOptions().UseMemberCasing(); + var dictionary = new Dictionary() + { + ["HelloWorld"] = 1, + ["helloWorld"] = 2, + ["HELLO-WORLD"] = 3 + }; + var expected = "{\"HelloWorld\":1,\"helloWorld\":2,\"HELLO-WORLD\":3}"; + + // Act + var actual = SerializeToJson(options, value: dictionary); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_WillThrow_IfContractResolver_IsNot_DefaultContractResolver() + { + // Arrange + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = new FooContractResolver(); + var expectedMessage = Resources.FormatInvalidContractResolverForJsonCasingConfiguration(nameof(FooContractResolver), nameof(DefaultContractResolver)); + + // Act & Assert + var exception = Assert.Throws( + () => options.UseCamelCasing(processDictionaryKeys: false)); + Assert.Equal(expectedMessage, actual: exception.Message); + } + + [Fact] + public void UseMemberCasing_WillThrow_IfContractResolver_IsNot_DefaultContractResolver() + { + // Arrange + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = new FooContractResolver(); + var expectedMessage = Resources.FormatInvalidContractResolverForJsonCasingConfiguration(nameof(FooContractResolver), nameof(DefaultContractResolver)); + + // Act & Assert + var exception = Assert.Throws( + () => options.UseMemberCasing()); + Assert.Equal(expectedMessage, actual: exception.Message); + } + + // NOTE: This method was created to make sure to create a different instance of contract resolver as by default + // MvcJsonOptions uses a static shared instance of resolver which when changed causes other tests to fail. + private MvcJsonOptions CreateDefaultMvcJsonOptions() + { + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = JsonSerializerSettingsProvider.CreateContractResolver(); + return options; + } + + private static string SerializeToJson(MvcJsonOptions options, object value) + { + return JsonConvert.SerializeObject( + value: value, + formatting: Formatting.None, + settings: options.SerializerSettings); + } + + private class AnnotatedFoo + { + [JsonProperty("HELLO-WORLD")] + public string HelloWorld { get; set; } + } + + private class FooContractResolver : IContractResolver + { + public JsonContract ResolveContract(Type type) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/EnumerableWrapperProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/EnumerableWrapperProviderTest.cs index 59ac66a273..b89802115d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/EnumerableWrapperProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/EnumerableWrapperProviderTest.cs @@ -78,7 +78,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal [InlineData(typeof(List))] [InlineData(typeof(List))] [InlineData(typeof(PersonList))] - public void ThrowsArugmentExceptionFor_ConcreteEnumerableOfT(Type declaredType) + public void ThrowsArgumentExceptionFor_ConcreteEnumerableOfT(Type declaredType) { // Arrange var expectedMessage = "The type must be an interface and must be or derive from 'IEnumerable`1'."; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlDataContractSerializerMvcOptionsSetupTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlDataContractSerializerMvcOptionsSetupTest.cs deleted file mode 100644 index 5219f0b97a..0000000000 --- a/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlSerializerMvcOptionsSetupTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/MvcXmlSerializerMvcOptionsSetupTest.cs deleted file mode 100644 index 0ff2072a4c..0000000000 --- a/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/SerializableErrorWrapperTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/SerializableErrorWrapperTests.cs index 2a6cfaf47d..ec0175b8df 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/SerializableErrorWrapperTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/SerializableErrorWrapperTests.cs @@ -6,7 +6,6 @@ using System.Runtime.Serialization; using System.Text; using System.Xml; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Testing; using Xunit; namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal @@ -28,8 +27,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal public void WrappedSerializableErrorInstance_ReturnedFromProperty() { // Arrange - var serializableError = new SerializableError(); - serializableError.Add("key1", "key1-error"); + var serializableError = new SerializableError + { + { "key1", "key1-error" } + }; // Act var wrapper = new SerializableErrorWrapper(serializableError); @@ -57,7 +58,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal { // Arrange var serializableErrorXml = "" + - "Test Error 1 Test Error 2Test Error 3"; + "Test error 0" + + "Test Error 1 Test Error 2" + + "Test Error 3" + + "Test Error 4"; var serializer = new DataContractSerializer(typeof(SerializableErrorWrapper)); // Act @@ -66,8 +70,28 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal var errors = wrapper.SerializableError; // Assert - Assert.Equal("Test Error 1 Test Error 2", errors["key1"]); - Assert.Equal("Test Error 3", errors["key2"]); + Assert.Collection( + errors, + kvp => + { + Assert.Equal(string.Empty, kvp.Key); + Assert.Equal("Test error 0", kvp.Value); + }, + kvp => + { + Assert.Equal("key1", kvp.Key); + Assert.Equal("Test Error 1 Test Error 2", kvp.Value); + }, + kvp => + { + Assert.Equal("key2", kvp.Key); + Assert.Equal("Test Error 3", kvp.Value); + }, + kvp => + { + Assert.Equal("list[3].key3", kvp.Key); + Assert.Equal("Test Error 4", kvp.Value); + }); } [Fact] @@ -75,11 +99,18 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal { // Arrange var modelState = new ModelStateDictionary(); + modelState.AddModelError(string.Empty, "Test error 0"); modelState.AddModelError("key1", "Test Error 1"); modelState.AddModelError("key1", "Test Error 2"); modelState.AddModelError("key2", "Test Error 3"); + modelState.AddModelError("list[3].key3", "Test Error 4"); var serializableError = new SerializableError(modelState); var outputStream = new MemoryStream(); + var expectedContent = "" + + "Test error 0" + + "Test Error 1 Test Error 2" + + "Test Error 3" + + "Test Error 4"; // Act using (var xmlWriter = XmlWriter.Create(outputStream)) @@ -91,15 +122,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal var res = new StreamReader(outputStream, Encoding.UTF8).ReadToEnd(); // Assert - var expectedContent = - TestPlatformHelper.IsMono ? - "Test Error 1 Test Error 2" + - "Test Error 3" : - "" + - "Test Error 1 Test Error 2Test Error 3"; - Assert.Equal(expectedContent, res); } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/SerializableWrapperProviderFactoryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/SerializableWrapperProviderFactoryTest.cs index e300f1d434..e09a15b43c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/SerializableWrapperProviderFactoryTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Internal/SerializableWrapperProviderFactoryTest.cs @@ -13,10 +13,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal public void Creates_WrapperProvider_ForSerializableErrorType(bool isSerialization) { // Arrange - var serializableErroWrapperProviderFactory = new SerializableErrorWrapperProviderFactory(); + var serializableErrorWrapperProviderFactory = new SerializableErrorWrapperProviderFactory(); // Act - var wrapperProvider = serializableErroWrapperProviderFactory.GetProvider( + var wrapperProvider = serializableErrorWrapperProviderFactory.GetProvider( new WrapperProviderContext(typeof(SerializableError), isSerialization)); // Assert @@ -28,10 +28,10 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal public void ReturnsNullFor_NonSerializableErrorTypes() { // Arrange - var serializableErroWrapperProviderFactory = new SerializableErrorWrapperProviderFactory(); + var serializableErrorWrapperProviderFactory = new SerializableErrorWrapperProviderFactory(); // Act - var wrapperProvider = serializableErroWrapperProviderFactory.GetProvider( + var wrapperProvider = serializableErrorWrapperProviderFactory.GetProvider( new WrapperProviderContext(typeof(Person), isSerialization: true)); // Assert diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test.csproj index a02cf7ca47..14f4f05b2f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test.csproj @@ -1,13 +1,12 @@ - + $(StandardTestTfms) - + - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetails21WrapperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetails21WrapperTest.cs new file mode 100644 index 0000000000..933da23704 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperProviderFactoryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperProviderFactoryTest.cs new file mode 100644 index 0000000000..21a62006b5 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs new file mode 100644 index 0000000000..055679f266 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs @@ -0,0 +1,99 @@ +// 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 +{ + public class ProblemDetailsWrapperTest + { + [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(ProblemDetailsWrapper)); + + // 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 ProblemDetailsWrapper(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); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetails21WrapperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetails21WrapperTest.cs new file mode 100644 index 0000000000..49b41afaf4 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs new file mode 100644 index 0000000000..53bae5afaf --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs @@ -0,0 +1,226 @@ +// 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 +{ + public class ValidationProblemDetailsWrapperTest + { + [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(ValidationProblemDetailsWrapper)); + + // 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(ValidationProblemDetailsWrapper)); + + // 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(ValidationProblemDetailsWrapper)); + + // 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 ValidationProblemDetailsWrapper(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 ValidationProblemDetailsWrapper(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); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs index 5edf2998e6..fdd09e318d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs @@ -11,7 +11,6 @@ using System.Xml; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Moq; using Xunit; @@ -111,7 +110,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml } [Fact] - public void HasProperSuppportedMediaTypes() + public void HasProperSupportedMediaTypes() { // Arrange & Act var formatter = new XmlDataContractSerializerInputFormatter(new MvcOptions()); @@ -124,7 +123,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml } [Fact] - public void HasProperSuppportedEncodings() + public void HasProperSupportedEncodings() { // Arrange & Act var formatter = new XmlDataContractSerializerInputFormatter(new MvcOptions()); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerMvcOptionsSetupTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerMvcOptionsSetupTest.cs new file mode 100644 index 0000000000..08c2b0aaf2 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs index 84360e3c94..c1bf95ba46 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs @@ -12,7 +12,6 @@ using System.Xml.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Testing; using Microsoft.AspNetCore.Testing.xunit; using Moq; @@ -350,7 +349,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml } [Fact] - public void HasProperSuppportedMediaTypes() + public void HasProperSupportedMediaTypes() { // Arrange & Act var formatter = new XmlSerializerInputFormatter(new MvcOptions()); @@ -363,7 +362,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml } [Fact] - public void HasProperSuppportedEncodings() + public void HasProperSupportedEncodings() { // Arrange & Act var formatter = new XmlSerializerInputFormatter(new MvcOptions()); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerMvcOptionsSetupTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerMvcOptionsSetupTest.cs new file mode 100644 index 0000000000..d3b8790e64 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTestHelper.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTestHelper.cs index d7310e628b..dd3c01c170 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTestHelper.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTestHelper.cs @@ -1,38 +1,23 @@ // 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.Net.Http; -using System.Xml.Linq; +using AngleSharp.Parser.Html; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { public static class AntiforgeryTestHelper { + public static string RetrieveAntiforgeryToken(string htmlContent) + => RetrieveAntiforgeryToken(htmlContent, actionUrl: string.Empty); + public static string RetrieveAntiforgeryToken(string htmlContent, string actionUrl) { - htmlContent = "" + htmlContent + ""; - var reader = new StringReader(htmlContent); - var htmlDocument = XDocument.Load(reader); + var parser = new HtmlParser(); + var htmlDocument = parser.Parse(htmlContent); - foreach (var form in htmlDocument.Descendants("form")) - { - foreach (var input in form.Descendants("input")) - { - if (input.Attribute("name") != null && - input.Attribute("type") != null && - input.Attribute("type").Value == "hidden" && - (input.Attribute("name").Value == "__RequestVerificationToken" || - input.Attribute("name").Value == "HtmlEncode[[__RequestVerificationToken]]")) - { - return input.Attributes("value").First().Value; - } - } - } - - throw new Exception($"Antiforgery token could not be located in {htmlContent}."); + return htmlDocument.RetrieveAntiforgeryToken(); } public static CookieMetadata RetrieveAntiforgeryCookie(HttpResponseMessage response) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTests.cs index 78fc00bec6..746f949205 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTests.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; -using Microsoft.AspNetCore.Antiforgery; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -175,5 +174,32 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var pragmaValue = Assert.Single(response.Headers.Pragma.ToArray()); Assert.Equal("no-cache", pragmaValue.Name); } + + [Fact] + public async Task RequestWithoutAntiforgeryToken_SendsBadRequest() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Antiforgery/Login"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task RequestWithoutAntiforgeryToken_ExecutesResultFilter() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Antiforgery/LoginWithRedirectResultFilter"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("http://example.com/antiforgery-redirect", response.Headers.Location.AbsoluteUri); + } } } \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs index b27a549321..521db7b904 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.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.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; +using System.Text; using System.Threading.Tasks; using BasicWebSite.Models; +using Microsoft.AspNetCore.Hosting; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -17,45 +21,99 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public ApiBehaviorTest(MvcTestFixture fixture) { Client = fixture.CreateDefaultClient(); + + var factory = fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + CustomInvalidModelStateClient = factory.CreateDefaultClient(); } + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + public HttpClient Client { get; } + public HttpClient CustomInvalidModelStateClient { get; } [Fact] public async Task ActionsReturnBadRequest_WhenModelStateIsInvalid() { // Arrange - var contactModel = new Contact + using (new ActivityReplacer()) { - Name = "Abc", - City = "Redmond", - State = "WA", - Zip = "Invalid", + var contactModel = new Contact + { + Name = "Abc", + City = "Redmond", + State = "WA", + Zip = "Invalid", + }; + var contactString = JsonConvert.SerializeObject(contactModel); + + // Act + var response = await Client.PostAsJsonAsync("/contact", contactModel); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType.MediaType); + var problemDetails = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + Assert.Collection( + problemDetails.Errors.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("Name", kvp.Key); + var error = Assert.Single(kvp.Value); + Assert.Equal("The field Name must be a string with a minimum length of 5 and a maximum length of 30.", error); + }, + kvp => + { + Assert.Equal("Zip", kvp.Key); + var error = Assert.Single(kvp.Value); + Assert.Equal("The field Zip must match the regular expression '\\d{5}'.", error); + } + ); + + Assert.Collection( + problemDetails.Extensions, + kvp => + { + Assert.Equal("traceId", kvp.Key); + Assert.Equal(Activity.Current.Id, kvp.Value); + }); + } + } + + [Fact] + public async Task ActionsReturnUnsupportedMediaType_WhenMediaTypeIsNotSupported() + { + // Arrange + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/contact") + { + Content = new StringContent("some content", Encoding.UTF8, "text/css"), }; - var contactString = JsonConvert.SerializeObject(contactModel); // Act - var response = await Client.PostAsJsonAsync("/contact", contactModel); + var response = await Client.SendAsync(requestMessage); // Assert - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); - var actual = JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); - Assert.Collection( - actual.OrderBy(kvp => kvp.Key), - kvp => - { - Assert.Equal("Name", kvp.Key); - var error = Assert.Single(kvp.Value); - Assert.Equal("The field Name must be a string with a minimum length of 5 and a maximum length of 30.", error); - }, - kvp => - { - Assert.Equal("Zip", kvp.Key); - var error = Assert.Single(kvp.Value); - Assert.Equal("The field Zip must match the regular expression '\\d{5}'.", error); - } - ); + await response.AssertStatusCodeAsync(HttpStatusCode.UnsupportedMediaType); + } + + [Fact] + public async Task ActionsReturnUnsupportedMediaType_WhenEncodingIsUnsupported() + { + // Arrange + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/contact") + { + Content = new StringContent("some content", Encoding.UTF7, "application/json"), + }; + + // Act + var response = await Client.SendAsync(requestMessage); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.UnsupportedMediaType); + var content = await response.Content.ReadAsStringAsync(); + var problemDetails = JsonConvert.DeserializeObject(content); + Assert.Equal((int)HttpStatusCode.UnsupportedMediaType, problemDetails.Status); + Assert.Equal("Unsupported Media Type", problemDetails.Title); } [Fact] @@ -71,16 +129,16 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests }; var expected = new Dictionary { - {"Name", new string[] {"The field Name must be a string with a minimum length of 5 and a maximum length of 30."}}, - {"Zip", new string[]{ @"The field Zip must match the regular expression '\d{5}'."}} + {"Name", new[] {"The field Name must be a string with a minimum length of 5 and a maximum length of 30."}}, + {"Zip", new[] { @"The field Zip must match the regular expression '\d{5}'."}} }; var contactString = JsonConvert.SerializeObject(contactModel); // Act - var response = await Client.PostAsJsonAsync("/contact/PostWithVnd", contactModel); + var response = await CustomInvalidModelStateClient.PostAsJsonAsync("/contact/PostWithVnd", contactModel); // Assert - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); Assert.Equal("application/vnd.error+json", response.Content.Headers.ContentType.MediaType); var content = await response.Content.ReadAsStringAsync(); var actual = JsonConvert.DeserializeObject>(content); @@ -169,9 +227,9 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadAsAsync(); - Assert.Equal(0, result.ContactId); - Assert.Null(result.Name); - Assert.Null(result.Email); + Assert.Equal(id, result.ContactId); + Assert.Equal(name, result.Name); + Assert.Equal(email, result.Email); } [Fact] @@ -203,5 +261,92 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var result = await response.Content.ReadAsStringAsync(); Assert.Equal(expected, result); } + + [Fact] + public async Task ClientErrorResultFilterExecutesForStatusCodeResults() + { + using (new ActivityReplacer()) + { + // Act + var response = await Client.GetAsync("/contact/ActionReturningStatusCodeResult"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NotFound); + var content = await response.Content.ReadAsStringAsync(); + var problemDetails = JsonConvert.DeserializeObject(content); + Assert.Equal(404, problemDetails.Status); + Assert.Collection( + problemDetails.Extensions, + kvp => + { + Assert.Equal("traceId", kvp.Key); + Assert.Equal(Activity.Current.Id, kvp.Value); + }); + } + } + + [Fact] + public async Task SerializingProblemDetails_IgnoresNullValuedProperties() + { + // Arrange + var expected = new[] { "status", "title", "traceId", "type" }; + + // Act + var response = await Client.GetAsync("/contact/ActionReturningStatusCodeResult"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NotFound); + var content = await response.Content.ReadAsStringAsync(); + + // Verify that null-valued properties on ProblemDetails are not serialized. + var json = JObject.Parse(content); + Assert.Equal(expected, json.Properties().OrderBy(p => p.Name).Select(p => p.Name)); + } + + [Fact] + public async Task SerializingProblemDetails_WithAllValuesSpecified() + { + // Arrange + var expected = new[] { "detail", "instance", "status", "title", "tracking-id", "type" }; + + // Act + var response = await Client.GetAsync("/contact/ActionReturningProblemDetails"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NotFound); + var content = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(content); + Assert.Equal(expected, json.Properties().OrderBy(p => p.Name).Select(p => p.Name)); + } + + [Fact] + public async Task SerializingValidationProblemDetails_WithExtensionData() + { + // Act + var response = await Client.GetAsync("/contact/ActionReturningValidationProblemDetails"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var content = await response.Content.ReadAsStringAsync(); + var validationProblemDetails = JsonConvert.DeserializeObject(content); + + Assert.Equal("Error", validationProblemDetails.Title); + Assert.Equal(400, validationProblemDetails.Status); + Assert.Collection( + validationProblemDetails.Extensions, + kvp => + { + Assert.Equal("tracking-id", kvp.Key); + Assert.Equal("27", kvp.Value); + }); + + Assert.Collection( + validationProblemDetails.Errors, + kvp => + { + Assert.Equal("Error1", kvp.Key); + Assert.Equal(new[] { "Error Message" }, kvp.Value); + }); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs index 774e0de862..e333821046 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -1,11 +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; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; +using ApiExplorerWebSite; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Testing.xunit; @@ -27,7 +30,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task ApiExplorer_IsVisible_EnabledWithConvention() { // Arrange & Act - var response = await Client.GetAsync("http://localhost/ApiExplorerVisbilityEnabledByConvention"); + var response = await Client.GetAsync("http://localhost/ApiExplorerVisibilityEnabledByConvention"); var body = await response.Content.ReadAsStringAsync(); var result = JsonConvert.DeserializeObject>(body); @@ -40,7 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task ApiExplorer_IsVisible_DisabledWithConvention() { // Arrange & Act - var response = await Client.GetAsync("http://localhost/ApiExplorerVisbilityDisabledByConvention"); + var response = await Client.GetAsync("http://localhost/ApiExplorerVisibilityDisabledByConvention"); var body = await response.Content.ReadAsStringAsync(); var result = JsonConvert.DeserializeObject>(body); @@ -709,7 +712,6 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert var description = Assert.Single(result); - Assert.Equal(2, description.SupportedResponseTypes.Count); Assert.Collection( description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), @@ -747,7 +749,6 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert var description = Assert.Single(result); - Assert.Equal(2, description.SupportedResponseTypes.Count); Assert.Collection( description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), @@ -829,17 +830,27 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert var description = Assert.Single(result); var responseType = Assert.Single(description.SupportedResponseTypes); - Assert.Equal(1, responseType.ResponseFormats.Count); - - var responseFormat = responseType.ResponseFormats[0]; - Assert.Equal("application/hal+json", responseFormat.MediaType); - Assert.Equal(typeof(JsonOutputFormatter).FullName, responseFormat.FormatterType); + Assert.Collection( + responseType.ResponseFormats, + responseFormat => + { + Assert.Equal("application/hal+custom", responseFormat.MediaType); + Assert.Null(responseFormat.FormatterType); + }, + responseFormat => + { + Assert.Equal("application/hal+json", responseFormat.MediaType); + Assert.Equal(typeof(JsonOutputFormatter).FullName, responseFormat.FormatterType); + }); } [Fact] public async Task ApiExplorer_ResponseContentType_NoMatch() { - // Arrange & Act + // Arrange + var expectedMediaTypes = new[] { "application/custom", "text/hal+bson" }; + + // Act var response = await Client.GetAsync("http://localhost/ApiExplorerResponseContentType/NoMatch"); var body = await response.Content.ReadAsStringAsync(); @@ -848,7 +859,11 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert var description = Assert.Single(result); var responseType = Assert.Single(description.SupportedResponseTypes); - Assert.Empty(responseType.ResponseFormats); + + + Assert.Equal(typeof(Product).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); } [ConditionalTheory] @@ -1018,6 +1033,75 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(typeof(string).FullName, feedback.Type); } + [Fact] + public async Task ApiExplorer_Parameters_DefaultValue() + { + // Arrange & Act + var response = await Client.GetAsync("ApiExplorerParameters/DefaultValueParameters"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var parameters = description.ParameterDescriptions; + + Assert.Collection( + parameters, + parameter => + { + Assert.Equal("searchTerm", parameter.Name); + Assert.Null(parameter.DefaultValue); + }, + parameter => + { + Assert.Equal("top", parameter.Name); + Assert.Equal("10", parameter.DefaultValue); + }, + parameter => + { + Assert.Equal("searchDay", parameter.Name); + Assert.Equal(nameof(DayOfWeek.Wednesday), parameter.DefaultValue); + }); + } + + [Fact] + public async Task ApiExplorer_Parameters_IsRequired() + { + // Arrange & Act + var response = await Client.GetAsync("ApiExplorerParameters/IsRequiredParameters"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var parameters = description.ParameterDescriptions; + + Assert.Collection( + parameters, + parameter => + { + Assert.Equal("requiredParam", parameter.Name); + Assert.True(parameter.IsRequired); + }, + parameter => + { + Assert.Equal("notRequiredParam", parameter.Name); + Assert.False(parameter.IsRequired); + }, + parameter => + { + Assert.Equal("Id", parameter.Name); + Assert.True(parameter.IsRequired); + }, + parameter => + { + Assert.Equal("Name", parameter.Name); + Assert.False(parameter.IsRequired); + }); + } + [Fact] public async Task ApiExplorer_Updates_WhenActionDescriptorCollectionIsUpdated() { @@ -1078,6 +1162,305 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("multipart/form-data", requestFormat.MediaType); } + [Fact] + public async Task ApiBehavior_UsesContentTypeFromProducesAttribute_WhenNoFormatterSupportsIt() + { + // Arrange + var expectedMediaTypes = new[] { "application/pdf" }; + + // Act + var body = await Client.GetStringAsync("ApiExplorerApiController/ProducesWithUnsupportedContentType"); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(Stream).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public Task ApiConvention_ForGetMethod_ReturningModel() => ApiConvention_ForGetMethod("GetProduct"); + + [Fact] + public Task ApiConvention_ForGetMethod_ReturningTaskOfActionResultOfModel() => ApiConvention_ForGetMethod("GetTaskOfActionResultOfProduct"); + + private async Task ApiConvention_ForGetMethod(string action) + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.GetStringAsync( + $"ApiExplorerResponseTypeWithApiConventionController/{action}"); + var result = JsonConvert.DeserializeObject>(response); + + // Assert + var description = Assert.Single(result); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => + { + Assert.Equal(typeof(Product).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public async Task ApiConvention_ForGetMethodThatDoesNotMatchConvention() + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.GetStringAsync( + $"ApiExplorerResponseTypeWithApiConventionController/GetProducts"); + var result = JsonConvert.DeserializeObject>(response); + + // Assert + var description = Assert.Single(result); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(IEnumerable).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + var actualMediaTypes = responseType.ResponseFormats.Select(r => r.MediaType).OrderBy(r => r); + Assert.Equal(expectedMediaTypes, actualMediaTypes); + }); + } + + [Fact] + public async Task ApiConvention_ForMethodWithResponseTypeAttributes() + { + // Arrange + var expectedMediaTypes = new[] { "application/json" }; + + // Act + var response = await Client.PostAsync( + $"ApiExplorerResponseTypeWithApiConventionController/PostWithConventions", + new StringContent(string.Empty)); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(202, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(403, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public async Task ApiConvention_ForPostMethodThatMatchesConvention() + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.PostAsync( + $"ApiExplorerResponseTypeWithApiConventionController/PostTaskOfProduct", + new StringContent(string.Empty)); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(201, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public async Task ApiConvention_ForPostActionWithProducesAttribute() + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "text/json", }; + + // Act + var response = await Client.PostAsync( + $"ApiExplorerResponseTypeWithApiConventionController/PostWithProduces", + new StringContent(string.Empty)); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(201, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public async Task ApiConvention_ForPutActionThatMatchesConvention() + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.PutAsync( + $"ApiExplorerResponseTypeWithApiConventionController/Put", + new StringContent(string.Empty)); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(204, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public async Task ApiConvention_ForDeleteActionThatMatchesConvention() + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.DeleteAsync( + $"ApiExplorerResponseTypeWithApiConventionController/DeleteProductAsync"); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public async Task ApiConvention_ForActionWithApiConventionMethod() + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.PostAsync( + "ApiExplorerResponseTypeWithApiConventionController/PostItem", + new StringContent(string.Empty)); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(302, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType); + Assert.Equal(409, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + private IEnumerable GetSortedMediaTypes(ApiExplorerResponseType apiResponseType) { return apiResponseType.ResponseFormats @@ -1111,6 +1494,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public string Source { get; set; } public string Type { get; set; } + + public string DefaultValue { get; set; } + + public bool IsRequired { get; set; } } // Used to serialize data between client and server diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicApiTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicApiTest.cs new file mode 100644 index 0000000000..289eb16f92 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicApiTest.cs @@ -0,0 +1,231 @@ +// 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; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing.xunit; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class BasicApiTest : IClassFixture + { + private static readonly byte[] PetBytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) + .GetBytes(@"{ + ""category"" : { + ""name"" : ""Cats"" + }, + ""images"": [ + { + ""url"": ""http://example.com/images/fluffy1.png"" + }, + { + ""url"": ""http://example.com/images/fluffy2.png"" + }, + ], + ""tags"": [ + { + ""name"": ""orange"" + }, + { + ""name"": ""kitty"" + } + ], + ""age"": 2, + ""hasVaccinations"": ""true"", + ""name"" : ""fluffy"", + ""status"" : ""available"" +}"); + + public BasicApiTest(BasicApiFixture fixture) + { + Client = fixture.CreateClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task Token_WithUnknownUser_ReturnsForbidden() + { + // Arrange & Act + var response = await Client.GetAsync("/token?username=fallguy@example.com"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + // Tests are conditional to avoid occasional CI failures on Windows 8 and Windows 2012 under full framework. + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "See aspnet/Identity#1630")] + public async Task Token_WithKnownUser_ReturnsOkAndToken() + { + // Arrange & Act + var response = await Client.GetAsync("/token?username=reader@example.com"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType.MediaType); + + var token = await response.Content.ReadAsStringAsync(); + Assert.NotNull(token); + Assert.NotEmpty(token); + } + + [Fact] + public async Task FindByStatus_WithNoToken_ReturnsUnauthorized() + { + // Arrange & Act + var response = await Client.GetAsync("/pet/findByStatus?status=available"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [ConditionalTheory] + [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "See aspnet/Identity#1630")] + [InlineData("reader@example.com")] + [InlineData("writer@example.com")] + public async Task FindByStatus_WithToken_ReturnsOkAndPet(string username) + { + // Arrange & Act 1 + var token = await Client.GetStringAsync($"/token?username={username}"); + + // Assert 1 (guard) + Assert.NotEmpty(token); + + // Arrange 2 + var request = new HttpRequestMessage(HttpMethod.Get, "/pet/findByStatus?status=available"); + request.Headers.Add(HeaderNames.Authorization, $"Bearer {token}"); + + // Act 2 + var response = await Client.SendAsync(request); + + // Assert 2 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); + + var json = await response.Content.ReadAsStringAsync(); + Assert.NotNull(json); + Assert.NotEmpty(json); + } + + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "See aspnet/Identity#1630")] + public async Task FindById_WithInvalidPetId_ReturnsNotFound() + { + // Arrange & Act 1 + var token = await Client.GetStringAsync("/token?username=reader@example.com"); + + // Assert 1 (guard) + Assert.NotEmpty(token); + + // Arrange 2 + var request = new HttpRequestMessage(HttpMethod.Get, "/pet/100"); + request.Headers.Add(HeaderNames.Authorization, $"Bearer {token}"); + + // Act 2 + var response = await Client.SendAsync(request); + + // Assert 2 + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "See aspnet/Identity#1630")] + public async Task FindById_WithValidPetId_ReturnsOkAndPet() + { + // Arrange & Act 1 + var token = await Client.GetStringAsync("/token?username=reader@example.com"); + + // Assert 1 (guard) + Assert.NotEmpty(token); + + // Arrange 2 + var request = new HttpRequestMessage(HttpMethod.Get, "/pet/-1"); + request.Headers.Add(HeaderNames.Authorization, $"Bearer {token}"); + + // Act 2 + var response = await Client.SendAsync(request); + + // Assert 2 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); + + var json = await response.Content.ReadAsStringAsync(); + Assert.NotNull(json); + Assert.NotEmpty(json); + } + + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "See aspnet/Identity#1630")] + public async Task AddPet_WithInsufficientClaims_ReturnsForbidden() + { + // Arrange & Act 1 + var token = await Client.GetStringAsync("/token?username=reader@example.com"); + + // Assert 1 (guard) + Assert.NotEmpty(token); + + // Arrange 2 + var request = new HttpRequestMessage(HttpMethod.Post, "/pet") + { + Content = new ByteArrayContent(PetBytes) + { + Headers = + { + { "Content-Type", "application/json" }, + }, + }, + Headers = + { + { HeaderNames.Authorization, $"Bearer {token}" }, + }, + }; + + // Act 2 + var response = await Client.SendAsync(request); + + // Assert 2 + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "See aspnet/Identity#1630")] + public async Task AddPet_WithValidClaims_ReturnsCreated() + { + // Arrange & Act 1 + var token = await Client.GetStringAsync("/token?username=writer@example.com"); + + // Assert 1 (guard) + Assert.NotEmpty(token); + + // Arrange 2 + var request = new HttpRequestMessage(HttpMethod.Post, "/pet") + { + Content = new ByteArrayContent(PetBytes) + { + Headers = + { + { HeaderNames.ContentType, "application/json" }, + }, + }, + Headers = + { + { HeaderNames.Authorization, $"Bearer {token}" }, + }, + }; + + // Act 2 + var response = await Client.SendAsync(request); + + // Assert 2 + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var location = response.Headers.Location.ToString(); + Assert.NotNull(location); + Assert.EndsWith("/1", location); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs index 6761579f04..ffaebb4fe0 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicTests.cs @@ -382,7 +382,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var expected = "ConventionalRoute - Hello from mypage"; // Act - var response = await Client.GetStringAsync("/PageRoute/ConventionalRoute/mypage"); + var response = await Client.GetStringAsync("/PageRoute/ConventionalRouteView/mypage"); // Assert Assert.Equal(expected, response.Trim()); @@ -395,7 +395,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var expected = "AttributeRoute - Hello from test-page"; // Act - var response = await Client.GetStringAsync("/PageRoute/Attribute/test-page"); + var response = await Client.GetStringAsync("/PageRoute/AttributeView/test-page"); // Assert Assert.Equal(expected, response.Trim()); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicViewsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicViewsTest.cs new file mode 100644 index 0000000000..5f9b0bbcd8 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/BasicViewsTest.cs @@ -0,0 +1,83 @@ +// 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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class BasicViewsTest : IClassFixture + { + public BasicViewsTest(BasicViewsFixture fixture) + { + Client = fixture.CreateClient(); + } + + public HttpClient Client { get; } + + [Theory] + [InlineData("/")] + [InlineData("/Home/HtmlHelpers")] + public async Task Get_ReturnsOkAndAntiforgeryToken(string path) + { + // Arrange & Act + var response = await Client.GetAsync(path); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType.MediaType); + + var html = await response.Content.ReadAsStringAsync(); + Assert.NotNull(html); + Assert.NotEmpty(html); + + var token = AntiforgeryTestHelper.RetrieveAntiforgeryToken(html, "/"); + Assert.NotNull(token); + Assert.NotEmpty(token); + } + + [Theory] + [InlineData("/")] + [InlineData("/Home/HtmlHelpers")] + public async Task Post_ReturnsOkAndNewPerson(string path) + { + // Arrange & Act 1 + var html = await Client.GetStringAsync(path); + + // Assert 1 (guard) + Assert.NotEmpty(html); + + // Arrange 2 + var token = AntiforgeryTestHelper.RetrieveAntiforgeryToken(html, "/"); + var name = Guid.NewGuid().ToString(); + name = name.Substring(startIndex: 0, length: name.LastIndexOf('-')); + var form = new Dictionary + { + { "__RequestVerificationToken", token }, + { "Age", "12" }, + { "BirthDate", "2006-03-01T09:51:43.041-07:00" }, + { "Name", name }, + }; + + var content = new FormUrlEncodedContent(form); + var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = content, + }; + + // Act 2 + var response = await Client.SendAsync(request); + + // Assert 2 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Contains($@"value=""{name}""", body); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeEndpointRoutingTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeEndpointRoutingTests.cs new file mode 100644 index 0000000000..7377442ac6 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeEndpointRoutingTests.cs @@ -0,0 +1,33 @@ +// 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; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class ConsumesAttributeEndpointRoutingTests : ConsumesAttributeTestsBase + { + public ConsumesAttributeEndpointRoutingTests(MvcTestFixture fixture) + : base(fixture) + { + } + + [Fact] + public async override Task HasEndpointMatch() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Routing/HasEndpointMatch"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.True(result); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs index 181094b715..d694aad1c4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs @@ -2,168 +2,32 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Net; -using System.Net.Http; -using System.Text; using System.Threading.Tasks; -using BasicWebSite.Models; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Testing.xunit; using Newtonsoft.Json; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { - public class ConsumesAttributeTests : IClassFixture> + public class ConsumesAttributeTests : ConsumesAttributeTestsBase { public ConsumesAttributeTests(MvcTestFixture fixture) + : base(fixture) { - Client = fixture.CreateDefaultClient(); } - public HttpClient Client { get; } - [Fact] - public async Task NoRequestContentType_SelectsActionWithoutConstraint() + public async override Task HasEndpointMatch() { - // Arrange - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_Company/CreateProduct"); + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Routing/HasEndpointMatch"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Act - var response = await Client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("CreateProduct_Product_Text", body); - } - - [Fact] - public async Task NoRequestContentType_Selects_IfASingleActionWithConstraintIsPresent() - { - // Arrange - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_PassThrough/CreateProduct"); - - // Act - var response = await Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("ConsumesAttribute_PassThrough_Product_Json", body); - } - - [Theory] - [InlineData("application/json")] - [InlineData("text/json")] - public async Task Selects_Action_BasedOnRequestContentType(string requestContentType) - { - // Arrange - var input = "{SampleString:\""+requestContentType+"\"}"; - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_AmbiguousActions/CreateProduct"); - request.Content = new StringContent(input, Encoding.UTF8, requestContentType); - - // Act - var response = await Client.SendAsync(request); - var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(requestContentType, product.SampleString); - } - - [Theory] - [InlineData("application/json")] - [InlineData("text/json")] - public async Task ActionLevelAttribute_OveridesClassLevel(string requestContentType) - { - // Arrange - var input = "{SampleString:\"" + requestContentType + "\"}"; - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_OverridesBase/CreateProduct"); - request.Content = new StringContent(input, Encoding.UTF8, requestContentType); - var expectedString = "ConsumesAttribute_OverridesBaseController_" + requestContentType; - - // Act - var response = await Client.SendAsync(request); - var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedString, product.SampleString); - } - - [ConditionalFact] - // Mono issue - https://github.com/aspnet/External/issues/18 - [FrameworkSkipCondition(RuntimeFrameworks.Mono)] - public async Task DerivedClassLevelAttribute_OveridesBaseClassLevel() - { - // Arrange - var input = "" + - "application/xml"; - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_Overrides/CreateProduct"); - request.Content = new StringContent(input, Encoding.UTF8, "application/xml"); - var expectedString = "ConsumesAttribute_OverridesController_application/xml"; - - // Act - var response = await Client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var product = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedString, product.SampleString); - } - - [Fact] - public async Task JsonSyntaxSuffix_SelectsActionConsumingJson() - { - // Arrange - var input = "{SampleString:\"some input\"}"; - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); - request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+json"); - - // Act - var response = await Client.SendAsync(request); - var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("Read from JSON: some input", product.SampleString); - } - - [ConditionalFact] - // Mono issue - https://github.com/aspnet/External/issues/18 - [FrameworkSkipCondition(RuntimeFrameworks.Mono)] - public async Task XmlSyntaxSuffix_SelectsActionConsumingXml() - { - // Arrange - var input = "" + - "some input"; - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); - request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+xml"); - - // Act - var response = await Client.SendAsync(request); - var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("Read from XML: some input", product.SampleString); + Assert.False(result); } } } \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTestsBase.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTestsBase.cs new file mode 100644 index 0000000000..9a978d2662 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTestsBase.cs @@ -0,0 +1,174 @@ +// 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 System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using BasicWebSite.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Testing.xunit; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public abstract class ConsumesAttributeTestsBase : IClassFixture> where TStartup : class + { + protected ConsumesAttributeTestsBase(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public abstract Task HasEndpointMatch(); + + [Fact] + public async Task NoRequestContentType_SelectsActionWithoutConstraint() + { + // Arrange + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_Company/CreateProduct"); + + // Act + var response = await Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("CreateProduct_Product_Text", body); + } + + [Fact] + public async Task NoRequestContentType_Selects_IfASingleActionWithConstraintIsPresent_ReturnsUnsupported() + { + // Arrange + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_PassThrough/CreateProduct"); + + // Act + var response = await Client.SendAsync(request); + await response.AssertStatusCodeAsync(HttpStatusCode.UnsupportedMediaType); + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task Selects_Action_BasedOnRequestContentType(string requestContentType) + { + // Arrange + var input = "{SampleString:\""+requestContentType+"\"}"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_AmbiguousActions/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, requestContentType); + + // Act + var response = await Client.SendAsync(request); + var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(requestContentType, product.SampleString); + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task ActionLevelAttribute_OverridesClassLevel(string requestContentType) + { + // Arrange + var input = "{SampleString:\"" + requestContentType + "\"}"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_OverridesBase/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, requestContentType); + var expectedString = "ConsumesAttribute_OverridesBaseController_" + requestContentType; + + // Act + var response = await Client.SendAsync(request); + var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedString, product.SampleString); + } + + [ConditionalFact] + // Mono issue - https://github.com/aspnet/External/issues/18 + [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + public async Task DerivedClassLevelAttribute_OverridesBaseClassLevel() + { + // Arrange + var input = "" + + "application/xml"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_Overrides/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, "application/xml"); + var expectedString = "ConsumesAttribute_OverridesController_application/xml"; + + // Act + var response = await Client.SendAsync(request); + var responseString = await response.Content.ReadAsStringAsync(); + var product = JsonConvert.DeserializeObject(responseString); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedString, product.SampleString); + } + + [Fact] + public async Task JsonSyntaxSuffix_SelectsActionConsumingJson() + { + // Arrange + var input = "{SampleString:\"some input\"}"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+json"); + + // Act + var response = await Client.SendAsync(request); + var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Read from JSON: some input", product.SampleString); + } + + [ConditionalFact] + // Mono issue - https://github.com/aspnet/External/issues/18 + [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + public async Task XmlSyntaxSuffix_SelectsActionConsumingXml() + { + // Arrange + var input = "" + + "some input"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+xml"); + + // Act + var response = await Client.SendAsync(request); + var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Read from XML: some input", product.SampleString); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsEndpointRoutingTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsEndpointRoutingTests.cs new file mode 100644 index 0000000000..82867e6ed5 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsEndpointRoutingTests.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.AspNetCore.Mvc.FunctionalTests +{ + public class CorsEndpointRoutingTests : CorsTestsBase + { + public CorsEndpointRoutingTests(MvcTestFixture fixture) + : base(fixture) + { + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTests.cs index 5d92c7e6eb..512fc5ea90 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTests.cs @@ -1,7 +1,6 @@ // 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 System.Net; using System.Net.Http; using System.Threading.Tasks; @@ -10,75 +9,15 @@ using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { - public class CorsTests : IClassFixture> + public class CorsTests : CorsTestsBase { - public CorsTests(MvcTestFixture fixture) + public CorsTests(MvcTestFixture fixture) + : base(fixture) { - Client = fixture.CreateDefaultClient(); - } - - public HttpClient Client { get; } - - [Theory] - [InlineData("GET")] - [InlineData("HEAD")] - [InlineData("POST")] - public async Task ResourceWithSimpleRequestPolicy_Allows_SimpleRequests(string method) - { - // Arrange - var origin = "http://example.com"; - var request = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Cors/GetBlogComments"); - request.Headers.Add(CorsConstants.Origin, origin); - - // Act - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[\"comment1\",\"comment2\",\"comment3\"]", content); - var responseHeaders = response.Headers; - var header = Assert.Single(response.Headers); - Assert.Equal(CorsConstants.AccessControlAllowOrigin, header.Key); - Assert.Equal(new[] { "*" }, header.Value.ToArray()); } [Fact] - public async Task OptionsRequest_NonPreflight_ExecutesOptionsAction() - { - // Arrange - var request = new HttpRequestMessage(new HttpMethod("OPTIONS"), "http://localhost/NonCors/GetOptions"); - - // Act - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[\"Create\",\"Update\",\"Delete\"]", content); - Assert.Empty(response.Headers); - } - - [Fact] - public async Task PreflightRequestOnNonCorsEnabledController_ExecutesOptionsAction() - { - // Arrange - var request = new HttpRequestMessage(new HttpMethod("OPTIONS"), "http://localhost/NonCors/GetOptions"); - request.Headers.Add(CorsConstants.Origin, "http://example.com"); - request.Headers.Add(CorsConstants.AccessControlRequestMethod, "POST"); - - // Act - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[\"Create\",\"Update\",\"Delete\"]", content); - Assert.Empty(response.Headers); - } - - [Fact] - public async Task PreflightRequestOnNonCorsEnabledController_DoesNotMatchTheAction() + public override async Task PreflightRequestOnNonCorsEnabledController_DoesNotMatchTheAction() { // Arrange var request = new HttpRequestMessage(new HttpMethod("OPTIONS"), "http://localhost/NonCors/Post"); @@ -91,264 +30,5 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - - [Theory] - [InlineData("GET")] - [InlineData("HEAD")] - [InlineData("POST")] - [InlineData("PUT")] - public async Task PolicyFailed_Disallows_PreFlightRequest(string method) - { - // Arrange - var request = new HttpRequestMessage( - new HttpMethod(CorsConstants.PreflightHttpMethod), - "http://localhost/Cors/GetBlogComments"); - - // Adding a custom header makes it a non-simple request. - request.Headers.Add(CorsConstants.Origin, "http://example.com"); - request.Headers.Add(CorsConstants.AccessControlRequestMethod, method); - request.Headers.Add(CorsConstants.AccessControlRequestHeaders, "Custom"); - - // Act - var response = await Client.SendAsync(request); - - // 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); - - // It should short circuit and hence no result. - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal(string.Empty, content); - } - - [Fact] - public async Task SuccessfulCorsRequest_AllowsCredentials_IfThePolicyAllowsCredentials() - { - // Arrange - var request = new HttpRequestMessage( - HttpMethod.Put, - "http://localhost/Cors/EditUserComment?userComment=abcd"); - - // Adding a custom header makes it a non-simple request. - request.Headers.Add(CorsConstants.Origin, "http://example.com"); - request.Headers.Add(CorsConstants.AccessControlExposeHeaders, "exposed1,exposed2"); - - // Act - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var responseHeaders = response.Headers; - Assert.Equal( - new[] { "http://example.com" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowOrigin).ToArray()); - Assert.Equal( - new[] { "true" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowCredentials).ToArray()); - Assert.Equal( - new[] { "exposed1,exposed2" }, - responseHeaders.GetValues(CorsConstants.AccessControlExposeHeaders).ToArray()); - - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("abcd", content); - } - - [Fact] - public async Task SuccessfulPreflightRequest_AllowsCredentials_IfThePolicyAllowsCredentials() - { - // Arrange - var request = new HttpRequestMessage( - new HttpMethod(CorsConstants.PreflightHttpMethod), - "http://localhost/Cors/EditUserComment?userComment=abcd"); - - // Adding a custom header makes it a non-simple request. - request.Headers.Add(CorsConstants.Origin, "http://example.com"); - request.Headers.Add(CorsConstants.AccessControlRequestMethod, "PUT"); - request.Headers.Add(CorsConstants.AccessControlRequestHeaders, "header1,header2"); - - // Act - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var responseHeaders = response.Headers; - Assert.Equal( - new[] { "http://example.com" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowOrigin).ToArray()); - Assert.Equal( - new[] { "true" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowCredentials).ToArray()); - Assert.Equal( - new[] { "header1,header2" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowHeaders).ToArray()); - Assert.Equal( - new[] { "PUT" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowMethods).ToArray()); - - var content = await response.Content.ReadAsStringAsync(); - Assert.Empty(content); - } - - [Fact] - public async Task PolicyFailed_Allows_ActualRequest_WithMissingResponseHeaders() - { - // Arrange - var request = new HttpRequestMessage(HttpMethod.Put, "http://localhost/Cors/GetUserComments"); - - // Adding a custom header makes it a non simple request. - request.Headers.Add(CorsConstants.Origin, "http://example2.com"); - - // Act - var response = await Client.SendAsync(request); - - // 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); - - // It still have executed the action. - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("[\"usercomment1\",\"usercomment2\",\"usercomment3\"]", content); - } - - [Theory] - [InlineData("GET")] - [InlineData("HEAD")] - [InlineData("POST")] - public async Task DisableCors_ActionsCanOverride_ControllerLevel(string method) - { - // Arrange - var request = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Cors/GetExclusiveContent"); - - // Exclusive content is not available on other sites. - request.Headers.Add(CorsConstants.Origin, "http://example.com"); - - // Act - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - // Since there are no response headers, the client should step in to block the content. - Assert.Empty(response.Headers); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("exclusive", content); - } - - [Theory] - [InlineData("GET")] - [InlineData("HEAD")] - [InlineData("POST")] - public async Task DisableCors_PreFlight_ActionsCanOverride_ControllerLevel(string method) - { - // Arrange - var request = new HttpRequestMessage( - new HttpMethod(CorsConstants.PreflightHttpMethod), - "http://localhost/Cors/GetExclusiveContent"); - - // Exclusive content is not available on other sites. - request.Headers.Add(CorsConstants.Origin, "http://example.com"); - request.Headers.Add(CorsConstants.AccessControlRequestMethod, method); - request.Headers.Add(CorsConstants.AccessControlRequestHeaders, "Custom"); - - // Act - var response = await Client.SendAsync(request); - - // Assert - // Since there are no response headers, the client should step in to block the content. - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Empty(response.Headers); - - // Nothing gets executed for a pre-flight request. - var content = await response.Content.ReadAsStringAsync(); - Assert.Empty(content); - } - - [Theory] - [InlineData("http://localhost/api/store/actionusingcontrollercorssettings")] - [InlineData("http://localhost/api/store/actionwithcorssettings")] - public async Task CorsFilter_RunsBeforeOtherAuthorizationFilters(string url) - { - // Arrange - 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[] { "http://example.com" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowOrigin).ToArray()); - Assert.Equal( - new[] { "true" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowCredentials).ToArray()); - Assert.Equal( - new[] { "Custom" }, - responseHeaders.GetValues(CorsConstants.AccessControlAllowHeaders).ToArray()); - - var content = await response.Content.ReadAsStringAsync(); - Assert.Empty(content); - } - - [Fact] - public async Task DisableCorsFilter_RunsBeforeOtherAuthorizationFilters() - { - // Controller has an authorization filter and Cors filter and the action has a DisableCors filter - // In this scenario, the CorsFilter should be executed before any other authorization filters - // i.e irrespective of where the Cors filter is applied(controller or action), Cors filters must - // always be executed before any other type of authorization filters. - - // Arrange - var request = new HttpRequestMessage( - new HttpMethod(CorsConstants.PreflightHttpMethod), - "http://localhost/api/store/actionwithcorsdisabled"); - - // 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); - Assert.Empty(response.Headers); - - // Nothing gets executed for a pre-flight request. - var content = await response.Content.ReadAsStringAsync(); - Assert.Empty(content); - } - - [Fact] - public async Task CorsFilter_OnAction_PreferredOverController_AndAuthorizationFiltersRunAfterCors() - { - // Arrange - var request = new HttpRequestMessage( - new HttpMethod(CorsConstants.PreflightHttpMethod), - "http://localhost/api/store/actionwithdifferentcorspolicy"); - request.Headers.Add(CorsConstants.Origin, "http://notexpecteddomain.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); - Assert.Empty(response.Headers); - - // Nothing gets executed for a pre-flight request. - var content = await response.Content.ReadAsStringAsync(); - Assert.Empty(content); - } } } \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTestsBase.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTestsBase.cs new file mode 100644 index 0000000000..41687b9c5f --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CorsTestsBase.cs @@ -0,0 +1,407 @@ +// 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 System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.Hosting; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public abstract class CorsTestsBase : IClassFixture> where TStartup : class + { + protected CorsTestsBase(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + [InlineData("POST")] + public async Task ResourceWithSimpleRequestPolicy_Allows_SimpleRequests(string method) + { + // Arrange + var origin = "http://example.com"; + var request = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Cors/GetBlogComments"); + request.Headers.Add(CorsConstants.Origin, origin); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("[\"comment1\",\"comment2\",\"comment3\"]", content); + var responseHeaders = response.Headers; + var header = Assert.Single(response.Headers); + Assert.Equal(CorsConstants.AccessControlAllowOrigin, header.Key); + Assert.Equal(new[] { "*" }, header.Value.ToArray()); + } + + [Fact] + public async Task OptionsRequest_NonPreflight_ExecutesOptionsAction() + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod("OPTIONS"), "http://localhost/NonCors/GetOptions"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("[\"Create\",\"Update\",\"Delete\"]", content); + Assert.Empty(response.Headers); + } + + [Fact] + public async Task PreflightRequestOnNonCorsEnabledController_ExecutesOptionsAction() + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod("OPTIONS"), "http://localhost/NonCors/GetOptions"); + request.Headers.Add(CorsConstants.Origin, "http://example.com"); + request.Headers.Add(CorsConstants.AccessControlRequestMethod, "POST"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("[\"Create\",\"Update\",\"Delete\"]", content); + Assert.Empty(response.Headers); + } + + [Fact] + public virtual async Task PreflightRequestOnNonCorsEnabledController_DoesNotMatchTheAction() + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod("OPTIONS"), "http://localhost/NonCors/Post"); + request.Headers.Add(CorsConstants.Origin, "http://example.com"); + request.Headers.Add(CorsConstants.AccessControlRequestMethod, "POST"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + [InlineData("POST")] + [InlineData("PUT")] + public async Task OriginMatched_ReturnsHeaders(string method) + { + // Arrange + var request = new HttpRequestMessage( + new HttpMethod(CorsConstants.PreflightHttpMethod), + "http://localhost/Cors/GetBlogComments"); + + // Adding a custom header makes it a non-simple request. + request.Headers.Add(CorsConstants.Origin, "http://example.com"); + request.Headers.Add(CorsConstants.AccessControlRequestMethod, method); + request.Headers.Add(CorsConstants.AccessControlRequestHeaders, "Custom"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + // MVC applied the policy and since that did not pass, there were no access control headers. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + 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(); + Assert.Equal(string.Empty, content); + } + + [Fact] + public async Task SuccessfulCorsRequest_AllowsCredentials_IfThePolicyAllowsCredentials() + { + // Arrange + var request = new HttpRequestMessage( + HttpMethod.Put, + "http://localhost/Cors/EditUserComment?userComment=abcd"); + + // Adding a custom header makes it a non-simple request. + request.Headers.Add(CorsConstants.Origin, "http://example.com"); + request.Headers.Add(CorsConstants.AccessControlExposeHeaders, "exposed1,exposed2"); + + // 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[] { "exposed1,exposed2" }, + responseHeaders.GetValues(CorsConstants.AccessControlExposeHeaders).ToArray()); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("abcd", content); + } + + [Fact] + public async Task SuccessfulPreflightRequest_AllowsCredentials_IfThePolicyAllowsCredentials() + { + // Arrange + var request = new HttpRequestMessage( + new HttpMethod(CorsConstants.PreflightHttpMethod), + "http://localhost/Cors/EditUserComment?userComment=abcd"); + + // Adding a custom header makes it a non-simple request. + request.Headers.Add(CorsConstants.Origin, "http://example.com"); + request.Headers.Add(CorsConstants.AccessControlRequestMethod, "PUT"); + request.Headers.Add(CorsConstants.AccessControlRequestHeaders, "header1,header2"); + + // 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[] { "header1,header2" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowHeaders).ToArray()); + Assert.Equal( + new[] { "PUT,POST" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowMethods).ToArray()); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Empty(content); + } + + [Fact] + public async Task PolicyFailed_Allows_ActualRequest_WithMissingResponseHeaders() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Put, "http://localhost/Cors/GetUserComments"); + + // Adding a custom header makes it a non simple request. + request.Headers.Add(CorsConstants.Origin, "http://example2.com"); + + // Act + var response = await Client.SendAsync(request); + + // 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); + + // It still have executed the action. + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("[\"usercomment1\",\"usercomment2\",\"usercomment3\"]", content); + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + [InlineData("POST")] + public async Task DisableCors_ActionsCanOverride_ControllerLevel(string method) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Cors/GetExclusiveContent"); + + // Exclusive content is not available on other sites. + request.Headers.Add(CorsConstants.Origin, "http://example.com"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Since there are no response headers, the client should step in to block the content. + Assert.Empty(response.Headers); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("exclusive", content); + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + [InlineData("POST")] + public async Task DisableCors_PreFlight_ActionsCanOverride_ControllerLevel(string method) + { + // Arrange + var request = new HttpRequestMessage( + new HttpMethod(CorsConstants.PreflightHttpMethod), + "http://localhost/Cors/GetExclusiveContent"); + + // Exclusive content is not available on other sites. + request.Headers.Add(CorsConstants.Origin, "http://example.com"); + request.Headers.Add(CorsConstants.AccessControlRequestMethod, method); + request.Headers.Add(CorsConstants.AccessControlRequestHeaders, "Custom"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + // Since there are no response headers, the client should step in to block the content. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(response.Headers); + + // Nothing gets executed for a pre-flight request. + var content = await response.Content.ReadAsStringAsync(); + Assert.Empty(content); + } + + [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[] { "Custom" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowHeaders).ToArray()); + Assert.Equal( + new[] { "GET" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowMethods).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. + 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[] { "http://example.com" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowOrigin).ToArray()); + Assert.Equal( + new[] { "true" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowCredentials).ToArray()); + Assert.Equal( + new[] { "Custom" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowHeaders).ToArray()); + Assert.Equal( + new[] { "GET" }, + responseHeaders.GetValues(CorsConstants.AccessControlAllowMethods).ToArray()); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Empty(content); + } + + [Fact] + public async Task DisableCorsFilter_RunsBeforeOtherAuthorizationFilters() + { + // Controller has an authorization filter and Cors filter and the action has a DisableCors filter + // In this scenario, the CorsFilter should be executed before any other authorization filters + // i.e irrespective of where the Cors filter is applied(controller or action), Cors filters must + // always be executed before any other type of authorization filters. + + // Arrange + var request = new HttpRequestMessage( + new HttpMethod(CorsConstants.PreflightHttpMethod), + "http://localhost/api/store/actionwithcorsdisabled"); + + // 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); + Assert.Empty(response.Headers); + + // Nothing gets executed for a pre-flight request. + var content = await response.Content.ReadAsStringAsync(); + Assert.Empty(content); + } + + [Fact] + public async Task CorsFilter_OnAction_PreferredOverController_AndAuthorizationFiltersRunAfterCors() + { + // Arrange + var request = new HttpRequestMessage( + new HttpMethod(CorsConstants.PreflightHttpMethod), + "http://localhost/api/store/actionwithdifferentcorspolicy"); + request.Headers.Add(CorsConstants.Origin, "http://notexpecteddomain.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); + Assert.Empty(response.Headers); + + // Nothing gets executed for a pre-flight request. + var content = await response.Content.ReadAsStringAsync(); + Assert.Empty(content); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DataAnnotationTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DataAnnotationTests.cs new file mode 100644 index 0000000000..f90e239345 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DataAnnotationTests.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Threading.Tasks; +using RazorWebSite; +using Microsoft.AspNetCore.Hosting; +using Xunit; +using System.Linq; +using System.Net.Http; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class DataAnnotationTests : IClassFixture> + { + private HttpClient Client { get; set; } + + public DataAnnotationTests(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(builder => + { + builder.UseStartup(); + }); + Client = factory.CreateDefaultClient(); + } + + private const string EnumUrl = "http://localhost/Enum/Enum"; + + [Fact] + public async Task DataAnnotationLocalizationOfEnums_FromDataAnnotationLocalizerProvider() + { + // Arrange & Act + var response = await Client.GetAsync(EnumUrl); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("FirstOptionDisplay from singletype", content); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DefaultValuesTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DefaultValuesTest.cs index da39f5f4ca..d6aa6472a7 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DefaultValuesTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DefaultValuesTest.cs @@ -18,7 +18,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public HttpClient Client { get; } [Fact] - public async Task Controller_WithDefaultValueAttribut_ReturnsDefault() + public async Task Controller_WithDefaultValueAttribute_ReturnsDefault() { // Arrange var expected = "hello"; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ErrorPageTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ErrorPageTests.cs index 644a4ac067..31a74644b1 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ErrorPageTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ErrorPageTests.cs @@ -6,7 +6,6 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text.Encodings.Web; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Razor.Internal; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -17,8 +16,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public class ErrorPageTests : IClassFixture> { private static readonly string PreserveCompilationContextMessage = HtmlEncoder.Default.Encode( - "One or more compilation references are missing. Ensure that your project is referencing " + - "'Microsoft.NET.Sdk.Web' and the 'PreserveCompilationContext' property is not set to false."); + "One or more compilation references may be missing. " + + "If you're seeing this in a published application, set 'CopyRefAssembliesToPublishDirectory' to true in your project file to ensure files in the refs directory are published."); public ErrorPageTests(MvcTestFixture fixture) { Client = fixture.CreateDefaultClient(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs index f49cc631fa..88c8c293ae 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs @@ -246,17 +246,21 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("This is a sample text file", body); } + // Use int for HttpStatusCode data because xUnit cannot serialize a GAC'd enum when running on .NET Framework. [Theory] - [InlineData("", HttpStatusCode.OK, 26)] - [InlineData("bytes = 0-6", HttpStatusCode.PartialContent, 7)] - [InlineData("bytes = 17-25", HttpStatusCode.PartialContent, 9)] - [InlineData("bytes = 0-50", HttpStatusCode.PartialContent, 26)] - [InlineData("0-6", HttpStatusCode.OK, 26)] - [InlineData("bytes = ", HttpStatusCode.OK, 26)] - [InlineData("bytes = 1-4, 5-11", HttpStatusCode.OK, 26)] - [InlineData("bytes = 35-36", HttpStatusCode.RequestedRangeNotSatisfiable, 26)] - [InlineData("bytes = -0", HttpStatusCode.RequestedRangeNotSatisfiable, 26)] - public async Task FileFromDisk_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest_WithLastModifiedAndEtag(string rangeString, HttpStatusCode httpStatusCode, int expectedContentLength) + [InlineData("", (int)HttpStatusCode.OK, 26)] + [InlineData("bytes = 0-6", (int)HttpStatusCode.PartialContent, 7)] + [InlineData("bytes = 17-25", (int)HttpStatusCode.PartialContent, 9)] + [InlineData("bytes = 0-50", (int)HttpStatusCode.PartialContent, 26)] + [InlineData("0-6", (int)HttpStatusCode.OK, 26)] + [InlineData("bytes = ", (int)HttpStatusCode.OK, 26)] + [InlineData("bytes = 1-4, 5-11", (int)HttpStatusCode.OK, 26)] + [InlineData("bytes = 35-36", (int)HttpStatusCode.RequestedRangeNotSatisfiable, 26)] + [InlineData("bytes = -0", (int)HttpStatusCode.RequestedRangeNotSatisfiable, 26)] + public async Task FileFromDisk_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest_WithLastModifiedAndEtag( + string rangeString, + int httpStatusCode, + int expectedContentLength) { // Arrange var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, "http://localhost/DownloadFiles/DownloadFromDiskWithFileName_WithLastModifiedAndEtag"); @@ -267,7 +271,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var response = await Client.SendAsync(httpRequestMessage); // Assert - Assert.Equal(httpStatusCode, response.StatusCode); + Assert.Equal(httpStatusCode, (int)response.StatusCode); Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); @@ -442,17 +446,21 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("This is sample text from a stream", body); } + // Use int for HttpStatusCode data because xUnit cannot serialize a GAC'd enum when running on .NET Framework. [Theory] - [InlineData("", HttpStatusCode.OK, 33)] - [InlineData("bytes = 0-6", HttpStatusCode.PartialContent, 7)] - [InlineData("bytes = 17-25", HttpStatusCode.PartialContent, 9)] - [InlineData("bytes = 0-50", HttpStatusCode.PartialContent, 33)] - [InlineData("0-6", HttpStatusCode.OK, 33)] - [InlineData("bytes = ", HttpStatusCode.OK, 33)] - [InlineData("bytes = 1-4, 5-11", HttpStatusCode.OK, 33)] - [InlineData("bytes = 35-36", HttpStatusCode.RequestedRangeNotSatisfiable, 33)] - [InlineData("bytes = -0", HttpStatusCode.RequestedRangeNotSatisfiable, 33)] - public async Task FileFromStream_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest(string rangeString, HttpStatusCode httpStatusCode, int expectedContentLength) + [InlineData("", (int)HttpStatusCode.OK, 33)] + [InlineData("bytes = 0-6", (int)HttpStatusCode.PartialContent, 7)] + [InlineData("bytes = 17-25", (int)HttpStatusCode.PartialContent, 9)] + [InlineData("bytes = 0-50", (int)HttpStatusCode.PartialContent, 33)] + [InlineData("0-6", (int)HttpStatusCode.OK, 33)] + [InlineData("bytes = ", (int)HttpStatusCode.OK, 33)] + [InlineData("bytes = 1-4, 5-11", (int)HttpStatusCode.OK, 33)] + [InlineData("bytes = 35-36", (int)HttpStatusCode.RequestedRangeNotSatisfiable, 33)] + [InlineData("bytes = -0", (int)HttpStatusCode.RequestedRangeNotSatisfiable, 33)] + public async Task FileFromStream_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest( + string rangeString, + int httpStatusCode, + int expectedContentLength) { // Arrange var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, "http://localhost/DownloadFiles/DownloadFromStreamWithFileName_WithEtag"); @@ -463,7 +471,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var response = await Client.SendAsync(httpRequestMessage); // Assert - Assert.Equal(httpStatusCode, response.StatusCode); + Assert.Equal(httpStatusCode, (int)response.StatusCode); Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); @@ -616,7 +624,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var response = await Client.SendAsync(httpRequestMessage); var body = await response.Content.ReadAsStringAsync(); - // Assert + // Assert Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); Assert.NotNull(body); @@ -643,17 +651,21 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("This is a sample text from a binary array", body); } + // Use int for HttpStatusCode data because xUnit cannot serialize a GAC'd enum when running on .NET Framework. [Theory] - [InlineData("", HttpStatusCode.OK, 41)] - [InlineData("bytes = 0-6", HttpStatusCode.PartialContent, 7)] - [InlineData("bytes = 17-25", HttpStatusCode.PartialContent, 9)] - [InlineData("bytes = 0-50", HttpStatusCode.PartialContent, 41)] - [InlineData("0-6", HttpStatusCode.OK, 41)] - [InlineData("bytes = ", HttpStatusCode.OK, 41)] - [InlineData("bytes = 1-4, 5-11", HttpStatusCode.OK, 41)] - [InlineData("bytes = 45-46", HttpStatusCode.RequestedRangeNotSatisfiable, 41)] - [InlineData("bytes = -0", HttpStatusCode.RequestedRangeNotSatisfiable, 41)] - public async Task FileFromBinaryData_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest(string rangeString, HttpStatusCode httpStatusCode, int expectedContentLength) + [InlineData("", (int)HttpStatusCode.OK, 41)] + [InlineData("bytes = 0-6", (int)HttpStatusCode.PartialContent, 7)] + [InlineData("bytes = 17-25", (int)HttpStatusCode.PartialContent, 9)] + [InlineData("bytes = 0-50", (int)HttpStatusCode.PartialContent, 41)] + [InlineData("0-6", (int)HttpStatusCode.OK, 41)] + [InlineData("bytes = ", (int)HttpStatusCode.OK, 41)] + [InlineData("bytes = 1-4, 5-11", (int)HttpStatusCode.OK, 41)] + [InlineData("bytes = 45-46", (int)HttpStatusCode.RequestedRangeNotSatisfiable, 41)] + [InlineData("bytes = -0", (int)HttpStatusCode.RequestedRangeNotSatisfiable, 41)] + public async Task FileFromBinaryData_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest( + string rangeString, + int httpStatusCode, + int expectedContentLength) { // Arrange var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, "http://localhost/DownloadFiles/DownloadFromBinaryDataWithFileName_WithEtag"); @@ -664,7 +676,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var response = await Client.SendAsync(httpRequestMessage); // Assert - Assert.Equal(httpStatusCode, response.StatusCode); + Assert.Equal(httpStatusCode, (int)response.StatusCode); Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); @@ -842,17 +854,21 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } + // Use int for HttpStatusCode data because xUnit cannot serialize a GAC'd enum when running on .NET Framework. [Theory] - [InlineData("", HttpStatusCode.OK, 38)] - [InlineData("bytes = 0-6", HttpStatusCode.PartialContent, 7)] - [InlineData("bytes = 17-25", HttpStatusCode.PartialContent, 9)] - [InlineData("bytes = 0-50", HttpStatusCode.PartialContent, 38)] - [InlineData("0-6", HttpStatusCode.OK, 38)] - [InlineData("bytes = ", HttpStatusCode.OK, 38)] - [InlineData("bytes = 1-4, 5-11", HttpStatusCode.OK, 38)] - [InlineData("bytes = 45-46", HttpStatusCode.RequestedRangeNotSatisfiable, 38)] - [InlineData("bytes = -0", HttpStatusCode.RequestedRangeNotSatisfiable, 38)] - public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest(string rangeString, HttpStatusCode httpStatusCode, int expectedContentLength) + [InlineData("", (int)HttpStatusCode.OK, 38)] + [InlineData("bytes = 0-6", (int)HttpStatusCode.PartialContent, 7)] + [InlineData("bytes = 17-25", (int)HttpStatusCode.PartialContent, 9)] + [InlineData("bytes = 0-50", (int)HttpStatusCode.PartialContent, 38)] + [InlineData("0-6", (int)HttpStatusCode.OK, 38)] + [InlineData("bytes = ", (int)HttpStatusCode.OK, 38)] + [InlineData("bytes = 1-4, 5-11", (int)HttpStatusCode.OK, 38)] + [InlineData("bytes = 45-46", (int)HttpStatusCode.RequestedRangeNotSatisfiable, 38)] + [InlineData("bytes = -0", (int)HttpStatusCode.RequestedRangeNotSatisfiable, 38)] + public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest( + string rangeString, + int httpStatusCode, + int expectedContentLength) { // Arrange var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, "http://localhost/EmbeddedFiles/DownloadFileWithFileName"); @@ -863,7 +879,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var response = await Client.SendAsync(httpRequestMessage); // Assert - Assert.Equal(httpStatusCode, response.StatusCode); + Assert.Equal(httpStatusCode, (int)response.StatusCode); Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/GlobalAuthorizationFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/GlobalAuthorizationFilterTest.cs index 23dce2a053..5ac55864af 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/GlobalAuthorizationFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/GlobalAuthorizationFilterTest.cs @@ -6,9 +6,7 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -58,11 +56,9 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task AuthorizationPoliciesDoNotCombine_WithV2_0() { // Arrange & Act - var factory = Factory.WithWebHostBuilder( - builder => builder.ConfigureServices( - services => services.Configure( - options => options.CompatibilityVersion = CompatibilityVersion.Version_2_0))); - var client = factory.CreateDefaultClient(); + var client = Factory + .WithWebHostBuilder(builder => builder.UseStartup()) + .CreateDefaultClient(); var response = await client.PostAsync("http://localhost/Administration/SignInCookie2", null); // Assert diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs index f865f1bcd4..1f7e18760b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs @@ -3,14 +3,15 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using System.Text; using System.Threading.Tasks; -using AngleSharp.Dom; -using AngleSharp.Dom.Html; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -25,14 +26,21 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests MvcTestFixture fixture, MvcEncodedTestFixture encodedFixture) { + Factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = fixture.CreateDefaultClient(); EncodedClient = encodedFixture.CreateDefaultClient(); } + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + public HttpClient Client { get; } public HttpClient EncodedClient { get; } + public WebApplicationFactory Factory { get; } + public static TheoryData WebPagesData { get @@ -130,6 +138,35 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests } } + [Fact] + public async Task HtmlGenerationWebSite_LinkGeneration_With21CompatibilityBehavior() + { + // Arrange + var client = Factory + .WithWebHostBuilder(builder => builder.UseStartup()) + .CreateDefaultClient(); + var expectedMediaType = MediaTypeHeaderValue.Parse("text/html; charset=utf-8"); + var outputFile = "compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index21Compat.html"; + var expectedContent = + await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile, sourceFile: false); + + // Act + // The host is not important as everything runs in memory and tests are isolated from each other. + var response = await client.GetAsync("http://localhost/HtmlGeneration_Home/"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedMediaType, response.Content.Headers.ContentType); + + responseContent = responseContent.Trim(); +#if GENERATE_BASELINES + ResourceFile.UpdateFile(_resourcesAssembly, outputFile, expectedContent, responseContent); +#else + Assert.Equal(expectedContent.Trim(), responseContent, ignoreLineEndingDifferences: true); +#endif + } + public static TheoryData EncodedPagesData { get @@ -367,7 +404,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(expected2, response2.Trim()); // Act - 3 - // Resend the cookiesless request and cached result from the first response. + // Resend the cookieless request and cached result from the first response. var response3 = await Client.GetStringAsync("/catalog/cart?correlationid=3"); // Assert - 3 @@ -566,7 +603,7 @@ Products: Music Systems, Televisions (3)"; var document = await Client.GetHtmlDocumentAsync(url); // Assert - var banner = QuerySelector(document, ".banner"); + var banner = document.RequiredQuerySelector(".banner"); Assert.Equal("Some status message", banner.TextContent); } @@ -581,19 +618,81 @@ Products: Music Systems, Televisions (3)"; var document = await Client.GetHtmlDocumentAsync(url); // Assert - var banner = QuerySelector(document, ".banner"); + var banner = document.RequiredQuerySelector(".banner"); Assert.Empty(banner.TextContent); } - private static IElement QuerySelector(IHtmlDocument document, string selector) + [Fact] + public async Task PartialTagHelper_AllowsUsingFallback() { - var element = document.QuerySelector(selector); - if (element == null) - { - throw new ArgumentException($"Document does not contain element that matches the selector {selector}: " + Environment.NewLine + document.DocumentElement.OuterHtml); - } + // Arrange + var url = "/Customer/PartialWithFallback"; - return element; + // Act + var document = await Client.GetHtmlDocumentAsync(url); + + // Assert + var content = document.RequiredQuerySelector("#content"); + Assert.Equal("Hello from fallback", content.TextContent); + } + + [Fact] + public async Task PartialTagHelper_AllowsUsingOptional() + { + // Arrange + var url = "/Customer/PartialWithOptional"; + + // Act + var document = await Client.GetHtmlDocumentAsync(url); + + // Assert + var content = document.RequiredQuerySelector("#content"); + Assert.Empty(content.TextContent); + } + + [Fact] + public async Task ValidationProviderAttribute_ValidationTagHelpers_GeneratesExpectedDataAttributes() + { + // Act + var document = await Client.GetHtmlDocumentAsync("HtmlGeneration_Home/ValidationProviderAttribute"); + + // Assert + var firstName = document.RequiredQuerySelector("#FirstName"); + Assert.Equal("true", firstName.GetAttribute("data-val")); + Assert.Equal("The FirstName field is required.", firstName.GetAttribute("data-val-required")); + Assert.Equal("The field FirstName must be a string with a maximum length of 5.", firstName.GetAttribute("data-val-length")); + Assert.Equal("5", firstName.GetAttribute("data-val-length-max")); + Assert.Equal("The field FirstName must match the regular expression '[A-Za-z]*'.", firstName.GetAttribute("data-val-regex")); + Assert.Equal("[A-Za-z]*", firstName.GetAttribute("data-val-regex-pattern")); + + var lastName = document.RequiredQuerySelector("#LastName"); + Assert.Equal("true", lastName.GetAttribute("data-val")); + Assert.Equal("The LastName field is required.", lastName.GetAttribute("data-val-required")); + Assert.Equal("The field LastName must be a string with a maximum length of 6.", lastName.GetAttribute("data-val-length")); + Assert.Equal("6", lastName.GetAttribute("data-val-length-max")); + Assert.False(lastName.HasAttribute("data-val-regex")); + } + + [Fact] + public async Task ValidationProviderAttribute_ValidationTagHelpers_GeneratesExpectedSpansAndDivsOnValidationError() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "HtmlGeneration_Home/ValidationProviderAttribute"); + request.Content = new FormUrlEncodedContent(new Dictionary + { + { "FirstName", "TestFirstName" }, + }); + + // Act + var response = await Client.SendAsync(request); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + var document = await response.GetHtmlDocumentAsync(); + Assert.Collection( + document.QuerySelectorAll("div.validation-summary-errors ul li"), + item => Assert.Equal("The field FirstName must be a string with a maximum length of 5.", item.TextContent), + item => Assert.Equal("The LastName field is required.", item.TextContent)); } private static HttpRequestMessage RequestWithLocale(string url, string locale) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationWithCultureTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationWithCultureTest.cs new file mode 100644 index 0000000000..d028f5ec24 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationWithCultureTest.cs @@ -0,0 +1,184 @@ +// 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.Net.Http; +using System.Threading.Tasks; +using AngleSharp.Dom; +using AngleSharp.Dom.Html; +using AngleSharp.Extensions; +using AngleSharp.Html; +using HtmlGenerationWebSite; +using Microsoft.AspNetCore.Hosting; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class HtmlGenerationWithCultureTest : IClassFixture> + { + public HtmlGenerationWithCultureTest(MvcTestFixture fixture) + { + var factory = fixture.WithWebHostBuilder(builder => builder.UseStartup()); + Client = factory.CreateDefaultClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task CacheTagHelper_AllowsVaryingByCulture() + { + // Arrange + string culture; + string correlationId; + string cachedCorrelationId; + + // Act - 1 + var document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=10"); + ReadValuesFromDocument(); + + // Assert - 1 + Assert.Equal("fr-FR", culture); + Assert.Equal("10", correlationId); + Assert.Equal("10", cachedCorrelationId); + + // Act - 2 + document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=en-GB&correlationId=11"); + ReadValuesFromDocument(); + + // Assert - 2 + Assert.Equal("en-GB", culture); + Assert.Equal("11", correlationId); + Assert.Equal("11", cachedCorrelationId); + + // Act - 3 + document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=14"); + ReadValuesFromDocument(); + + // Assert - 3 + Assert.Equal("fr-FR", culture); + Assert.Equal("14", correlationId); + // Verify we're reading a cached value + Assert.Equal("10", cachedCorrelationId); + + void ReadValuesFromDocument() + { + culture = QuerySelector(document, "#culture").TextContent; + correlationId = QuerySelector(document, "#correlation-id").TextContent; + cachedCorrelationId = QuerySelector(document, "#cached-correlation-id").TextContent; + } + } + + [Fact] + public async Task CacheTagHelper_AllowsVaryingByUICulture() + { + // Arrange + string culture; + string uiCulture; + string correlationId; + string cachedCorrelationId; + + // Act - 1 + var document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&ui-culture=fr-FR&correlationId=10"); + ReadValuesFromDocument(); + + // Assert - 1 + Assert.Equal("fr-FR", culture); + Assert.Equal("fr-FR", uiCulture); + Assert.Equal("10", correlationId); + Assert.Equal("10", cachedCorrelationId); + + // Act - 2 + document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&ui-culture=fr-CA&correlationId=11"); + ReadValuesFromDocument(); + + // Assert - 2 + Assert.Equal("fr-FR", culture); + Assert.Equal("fr-CA", uiCulture); + Assert.Equal("11", correlationId); + Assert.Equal("11", cachedCorrelationId); + + // Act - 3 + document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&ui-culture=fr-FR&correlationId=14"); + ReadValuesFromDocument(); + + // Assert - 3 + Assert.Equal("fr-FR", culture); + Assert.Equal("fr-FR", uiCulture); + Assert.Equal("14", correlationId); + // Verify we're reading a cached value + Assert.Equal("10", cachedCorrelationId); + + void ReadValuesFromDocument() + { + culture = QuerySelector(document, "#culture").TextContent; + uiCulture = QuerySelector(document, "#ui-culture").TextContent; + correlationId = QuerySelector(document, "#correlation-id").TextContent; + cachedCorrelationId = QuerySelector(document, "#cached-correlation-id").TextContent; + } + } + + [Fact] + public async Task CacheTagHelper_VaryByCultureComposesWithOtherVaryByOptions() + { + // Arrange + string culture; + string correlationId; + string cachedCorrelationId; + + // Act - 1 + var document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=10"); + ReadValuesFromDocument(); + + // Assert - 1 + Assert.Equal("fr-FR", culture); + Assert.Equal("10", correlationId); + Assert.Equal("10", cachedCorrelationId); + + // Act - 2 + document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=11&varyByQueryKey=new-key"); + ReadValuesFromDocument(); + + // Assert - 2 + // vary-by-query should produce a new cached value. + Assert.Equal("fr-FR", culture); + Assert.Equal("11", correlationId); + Assert.Equal("11", cachedCorrelationId); + + // Act - 3 + document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=14"); + ReadValuesFromDocument(); + + // Assert - 3 + Assert.Equal("fr-FR", culture); + Assert.Equal("14", correlationId); + + if (cachedCorrelationId != "10") + { + // This is logging to investigate potential flakiness in this test tracked by https://github.com/aspnet/Mvc/issues/8281 + var documentContent = document.ToHtml(new HtmlMarkupFormatter()); + throw new XunitException($"Unexpected correlation Id, reading values from document:{Environment.NewLine}{documentContent}"); + } + + Assert.Equal("10", cachedCorrelationId); + + void ReadValuesFromDocument() + { + culture = QuerySelector(document, "#culture").TextContent; + correlationId = QuerySelector(document, "#correlation-id").TextContent; + cachedCorrelationId = QuerySelector(document, "#cached-correlation-id").TextContent; + } + } + + private static IElement QuerySelector(IHtmlDocument document, string selector) + { + var element = document.QuerySelector(selector); + if (element == null) + { + throw new ArgumentException($"Document does not contain element that matches the selector {selector}: " + Environment.NewLine + document.DocumentElement.OuterHtml); + } + + return element; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/BasicApiFixture.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/BasicApiFixture.cs new file mode 100644 index 0000000000..56294311a7 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/BasicApiFixture.cs @@ -0,0 +1,21 @@ +// 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 BasicApi; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class BasicApiFixture : MvcTestFixture + { + // Do not leave .db file behind. Also, ensure added pet gets expected id (1) in subsequent runs. + protected override void Dispose(bool disposing) + { + if (disposing) + { + Startup.DropDatabase(Server.Host.Services); + } + + base.Dispose(disposing); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/BasicViewsFixture.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/BasicViewsFixture.cs new file mode 100644 index 0000000000..3ea0c60835 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/BasicViewsFixture.cs @@ -0,0 +1,21 @@ +// 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 BasicViews; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class BasicViewsFixture : MvcTestFixture + { + // Do not leave .db file behind. + protected override void Dispose(bool disposing) + { + if (disposing) + { + Startup.DropDatabase(Server.Host.Services); + } + + base.Dispose(disposing); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/HttpClientExtensions.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/HttpClientExtensions.cs index fe8d4f300b..184e83cd31 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/HttpClientExtensions.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/HttpClientExtensions.cs @@ -18,6 +18,11 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var response = await client.GetAsync(requestUri); await AssertStatusCodeAsync(response, HttpStatusCode.OK); + return await GetHtmlDocumentAsync(response); + } + + public static async Task GetHtmlDocumentAsync(this HttpResponseMessage response) + { var content = await response.Content.ReadAsStringAsync(); var parser = new HtmlParser(); var document = parser.Parse(content); @@ -48,7 +53,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests throw new StatusCodeMismatchException { - ExpectedStatusCode = HttpStatusCode.OK, + ExpectedStatusCode = expectedStatusCode, ActualStatusCode = response.StatusCode, ResponseContent = responseContent, }; @@ -66,7 +71,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests { get { - return $"Excepted status code 200. Actual {ActualStatusCode}. Response Content:" + Environment.NewLine + ResponseContent; + return $"Excepted status code {ExpectedStatusCode}. Actual {ActualStatusCode}. Response Content:" + Environment.NewLine + ResponseContent; } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/IHtmlDocumentExtensions.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/IHtmlDocumentExtensions.cs new file mode 100644 index 0000000000..b23ccfa657 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/IHtmlDocumentExtensions.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using AngleSharp.Dom; +using AngleSharp.Dom.Html; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public static class IHtmlDocumentExtensions + { + public static IElement RequiredQuerySelector(this IHtmlDocument document, string selector) + { + var element = document.QuerySelector(selector); + if (element == null) + { + throw new ArgumentException($"Document does not contain element that matches the selector {selector}: " + Environment.NewLine + document.DocumentElement.OuterHtml); + } + + return element; + } + + public static string RetrieveAntiforgeryToken(this IHtmlDocument htmlDocument) + { + var hiddenInputs = htmlDocument.QuerySelectorAll("form input[type=hidden]"); + foreach (var input in hiddenInputs) + { + if (!input.HasAttribute("name")) + { + continue; + } + + var name = input.GetAttribute("name"); + if (name == "__RequestVerificationToken" || name == "HtmlEncode[[__RequestVerificationToken]]") + { + return input.GetAttribute("value"); + } + } + + throw new Exception($"Antiforgery token could not be located in {htmlDocument.Source.Text}."); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/MvcTestFixture.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/MvcTestFixture.cs index 7e32759610..cf1ed53098 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/MvcTestFixture.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/MvcTestFixture.cs @@ -3,10 +3,11 @@ using System.Globalization; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { @@ -19,8 +20,12 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests .UseRequestCulture("en-GB", "en-US") .UseEnvironment("Production") .ConfigureServices( - services => services.Configure( - options => options.CompatibilityVersion = CompatibilityVersion.Version_2_1)); + services => + { + var testSink = new TestSink(); + var loggerFactory = new TestLoggerFactory(testSink, enabled: true); + services.AddSingleton(loggerFactory); + }); } protected override TestServer CreateServer(IWebHostBuilder builder) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/ResourceFile.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/ResourceFile.cs similarity index 98% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/ResourceFile.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/ResourceFile.cs index f1128e0f66..97c51a0d6e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/ResourceFile.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/ResourceFile.cs @@ -7,6 +7,7 @@ using System.IO; using System.Reflection; using System.Text; using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; using Xunit; namespace Microsoft.AspNetCore.Mvc @@ -190,7 +191,8 @@ namespace Microsoft.AspNetCore.Mvc { // The build system compiles every file under the resources folder as a resource available at runtime // with the same name as the file name. Need to update this file on disc. - var projectPath = SolutionPathUtility.GetProjectPath("test", assembly); + var solutionPath = TestPathUtilities.GetSolutionRootDirectory("Mvc"); + var projectPath = Path.Combine(solutionPath, "test", assembly.GetName().Name); var fullPath = Path.Combine(projectPath, resourceName); WriteFile(fullPath, content); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputFormatterTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputFormatterTests.cs index 2425c21f3a..2d8356a19a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputFormatterTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputFormatterTests.cs @@ -287,7 +287,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests { // Act var input = "Test"; - var response = await Client.PostAsJsonAsync("PolymorhpicPropertyBinding/Action", input); + var response = await Client.PostAsJsonAsync("PolymorphicPropertyBinding/Action", input); // Assert await response.AssertStatusCodeAsync(HttpStatusCode.OK); @@ -299,7 +299,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task ValidationUsesModelMetadataFromActualModelType_ForInputFormattedProperties() { // Act - var response = await Client.PostAsJsonAsync("PolymorhpicPropertyBinding/Action", string.Empty); + var response = await Client.PostAsJsonAsync("PolymorphicPropertyBinding/Action", string.Empty); // Assert await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputObjectValidationTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputObjectValidationTests.cs index dbe25c6c92..a56ee08b8e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputObjectValidationTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputObjectValidationTests.cs @@ -1,13 +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; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using FormatterWebSite; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Testing; using Microsoft.AspNetCore.Testing.xunit; using Newtonsoft.Json; using Xunit; @@ -91,12 +92,12 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); // Mono issue - https://github.com/aspnet/External/issues/29 - Assert.Equal(PlatformNormalizer.NormalizeContent( + Assert.Equal( "The field Id must be between 1 and 2000.," + "The field Name must be a string or array type with a minimum length of '5'.," + "The field Alias must be a string with a minimum length of 3 and a maximum length of 15.," + "The field Designation must match the regular expression " + - (TestPlatformHelper.IsMono ? "[0-9a-zA-Z]*." : "'[0-9a-zA-Z]*'.")), + "'[0-9a-zA-Z]*'.", await response.Content.ReadAsStringAsync()); } @@ -182,5 +183,174 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("xyz", await response.Content.ReadAsStringAsync()); } + + [Fact] + public async Task ValidationProviderAttribute_WillValidateObject() + { + // Arrange + var invalidRequestData = "{\"FirstName\":\"TestName123\", \"LastName\": \"Test\"}"; + var content = new StringContent(invalidRequestData, Encoding.UTF8, "application/json"); + var expectedErrorMessage = + "{\"FirstName\":[\"The field FirstName must match the regular expression '[A-Za-z]*'.\"," + + "\"The field FirstName must be a string with a maximum length of 5.\"]}"; + + // Act + var response = await Client.PostAsync( + "http://localhost/Validation/ValidationProviderAttribute", content); + + // Assert + Assert.Equal(expected: StatusCodes.Status400BadRequest, actual: (int)response.StatusCode); + + var responseContent = await response.Content.ReadAsStringAsync(); + Assert.Equal(expectedErrorMessage, actual: responseContent); + } + + [Fact] + public async Task ValidationProviderAttribute_DoesNotInterfere_WithOtherValidationAttributes() + { + // Arrange + var invalidRequestData = "{\"FirstName\":\"Test\", \"LastName\": \"Testsson\"}"; + var content = new StringContent(invalidRequestData, Encoding.UTF8, "application/json"); + var expectedErrorMessage = + "{\"LastName\":[\"The field LastName must be a string with a maximum length of 5.\"]}"; + + // Act + var response = await Client.PostAsync( + "http://localhost/Validation/ValidationProviderAttribute", content); + + // Assert + Assert.Equal(expected: StatusCodes.Status400BadRequest, actual: (int)response.StatusCode); + + var responseContent = await response.Content.ReadAsStringAsync(); + Assert.Equal(expectedErrorMessage, actual: responseContent); + } + + [Fact] + public async Task ValidationProviderAttribute_RequiredAttributeErrorMessage_WillComeFirst() + { + // Arrange + var invalidRequestData = "{\"FirstName\":\"Testname\", \"LastName\": \"\"}"; + var content = new StringContent(invalidRequestData, Encoding.UTF8, "application/json"); + var expectedError = + "{\"LastName\":[\"The LastName field is required.\"]," + + "\"FirstName\":[\"The field FirstName must be a string with a maximum length of 5.\"]}"; + + // Act + var response = await Client.PostAsync( + "http://localhost/Validation/ValidationProviderAttribute", content); + + // Assert + Assert.Equal(expected: StatusCodes.Status400BadRequest, actual: (int)response.StatusCode); + + var responseContent = await response.Content.ReadAsStringAsync(); + Assert.Equal(expectedError, actual: responseContent); + } + + // Test for https://github.com/aspnet/Mvc/issues/7357 + [Fact] + public async Task ValidationThrowsError_WhenValidationExceedsMaxValidationDepth() + { + // Arrange + var expected = $"ValidationVisitor exceeded the maximum configured validation depth '32' when validating property 'Value' on type '{typeof(RecursiveIdentifier)}'. " + + "This may indicate a very deep or infinitely recursive object graph. Consider modifying 'MvcOptions.MaxValidationDepth' or suppressing validation on the model type."; + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "Validation/ValidationThrowsError_WhenValidationExceedsMaxValidationDepth") + { + Content = new StringContent(@"{ ""Id"": ""S-1-5-21-1004336348-1177238915-682003330-512"" }", Encoding.UTF8, "application/json"), + }; + + // Act & Assert + 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/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputValidationTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputValidationTests.cs index 5c5c9e7189..2e87b4b44c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputValidationTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputValidationTests.cs @@ -89,10 +89,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests "The RequiredProp field is required.", errors["RequiredProp"]); Assert.Equal( - "A value for the 'BindRequiredProp' property was not provided.", + "A value for the 'BindRequiredProp' parameter or property was not provided.", errors["BindRequiredProp"]); Assert.Equal( - "A value for the 'RequiredAndBindRequiredProp' property was not provided.", + "A value for the 'RequiredAndBindRequiredProp' parameter or property was not provided.", errors["RequiredAndBindRequiredProp"]); Assert.Equal( "The field OptionalStringLengthProp must be a string with a maximum length of 5.", @@ -104,10 +104,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests "The requiredParam field is required.", errors["requiredParam"]); Assert.Equal( - "A value for the 'bindRequiredParam' property was not provided.", + "A value for the 'bindRequiredParam' parameter or property was not provided.", errors["bindRequiredParam"]); Assert.Equal( - "A value for the 'requiredAndBindRequiredParam' property was not provided.", + "A value for the 'requiredAndBindRequiredParam' parameter or property was not provided.", errors["requiredAndBindRequiredParam"]); Assert.Equal( "The field optionalStringLengthParam must be a string with a maximum length of 5.", diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/LinkGeneratorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/LinkGeneratorTest.cs new file mode 100644 index 0000000000..1597aa98a0 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/LinkGeneratorTest.cs @@ -0,0 +1,233 @@ +// 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 System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + // Functional tests for MVC's scenarios with LinkGenerator (2.2+ only) + public class LinkGeneratorTest : IClassFixture> + { + public LinkGeneratorTest(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public async Task GetPathByAction_CanGeneratePathToSelf() + { + // Act + var response = await Client.GetAsync("LG1/LinkToSelf"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/LG1/LinkToSelf", responseContent); + } + + [Fact] + public async Task GetPathByAction_CanGeneratePathToSelf_PreserveAmbientValues() + { + // Act + var response = await Client.GetAsync("LG1/LinkToSelf/17?another-value=5"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/LG1/LinkToSelf/17?another-value=5", responseContent); + } + + [Fact] + public async Task GetPathByAction_CanGeneratePathToAnotherAction_RemovesAmbientValues() + { + // Act + var response = await Client.GetAsync("LG1/LinkToAnotherAction/17?another-value=5"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/LG1/LinkToSelf?another-value=5", responseContent); + } + + [Fact] + public async Task GetPathByAction_CanGeneratePathToAnotherController_RemovesAmbientValues() + { + // Act + var response = await Client.GetAsync("LG1/LinkToAnotherController/17?another-value=5"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/LG2/SomeAction?another-value=5", responseContent); + } + + [Fact] + public async Task GetPathByAction_CanGeneratePathToAnotherControllerInArea_RemovesAmbientValues() + { + // Act + var response = await Client.GetAsync("LG1/LinkToAnArea/17?another-value=5"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/Admin/LG3/SomeAction?another-value=5", responseContent); + } + + [Fact] + public async Task GetPathByAction_CanGeneratePathWithinArea() + { + // Act + var response = await Client.GetAsync("Admin/LG3/LinkInsideOfArea/17"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/Admin/LG3/SomeAction", responseContent); + } + + // Rejected because the calling code relies on ambient values, but doesn't pass + // the HttpContext. + [Fact] + public async Task GetPathByAction_FailsToGenerateLinkInsideArea() + { + // Act + var response = await Client.GetAsync("Admin/LG3/LinkInsideOfAreaFail/17?another-value=5"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(string.Empty, responseContent); + } + + [Fact] + public async Task GetPathByAction_CanGeneratePathOutsideOfArea() + { + // Act + var response = await Client.GetAsync("Admin/LG3/LinkOutsideOfArea/17?another-value=5"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(string.Empty, responseContent); + } + + [Fact] + public async Task GetPathByAction_CanGeneratePathFromPath() + { + // Act + var response = await Client.GetAsync("LGAnotherPage/17"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/LG2/SomeAction", responseContent); + } + + [Fact] + public async Task GetPathByPage_FromPage_CanGeneratePathWithRelativePageName() + { + // Act + var response = await Client.GetAsync("LGPage/17"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/LGAnotherPage", responseContent); + } + + [Fact] + public async Task GetPathByPage_CanGeneratePathToPage() + { + // Act + var response = await Client.GetAsync("LG1/LinkToPage/17?another-value=4"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/LGPage?another-value=4", responseContent); + } + + [Fact] + public async Task GetPathByPage_CanGeneratePathToPage_PathTransformed() + { + // Act + var response = await Client.GetAsync("LG1/LinkToPageWithTransformedPath?id=HelloWorld"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/page-route-transformer/test-page/ExtraPath/HelloWorld", responseContent); + } + + [Fact] + public async Task GetPathByPage_CanGeneratePathToPageInArea() + { + // Act + var response = await Client.GetAsync("LG1/LinkToPageInArea/17?another-value=4"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/Admin/LGAreaPage?another-value=4&handler=a-handler", responseContent); + } + + [Fact] + public async Task GetUriByAction_CanGenerateFullUri() + { + // Act + var response = await Client.GetAsync("LG1/LinkWithFullUri/17"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("http://localhost/LG1/LinkWithFullUri/17#hi", responseContent); + } + + [Fact] + public async Task GetUriByAction_CanGenerateFullUri_WithoutHttpContext() + { + // Act + var response = await Client.GetAsync("LG1/LinkWithFullUriWithoutHttpContext/17"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("https://www.example.com/LG1/LinkWithFullUri#hi", responseContent); + } + + [Fact] + public async Task GetUriByPage_CanGenerateFullUri() + { + // Act + var response = await Client.GetAsync("LG1/LinkToPageWithFullUri/17"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("http://localhost/LGPage", responseContent); + } + + [Fact] + public async Task GetUriByPage_CanGenerateFullUri_WithoutHttpContext() + { + // Act + var response = await Client.GetAsync("LG1/LinkToPageWithFullUriWithoutHttpContext/17"); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("https://www.example.com/Admin/LGAreaPage?handler=a-handler", responseContent); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj index 954ef92727..d93286ee67 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj @@ -1,13 +1,11 @@ - + $(StandardTestTfms) - $(DefineConstants);GENERATE_BASELINES $(DefineConstants);__RemoveThisBitTo__GENERATE_BASELINES - $(DefineConstants);FUNCTIONAL_TESTS @@ -26,6 +24,12 @@ + + + + + + @@ -35,10 +39,7 @@ - - - @@ -51,10 +52,15 @@ - + + + + + - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorFileUpdateTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorFileUpdateTests.cs new file mode 100644 index 0000000000..b4aee9ad9c --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorFileUpdateTests.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.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + // Verifies that updating Razor files (views and pages) with AllowRecompilingViewsOnFileChange=true works + public class RazorFileUpdateTests : IClassFixture> + { + public RazorFileUpdateTests(MvcTestFixture fixture) + { + var factory = fixture.WithWebHostBuilder(builder => + { + builder.UseStartup(); + builder.ConfigureTestServices(services => + { + services.Configure(options => options.AllowRecompilingViewsOnFileChange = true); + }); + }); + Client = factory.CreateDefaultClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task RazorViews_AreUpdatedOnChange() + { + // Arrange + var expected1 = "Original content"; + var expected2 = "New content"; + var path = "/Views/UpdateableShared/_Partial.cshtml"; + + // Act - 1 + var body = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Assert - 1 + Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true); + + // Act - 2 + await UpdateFile(path, expected2); + body = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Assert - 2 + Assert.Equal(expected2, body.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task RazorViews_AreUpdatedWhenViewImportsChange() + { + // Arrange + var content = "@GetType().Assembly.FullName"; + await UpdateFile("/Views/UpdateableIndex/Index.cshtml", content); + var initial = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Act + // Trigger a change in ViewImports + await UpdateFile("/Views/UpdateableIndex/_ViewImports.cshtml", string.Empty); + var updated = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Assert + Assert.NotEqual(initial, updated); + } + + [Fact] + public async Task RazorPages_AreUpdatedOnChange() + { + // Arrange + var expected1 = "Original content"; + var expected2 = "New content"; + + // Act - 1 + var body = await Client.GetStringAsync("/UpdateablePage"); + + // Assert - 1 + Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true); + + // Act - 2 + await UpdateRazorPages(); + await UpdateFile("/Pages/UpdateablePage.cshtml", "@page" + Environment.NewLine + expected2); + body = await Client.GetStringAsync("/UpdateablePage"); + + // Assert - 2 + Assert.Equal(expected2, body.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task RazorPages_AreUpdatedWhenViewImportsChange() + { + // Arrange + var content = "@GetType().Assembly.FullName"; + await UpdateFile("/Pages/UpdateablePage.cshtml", "@page" + Environment.NewLine + content); + var initial = await Client.GetStringAsync("/UpdateablePage"); + + // Act + // Trigger a change in ViewImports + await UpdateRazorPages(); + await UpdateFile("/Pages/UpdateablePage.cshtml", "@page" + Environment.NewLine + content); + var updated = await Client.GetStringAsync("/UpdateablePage"); + + // Assert + Assert.NotEqual(initial, updated); + } + + private async Task UpdateFile(string path, string content) + { + var updateContent = new FormUrlEncodedContent(new Dictionary + { + { "path", path }, + { "content", content }, + }); + + var response = await Client.PostAsync($"/UpdateableFileProvider/Update", updateContent); + response.EnsureSuccessStatusCode(); + } + + private async Task UpdateRazorPages() + { + var response = await Client.PostAsync($"/UpdateableFileProvider/UpdateRazorPages", new StringContent(string.Empty)); + response.EnsureSuccessStatusCode(); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPageExecutionInstrumentationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPageExecutionInstrumentationTest.cs deleted file mode 100644 index b6e87cf1a5..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPageExecutionInstrumentationTest.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System.Net.Http; -using System.Reflection; -using System.Threading.Tasks; -using RazorPageExecutionInstrumentationWebSite; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.FunctionalTests -{ - public class RazorPageExecutionInstrumentationTest : IClassFixture> - { - private static readonly Assembly _resourcesAssembly = - typeof(RazorPageExecutionInstrumentationTest).GetTypeInfo().Assembly; - - public RazorPageExecutionInstrumentationTest(MvcTestFixture fixture) - { - Client = fixture.CreateDefaultClient(); - } - - public HttpClient Client { get; } - - [Fact] - public async Task InstrumentedViews_RenderAsExpected() - { - // Arrange - var outputFile = "compiler/resources/RazorPageExecutionInstrumentationWebSite.Home.ViewWithPartial.html"; - var expectedContent = - await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile, sourceFile: false); - - // Act - var content = await Client.GetStringAsync("http://localhost/Home/ViewWithPartial"); - - // Assert -#if GENERATE_BASELINES - ResourceFile.UpdateFile(_resourcesAssembly, outputFile, expectedContent, content); -#else - Assert.Equal(expectedContent, content, ignoreLineEndingDifferences: true); -#endif - } - } -} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs index d97e128727..ab9c81bfad 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs @@ -11,7 +11,6 @@ using System.Net.Http.Headers; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Testing; using Newtonsoft.Json.Linq; @@ -144,6 +143,26 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("CustomActionResult", content); } + [Fact] + public async Task Page_Handler_ReturnPartialWithoutModel() + { + // Act + var document = await Client.GetHtmlDocumentAsync("RenderPartialWithoutModel"); + + var element = document.RequiredQuerySelector("#content"); + Assert.Equal("Welcome, Guest", element.TextContent); + } + + [Fact] + public async Task Page_Handler_ReturnPartialWithModel() + { + // Act + var document = await Client.GetHtmlDocumentAsync("RenderPartialWithModel"); + + var element = document.RequiredQuerySelector("#content"); + Assert.Equal("Welcome, Admin", element.TextContent); + } + [Fact] public async Task Page_Handler_AsyncReturnTypeImplementsIActionResult() { @@ -900,7 +919,7 @@ Hello from /Pages/WithViewStart/Index.cshtml!"; { // Arrange var expected = -@"Microsoft.AspNetCore.Mvc.Routing.UrlHelper +@"Microsoft.AspNetCore.Mvc.Routing.EndpointRoutingUrlHelper Microsoft.AspNetCore.Mvc.ViewFeatures.HtmlHelper`1[AspNetCore.InjectedPageProperties] Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary`1[AspNetCore.InjectedPageProperties]"; @@ -1403,7 +1422,7 @@ Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary`1[AspNetCore.InjectedPa } [Fact] - public async Task ViewDataAvaialableInPageFilter_AfterHandlerMethod_ReturnsPageResult() + public async Task ViewDataAwaitableInPageFilter_AfterHandlerMethod_ReturnsPageResult() { // Act var content = await Client.GetStringAsync("http://localhost/Pages/ViewDataAvailableAfterHandlerExecuted"); @@ -1412,6 +1431,54 @@ Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary`1[AspNetCore.InjectedPa Assert.Equal("ViewData: Bar", content); } + [Fact] + public async Task OptionsRequest_WithoutHandler_Returns200_WithoutExecutingPage() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Options, "http://localhost/HelloWorld"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Empty(content.Trim()); + } + + [Fact] + public async Task PageWithOptionsHandler_ExecutesGetRequest() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/HelloWorldWithOptionsHandler"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello from OnGet!", content.Trim()); + } + + [Fact] + public async Task PageWithOptionsHandler_ExecutesOptionsRequest() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Options, "http://localhost/HelloWorldWithOptionsHandler"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello from OnOptions!", content.Trim()); + } + private async Task AddAntiforgeryHeaders(HttpRequestMessage request) { var getResponse = await Client.GetAsync(request.RequestUri); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs index 680af29004..4a11189cb2 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs @@ -330,7 +330,7 @@ Hello from page"; var response = await Client.GetStringAsync("/Accounts/PageWithLinks"); // Assert - Assert.Equal(expected, response.Trim()); + Assert.Equal(expected, response.Trim(), ignoreLineEndingDifferences: true); } [Fact] @@ -346,7 +346,7 @@ Hello from page"; var response = await Client.GetStringAsync("/Accounts/RelativeLinks"); // Assert - Assert.Equal(expected, response.Trim()); + Assert.Equal(expected, response.Trim(), ignoreLineEndingDifferences: true); } [Fact] @@ -369,7 +369,7 @@ Hello from /Pages/Shared/"; var response = await Client.GetStringAsync("/Accounts/Manage/RenderPartials"); // Assert - Assert.Equal(expected, response.Trim()); + Assert.Equal(expected, response.Trim(), ignoreLineEndingDifferences: true); } [Fact] @@ -384,7 +384,7 @@ Hello from /Pages/Shared/"; } [Fact] - public async Task AllowAnonymouseToPageConvention_CanBeAppliedToAreaPages() + public async Task AllowAnonymousToPageConvention_CanBeAppliedToAreaPages() { // Act var response = await Client.GetStringAsync("/Accounts/RequiresAuth/AllowAnonymous"); @@ -395,7 +395,7 @@ Hello from /Pages/Shared/"; // These test is important as it covers a feature that allows razor pages to use a different // model at runtime that wasn't known at compile time. Like a non-generic model used at compile - // time and overrided at runtime with a closed-generic model that performs the actual implementation. + // time and overriden at runtime with a closed-generic model that performs the actual implementation. // An example of this is how the Identity UI library defines a base page model in their views, // like how the Register.cshtml view defines its model as RegisterModel and then, at runtime it replaces // that model with RegisterModel where TUser is the type of the user used to configure identity. @@ -419,14 +419,16 @@ Hello from /Pages/Shared/"; var token = AntiforgeryTestHelper.RetrieveAntiforgeryToken(await getPage.Content.ReadAsStringAsync(), ""); var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(getPage); - var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel"); - message.Content = new FormUrlEncodedContent(new Dictionary + var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel") { - ["__RequestVerificationToken"] = token, - ["ConfirmPassword"] = "", - ["Password"] = "", - ["Email"] = "" - }); + Content = new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["ConfirmPassword"] = "", + ["Password"] = "", + ["Email"] = "" + }) + }; message.Headers.TryAddWithoutValidation("Cookie", $"{cookie.Key}={cookie.Value}"); // Act @@ -442,18 +444,20 @@ Hello from /Pages/Shared/"; public async Task PageConventions_CustomizedModelCanWorkWithModelState() { // Arrange - var getPage = await Client.GetAsync("/CustomModelTypeModel"); + var getPage = await Client.GetAsync("/CustomModelTypeModel?Attempts=0"); var token = AntiforgeryTestHelper.RetrieveAntiforgeryToken(await getPage.Content.ReadAsStringAsync(), ""); var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(getPage); - var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel"); - message.Content = new FormUrlEncodedContent(new Dictionary + var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel?Attempts=3") { - ["__RequestVerificationToken"] = token, - ["Email"] = "javi@example.com", - ["Password"] = "Password.12$", - ["ConfirmPassword"] = "Password.12$", - }); + Content = new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["Email"] = "javi@example.com", + ["Password"] = "Password.12$", + ["ConfirmPassword"] = "Password.12$", + }) + }; message.Headers.TryAddWithoutValidation("Cookie", $"{cookie.Key}={cookie.Value}"); // Act @@ -464,6 +468,37 @@ Hello from /Pages/Shared/"; Assert.Equal("/", response.Headers.Location.ToString()); } + [Fact] + public async Task PageConventions_CustomizedModelCanWorkWithModelState_EnforcesBindRequired() + { + // Arrange + var getPage = await Client.GetAsync("/CustomModelTypeModel?Attempts=0"); + var token = AntiforgeryTestHelper.RetrieveAntiforgeryToken(await getPage.Content.ReadAsStringAsync(), ""); + var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(getPage); + + var message = new HttpRequestMessage(HttpMethod.Post, "/CustomModelTypeModel") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["Email"] = "javi@example.com", + ["Password"] = "Password.12$", + ["ConfirmPassword"] = "Password.12$", + }) + }; + message.Headers.TryAddWithoutValidation("Cookie", $"{cookie.Key}={cookie.Value}"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseText = await response.Content.ReadAsStringAsync(); + Assert.Contains( + "A value for the 'Attempts' parameter or property was not provided.", + responseText); + } + [Fact] public async Task ValidationAttributes_OnTopLevelProperties() { @@ -506,7 +541,7 @@ Hello from /Pages/Shared/"; } [Fact] - public async Task ViewDataAttributes_SetInPageModel_AreTransferedToLayout() + public async Task ViewDataAttributes_SetInPageModel_AreTransferredToLayout() { // Arrange var document = await Client.GetHtmlDocumentAsync("/ViewData/ViewDataInPage"); @@ -526,7 +561,7 @@ Hello from /Pages/Shared/"; } [Fact] - public async Task ViewDataAttributes_SetInPageWithoutModel_AreTransferedToLayout() + public async Task ViewDataAttributes_SetInPageWithoutModel_AreTransferredToLayout() { // Arrange var document = await Client.GetHtmlDocumentAsync("/ViewData/ViewDataInPageWithoutModel"); @@ -552,5 +587,125 @@ Hello from /Pages/Shared/"; var title = document.QuerySelector("title").TextContent; Assert.Equal("View Data in Pages", title); } + + [Fact] + public async Task Antiforgery_RequestWithoutAntiforgeryToken_Returns200ForHeadRequests() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Head, "/Antiforgery/AntiforgeryDefault"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Antiforgery_RequestWithoutAntiforgeryToken_Returns400BadRequest() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "/Antiforgery/AntiforgeryDefault"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Antiforgery_RequestWithAntiforgeryToken_Succeeds() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "/Antiforgery/AntiforgeryDefault"); + await AddAntiforgeryHeadersAsync(request); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Antiforgery_IgnoreAntiforgeryTokenAppliedToModelWorks() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "/Antiforgery/IgnoreAntiforgery"); + await AddAntiforgeryHeadersAsync(request); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ViewDataSetInViewStart_IsAvailableToPage() + { + // Arrange & Act + var document = await Client.GetHtmlDocumentAsync("/ViewData/ViewDataSetInViewStart"); + + // Assert + var valueSetInViewStart = document.RequiredQuerySelector("#valuefromviewstart").TextContent; + var valueSetInPageModel = document.RequiredQuerySelector("#valuefrompagemodel").TextContent; + var valueSetInPage = document.RequiredQuerySelector("#valuefrompage").TextContent; + + Assert.Equal("Value from _ViewStart", valueSetInViewStart); + Assert.Equal("Value from Page Model", valueSetInPageModel); + Assert.Equal("Value from Page", valueSetInPage); + } + + [Fact] + public async Task RoundTrippingFormFileInputWorks() + { + // Arrange + var url = "/PropertyBinding/BindFormFile"; + var response = await Client.GetAsync(url); + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + + var document = await response.GetHtmlDocumentAsync(); + + var property1 = document.RequiredQuerySelector("#property1").GetAttribute("name"); + var file1 = document.RequiredQuerySelector("#file1").GetAttribute("name"); + var file2 = document.RequiredQuerySelector("#file2").GetAttribute("name"); + var file3 = document.RequiredQuerySelector("#file3").GetAttribute("name"); + var antiforgeryToken = document.RetrieveAntiforgeryToken(); + + var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(response); + + var content = new MultipartFormDataContent + { + { new StringContent("property1-value"), property1 }, + { new StringContent("test-value1"), file1, "test1.txt" }, + { new StringContent("test-value2"), file3, "test2.txt" } + }; + + var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = content, + }; + request.Headers.Add("Cookie", cookie.Key + "=" + cookie.Value); + request.Headers.Add("RequestVerificationToken", antiforgeryToken); + + response = await Client.SendAsync(request); + + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + } + + private async Task AddAntiforgeryHeadersAsync(HttpRequestMessage request) + { + var response = await Client.GetAsync(request.RequestUri); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var responseBody = await response.Content.ReadAsStringAsync(); + var formToken = AntiforgeryTestHelper.RetrieveAntiforgeryToken(responseBody); + var cookie = AntiforgeryTestHelper.RetrieveAntiforgeryCookie(response); + + request.Headers.Add("Cookie", cookie.Key + "=" + cookie.Value); + request.Headers.Add("RequestVerificationToken", formToken); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RemoteAttributeValidationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RemoteAttributeValidationTest.cs index 5e1919c462..8d5064bd49 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RemoteAttributeValidationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RemoteAttributeValidationTest.cs @@ -45,9 +45,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests #if GENERATE_BASELINES ResourceFile.UpdateFile(_resourcesAssembly, outputFile, expectedContent, responseContent); #else - // Mono issue - https://github.com/aspnet/External/issues/19 Assert.Equal( - PlatformNormalizer.NormalizeContent(expectedContent), + expectedContent, responseContent, ignoreLineEndingDifferences: true); #endif diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesEndpointRoutingTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesEndpointRoutingTest.cs new file mode 100644 index 0000000000..113cfde4dc --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesEndpointRoutingTest.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 System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class RequestServicesEndpointRoutingTest : RequestServicesTestBase + { + public RequestServicesEndpointRoutingTest(MvcTestFixture fixture) + : base(fixture) + { + } + + [Fact] + public async override Task HasEndpointMatch() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Routing/HasEndpointMatch"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.True(result); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesTest.cs index 29fd69112c..73c0e5af15 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesTest.cs @@ -1,93 +1,33 @@ // 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.Net; -using System.Net.Http; using System.Threading.Tasks; +using Newtonsoft.Json; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { - // Each of these tests makes two requests, because we want each test to verify that the data is - // PER-REQUEST and does not linger around to impact the next request. - public class RequestServicesTest : IClassFixture> + public class RequestServicesTest : RequestServicesTestBase { public RequestServicesTest(MvcTestFixture fixture) + : base(fixture) { - Client = fixture.CreateDefaultClient(); - } - - public HttpClient Client { get; } - - [Theory] - [InlineData("http://localhost/RequestScopedService/FromFilter")] - [InlineData("http://localhost/RequestScopedService/FromView")] - [InlineData("http://localhost/RequestScopedService/FromViewComponent")] - [InlineData("http://localhost/RequestScopedService/FromActionArgument")] - public async Task RequestServices(string url) - { - for (var i = 0; i < 2; i++) - { - // Arrange - var requestId = Guid.NewGuid().ToString(); - var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.TryAddWithoutValidation("RequestId", requestId); - - // Act - var response = await Client.SendAsync(request); - - // Assert - response.EnsureSuccessStatusCode(); - var body = (await response.Content.ReadAsStringAsync()).Trim(); - Assert.Equal(requestId, body); - } } [Fact] - public async Task RequestServices_TagHelper() + public async override Task HasEndpointMatch() { - // Arrange - var url = "http://localhost/RequestScopedService/FromTagHelper"; + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Routing/HasEndpointMatch"); - // Act & Assert - for (var i = 0; i < 2; i++) - { - var requestId = Guid.NewGuid().ToString(); - var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.TryAddWithoutValidation("RequestId", requestId); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var response = await Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); - var body = (await response.Content.ReadAsStringAsync()).Trim(); - - var expected = "" + requestId + ""; - Assert.Equal(expected, body); - } - } - - [Fact] - public async Task RequestServices_ActionConstraint() - { - // Arrange - var url = "http://localhost/RequestScopedService/FromActionConstraint"; - - // Act & Assert - var requestId1 = "b40f6ec1-8a6b-41c1-b3fe-928f581ebaf5"; - var request1 = new HttpRequestMessage(HttpMethod.Get, url); - request1.Headers.TryAddWithoutValidation("RequestId", requestId1); - - var response1 = await Client.SendAsync(request1); - - var body1 = (await response1.Content.ReadAsStringAsync()).Trim(); - Assert.Equal(requestId1, body1); - - var requestId2 = Guid.NewGuid().ToString(); - var request2 = new HttpRequestMessage(HttpMethod.Get, url); - request2.Headers.TryAddWithoutValidation("RequestId", requestId2); - - var response2 = await Client.SendAsync(request2); - Assert.Equal(HttpStatusCode.NotFound, response2.StatusCode); + Assert.False(result); } } } \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesTestBase.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesTestBase.cs new file mode 100644 index 0000000000..cc4e06ba74 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RequestServicesTestBase.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; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + // Each of these tests makes two requests, because we want each test to verify that the data is + // PER-REQUEST and does not linger around to impact the next request. + public abstract class RequestServicesTestBase : IClassFixture> where TStartup : class + { + protected RequestServicesTestBase(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public abstract Task HasEndpointMatch(); + + [Theory] + [InlineData("http://localhost/RequestScopedService/FromFilter")] + [InlineData("http://localhost/RequestScopedService/FromView")] + [InlineData("http://localhost/RequestScopedService/FromViewComponent")] + [InlineData("http://localhost/RequestScopedService/FromActionArgument")] + public async Task RequestServices(string url) + { + for (var i = 0; i < 2; i++) + { + // Arrange + var requestId = Guid.NewGuid().ToString(); + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("RequestId", requestId); + + // Act + var response = await Client.SendAsync(request); + + // Assert + response.EnsureSuccessStatusCode(); + var body = (await response.Content.ReadAsStringAsync()).Trim(); + Assert.Equal(requestId, body); + } + } + + [Fact] + public async Task RequestServices_TagHelper() + { + // Arrange + var url = "http://localhost/RequestScopedService/FromTagHelper"; + + // Act & Assert + for (var i = 0; i < 2; i++) + { + var requestId = Guid.NewGuid().ToString(); + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("RequestId", requestId); + + var response = await Client.SendAsync(request); + + var body = (await response.Content.ReadAsStringAsync()).Trim(); + + var expected = "" + requestId + ""; + Assert.Equal(expected, body); + } + } + + [Fact] + public async Task RequestServices_Constraint() + { + // Arrange + var url = "http://localhost/RequestScopedService/FromConstraint"; + + // Act & Assert + var requestId1 = "b40f6ec1-8a6b-41c1-b3fe-928f581ebaf5"; + var request1 = new HttpRequestMessage(HttpMethod.Get, url); + request1.Headers.TryAddWithoutValidation("RequestId", requestId1); + + var response1 = await Client.SendAsync(request1); + + var body1 = (await response1.Content.ReadAsStringAsync()).Trim(); + Assert.Equal(requestId1, body1); + + var requestId2 = Guid.NewGuid().ToString(); + var request2 = new HttpRequestMessage(HttpMethod.Get, url); + request2.Headers.TryAddWithoutValidation("RequestId", requestId2); + + var response2 = await Client.SendAsync(request2); + Assert.Equal(HttpStatusCode.NotFound, response2.StatusCode); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RouteDataTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RouteDataTest.cs deleted file mode 100644 index 3ea689b1a4..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RouteDataTest.cs +++ /dev/null @@ -1,103 +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.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Internal; -using Microsoft.AspNetCore.Routing; -using Newtonsoft.Json; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.FunctionalTests -{ - public class RouteDataTest : IClassFixture> - { - public RouteDataTest(MvcTestFixture fixture) - { - Client = fixture.CreateDefaultClient(); - } - - public HttpClient Client { get; } - - [Fact] - public async Task RouteData_Routers_ConventionalRoute() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Routing/Conventional"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal( - new string[] - { - typeof(RouteCollection).FullName, - typeof(Route).FullName, - typeof(MvcRouteHandler).FullName, - }, - result.Routers); - } - - [Fact] - public async Task RouteData_Routers_AttributeRoute() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Routing/Attribute"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal(new string[] - { - typeof(RouteCollection).FullName, - typeof(AttributeRoute).FullName, - typeof(MvcAttributeRouteHandler).FullName, - }, - result.Routers); - } - - // Verifies that components in the MVC pipeline can modify datatokens - // without impacting any static data. - // - // This does two request, to verify that the data in the route is not modified - [Fact] - public async Task RouteData_DataTokens_FilterCanSetDataTokens() - { - // Arrange - var response = await Client.GetAsync("http://localhost/Routing/DataTokens"); - - // Guard - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - Assert.Single(result.DataTokens); - Assert.Single(result.DataTokens, kvp => kvp.Key == "actionName" && ((string)kvp.Value) == "DataTokens"); - - // Act - response = await Client.GetAsync("http://localhost/Routing/Conventional"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - body = await response.Content.ReadAsStringAsync(); - result = JsonConvert.DeserializeObject(body); - - Assert.Single(result.DataTokens); - Assert.Single(result.DataTokens, kvp => kvp.Key == "actionName" && ((string)kvp.Value) == "Conventional"); - } - - private class ResultData - { - public Dictionary DataTokens { get; set; } - - public string[] Routers { get; set; } - } - } -} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingEndpointRoutingTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingEndpointRoutingTest.cs new file mode 100644 index 0000000000..07512b9b01 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingEndpointRoutingTest.cs @@ -0,0 +1,386 @@ +// 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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class RoutingEndpointRoutingTest : RoutingTestsBase + { + public RoutingEndpointRoutingTest(MvcTestFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task AttributeRoutedAction_ContainsPage_RouteMatched() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/PageRoute/Attribute/pagevalue"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/PageRoute/Attribute/pagevalue", result.ExpectedUrls); + Assert.Equal("PageRoute", result.Controller); + Assert.Equal("AttributeRoute", result.Action); + + Assert.Contains( + new KeyValuePair("page", "pagevalue"), + result.RouteValues); + } + + [Fact] + public async Task ParameterTransformer_TokenReplacement_Found() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/parameter-transformer/my-action"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("ParameterTransformer", result.Controller); + Assert.Equal("MyAction", result.Action); + } + + [Fact] + public async Task ParameterTransformer_TokenReplacement_NotFound() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/ParameterTransformer/MyAction"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task AttributeRoutedAction_Parameters_Found() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/EndpointRouting/Index"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("EndpointRouting", result.Controller); + Assert.Equal("Index", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_Parameters_DefaultValue_Found() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/EndpointRouting"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("EndpointRouting", result.Controller); + Assert.Equal("Index", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_ParameterTransformer_Found() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/endpoint-routing/ParameterTransformer"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("EndpointRouting", result.Controller); + Assert.Equal("ParameterTransformer", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_ParameterTransformer_NotFound() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/EndpointRouting/ParameterTransformer"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task AttributeRoutedAction_ParameterTransformer_LinkToSelf() + { + // Arrange + var url = LinkFrom("http://localhost/endpoint-routing/ParameterTransformer").To(new { }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("EndpointRouting", result.Controller); + Assert.Equal("ParameterTransformer", result.Action); + + Assert.Equal("/endpoint-routing/ParameterTransformer", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_ParameterTransformer_LinkWithAmbientController() + { + // Arrange + var url = LinkFrom("http://localhost/endpoint-routing/ParameterTransformer").To(new { action = "Get", id = 5 }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("EndpointRouting", result.Controller); + Assert.Equal("ParameterTransformer", result.Action); + + Assert.Equal("/endpoint-routing/5", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_ParameterTransformer_LinkToAttributeRoutedController() + { + // Arrange + var url = LinkFrom("http://localhost/endpoint-routing/ParameterTransformer").To(new { action = "ShowPosts", controller = "Blog" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("EndpointRouting", result.Controller); + Assert.Equal("ParameterTransformer", result.Action); + + Assert.Equal("/Blog/ShowPosts", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_ParameterTransformer_LinkToConventionalController() + { + // Arrange + var url = LinkFrom("http://localhost/endpoint-routing/ParameterTransformer").To(new { action = "Index", controller = "Home" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("EndpointRouting", result.Controller); + Assert.Equal("ParameterTransformer", result.Action); + + Assert.Equal("/", result.Link); + } + + [Fact] + public async override Task HasEndpointMatch() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Routing/HasEndpointMatch"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.True(result); + } + + [Fact] + public async override Task RouteData_Routers_ConventionalRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/RouteData/Conventional"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal( + Array.Empty(), + result.Routers); + } + + [Fact] + public async override Task RouteData_Routers_AttributeRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/RouteData/Attribute"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal( + Array.Empty(), + result.Routers); + } + + // Endpoint routing exposes HTTP 405s for HTTP method mismatches + [Fact] + public override async Task ConventionalRoutedController_InArea_ActionBlockedByHttpMethod() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Travel/Flight/BuyTickets"); + + // Assert + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + + [Fact] + public async Task ConventionalRoutedAction_ParameterTransformer() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/ConventionalTransformerRoute/conventional-transformer/Index"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("ConventionalTransformer", result.Controller); + Assert.Equal("Index", result.Action); + } + + [Fact] + public async Task ConventionalRoutedAction_ParameterTransformer_NotFound() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/ConventionalTransformerRoute/ConventionalTransformer/Index"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ConventionalRoutedAction_ParameterTransformer_DefaultValue() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/ConventionalTransformerRoute/conventional-transformer"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("ConventionalTransformer", result.Controller); + Assert.Equal("Index", result.Action); + } + + [Fact] + public async Task ConventionalRoutedAction_ParameterTransformer_WithParam() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/ConventionalTransformerRoute/conventional-transformer/Param/my-value"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("ConventionalTransformer", result.Controller); + Assert.Equal("Param", result.Action); + + Assert.Equal("/ConventionalTransformerRoute/conventional-transformer/Param/my-value", Assert.Single(result.ExpectedUrls)); + } + + [Fact] + public async Task ConventionalRoutedAction_ParameterTransformer_LinkToConventionalController() + { + // Arrange + var url = LinkFrom("http://localhost/ConventionalTransformerRoute/conventional-transformer/Index").To(new { action = "Index", controller = "Home" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("ConventionalTransformer", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal("/", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_ParameterTransformer_LinkToConventionalControllerWithParam() + { + // Arrange + var url = LinkFrom("http://localhost/ConventionalTransformerRoute/conventional-transformer/Index").To(new { action = "Param", controller = "ConventionalTransformer", param = "MyValue" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("ConventionalTransformer", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal("/ConventionalTransformerRoute/conventional-transformer/Param/my-value", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_ParameterTransformer_LinkToSelf() + { + // Arrange + var url = LinkFrom("http://localhost/ConventionalTransformerRoute/conventional-transformer/Index").To(new {}); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("ConventionalTransformer", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal("/ConventionalTransformerRoute/conventional-transformer", result.Link); + } + + // Endpoint routing exposes HTTP 405s for HTTP method mismatches. + protected override void AssertCorsRejectionStatusCode(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingEndpointRoutingWithoutRazorPagesTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingEndpointRoutingWithoutRazorPagesTests.cs new file mode 100644 index 0000000000..8b597e0ab8 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingEndpointRoutingWithoutRazorPagesTests.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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class RoutingEndpointRoutingWithoutRazorPagesTests : RoutingWithoutRazorPagesTestsBase + { + public RoutingEndpointRoutingWithoutRazorPagesTests(MvcTestFixture fixture) + : base(fixture) + { + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTests.cs index e2917d8f1e..538d06358c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTests.cs @@ -1,1031 +1,39 @@ // 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 System.Net; using System.Net.Http; using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Routing; using Newtonsoft.Json; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { - public class RoutingTests : IClassFixture> + public class RoutingTests : RoutingTestsBase { - public RoutingTests(MvcTestFixture fixture) + public RoutingTests(MvcTestFixture fixture) + : base(fixture) { - Client = fixture.CreateDefaultClient(); } - public HttpClient Client { get; } - [Fact] - public async Task ConventionalRoutedController_ActionIsReachable() + public async override Task HasEndpointMatch() { // Arrange & Act - var response = await Client.GetAsync("http://localhost/Home/Index"); + var response = await Client.GetAsync("http://localhost/Routing/HasEndpointMatch"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); + var result = JsonConvert.DeserializeObject(body); - Assert.Contains("/Home/Index", result.ExpectedUrls); - Assert.Equal("Home", result.Controller); - Assert.Equal("Index", result.Action); - Assert.Equal( - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "controller", "Home" }, - { "action", "Index" }, - }, - result.RouteValues); - } - - [Fact] - public async Task ConventionalRoutedController_ActionIsReachable_WithDefaults() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/", result.ExpectedUrls); - Assert.Equal("Home", result.Controller); - Assert.Equal("Index", result.Action); - Assert.Equal( - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "controller", "Home" }, - { "action", "Index" }, - }, - result.RouteValues); - } - - [Fact] - public async Task ConventionalRoutedController_NonActionIsNotReachable() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Home/NotAnAction"); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task ConventionalRoutedController_InArea_ActionIsReachable() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Travel/Flight/Index"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Travel/Flight/Index", result.ExpectedUrls); - Assert.Equal("Flight", result.Controller); - Assert.Equal("Index", result.Action); - Assert.Equal( - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "area", "Travel" }, - { "controller", "Flight" }, - { "action", "Index" }, - }, - result.RouteValues); - } - - [Fact] - public async Task ConventionalRoutedController_InArea_ActionBlockedByHttpMethod() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Travel/Flight/BuyTickets"); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Theory] - [InlineData("", "/Home/OptionalPath/default")] - [InlineData("CustomPath", "/Home/OptionalPath/CustomPath")] - public async Task ConventionalRoutedController_WithOptionalSegment(string optionalSegment, string expected) - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Home/OptionalPath/" + optionalSegment); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Single(result.ExpectedUrls, expected); - } - - [Fact] - public async Task AttributeRoutedAction_IsReachable() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Store/Shop/Products"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Store/Shop/Products", result.ExpectedUrls); - Assert.Equal("Store", result.Controller); - Assert.Equal("ListProducts", result.Action); - - Assert.Contains( - new KeyValuePair("controller", "Store"), - result.RouteValues); - - Assert.Contains( - new KeyValuePair("action", "ListProducts"), - result.RouteValues); - } - - [Theory] - [InlineData("Get", "/Friends")] - [InlineData("Get", "/Friends/Peter")] - [InlineData("Delete", "/Friends")] - public async Task AttributeRoutedAction_AcceptRequestsWithValidMethods_InRoutesWithoutExtraTemplateSegmentsOnTheAction( - string method, - string url) - { - // Arrange - var request = new HttpRequestMessage(new HttpMethod(method), $"http://localhost{url}"); - - // Assert - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains(url, result.ExpectedUrls); - Assert.Equal("Friends", result.Controller); - Assert.Equal(method, result.Action); - - Assert.Contains( - new KeyValuePair("controller", "Friends"), - result.RouteValues); - - Assert.Contains( - new KeyValuePair("action", method), - result.RouteValues); - - if (result.RouteValues.ContainsKey("id")) - { - Assert.Contains( - new KeyValuePair("id", "Peter"), - result.RouteValues); - } - } - - [Theory] - [InlineData("Post", "/Friends")] - [InlineData("Put", "/Friends")] - [InlineData("Patch", "/Friends")] - [InlineData("Options", "/Friends")] - [InlineData("Head", "/Friends")] - public async Task AttributeRoutedAction_RejectsRequestsWithWrongMethods_InRoutesWithoutExtraTemplateSegmentsOnTheAction( - string method, - string url) - { - // Arrange - var request = new HttpRequestMessage(new HttpMethod(method), $"http://localhost{url}"); - - // Assert - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Theory] - [InlineData("http://localhost/api/v1/Maps")] - [InlineData("http://localhost/api/v2/Maps")] - public async Task AttributeRoutedAction_MultipleRouteAttributes_WorksWithNameAndOrder(string url) - { - // Arrange & Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Maps", result.Controller); - Assert.Equal("Get", result.Action); - - Assert.Equal(new string[] - { - "/api/v2/Maps", - "/api/v1/Maps", - "/api/v2/Maps" - }, - result.ExpectedUrls); - } - - [Fact] - public async Task AttributeRoutedAction_MultipleRouteAttributes_WorksWithOverrideRoutes() - { - // Arrange - var url = "http://localhost/api/v2/Maps"; - - // Act - var response = await Client.SendAsync(new HttpRequestMessage(HttpMethod.Post, url)); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Maps", result.Controller); - Assert.Equal("Post", result.Action); - - Assert.Equal(new string[] - { - "/api/v2/Maps", - "/api/v2/Maps" - }, - result.ExpectedUrls); - } - - [Fact] - public async Task AttributeRoutedAction_MultipleRouteAttributes_RouteAttributeTemplatesIgnoredForOverrideActions() - { - // Arrange - var url = "http://localhost/api/v1/Maps"; - - // Act - var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod("POST"), url)); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Theory] - [InlineData("http://localhost/api/v1/Maps/5", "PUT")] - [InlineData("http://localhost/api/v2/Maps/5", "PUT")] - [InlineData("http://localhost/api/v1/Maps/PartialUpdate/5", "PATCH")] - [InlineData("http://localhost/api/v2/Maps/PartialUpdate/5", "PATCH")] - public async Task AttributeRoutedAction_MultipleRouteAttributes_CombinesWithMultipleHttpAttributes( - string url, - string method) - { - // Arrange & Act - var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod(method), url)); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Maps", result.Controller); - Assert.Equal("Update", result.Action); - - Assert.Equal(new string[] - { - "/api/v2/Maps/PartialUpdate/5", - "/api/v2/Maps/PartialUpdate/5" - }, - result.ExpectedUrls); - } - - [Theory] - [InlineData("http://localhost/Banks/Get/5")] - [InlineData("http://localhost/Bank/Get/5")] - public async Task AttributeRoutedAction_MultipleHttpAttributesAndTokenReplacement(string url) - { - // Arrange - var expectedUrl = new Uri(url).AbsolutePath; - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Banks", result.Controller); - Assert.Equal("Get", result.Action); - - Assert.Equal(new string[] - { - "/Bank/Get/5", - "/Bank/Get/5" - }, - result.ExpectedUrls); - } - - [Theory] - [InlineData("http://localhost/api/v1/Maps/5", "PATCH")] - [InlineData("http://localhost/api/v2/Maps/5", "PATCH")] - [InlineData("http://localhost/api/v1/Maps/PartialUpdate/5", "PUT")] - [InlineData("http://localhost/api/v2/Maps/PartialUpdate/5", "PUT")] - public async Task AttributeRoutedAction_MultipleRouteAttributes_WithMultipleHttpAttributes_RespectsConstraints( - string url, - string method) - { - // Arrange - var expectedUrl = new Uri(url).AbsolutePath; - - // Act - var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod(method), url)); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - // The url would be /Store/ListProducts with conventional routes - [Fact] - public async Task AttributeRoutedAction_IsNotReachableWithTraditionalRoute() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Store/ListProducts"); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - // There's two actions at this URL - but attribute routes go in the route table - // first. - [Fact] - public async Task AttributeRoutedAction_TriedBeforeConventionalRouting() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Home/About"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Home/About", result.ExpectedUrls); - Assert.Equal("Store", result.Controller); - Assert.Equal("About", result.Action); - } - - [Fact] - public async Task AttributeRoutedAction_ControllerLevelRoute_WithActionParameter_IsReachable() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Blog/Edit/5"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Blog/Edit/5", result.ExpectedUrls); - Assert.Equal("Blog", result.Controller); - Assert.Equal("Edit", result.Action); - - Assert.Contains( - new KeyValuePair("controller", "Blog"), - result.RouteValues); - - Assert.Contains( - new KeyValuePair("action", "Edit"), - result.RouteValues); - - Assert.Contains( - new KeyValuePair("postId", "5"), - result.RouteValues); - } - - // There's no [HttpGet] on the action here. - [Fact] - public async Task AttributeRoutedAction_ControllerLevelRoute_IsReachable() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/api/Employee"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/api/Employee", result.ExpectedUrls); - Assert.Equal("Employee", result.Controller); - Assert.Equal("List", result.Action); - } - - // We are intentionally skipping GET because we have another method with [HttpGet] on the same controller - // and a test that verifies that if you define another action with a specific verb we'll route to that - // more specific action. - [Theory] - [InlineData("PUT")] - [InlineData("POST")] - [InlineData("PATCH")] - [InlineData("DELETE")] - public async Task AttributeRoutedAction_RouteAttributeOnAction_IsReachable(string method) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Store/Shop/Orders"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Store/Shop/Orders", result.ExpectedUrls); - Assert.Equal("Store", result.Controller); - Assert.Equal("Orders", result.Action); - } - - [Theory] - [InlineData("GET")] - [InlineData("POST")] - [InlineData("PUT")] - [InlineData("PATCH")] - [InlineData("DELETE")] - public async Task AttributeRoutedAction_RouteAttributeOnActionAndController_IsReachable(string method) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/api/Employee/5/Salary"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/api/Employee/5/Salary", result.ExpectedUrls); - Assert.Equal("Employee", result.Controller); - Assert.Equal("Salary", result.Action); - } - - [Fact] - public async Task AttributeRoutedAction_RouteAttributeOnActionAndHttpGetOnDifferentAction_ReachesHttpGetAction() - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Store/Shop/Orders"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Store/Shop/Orders", result.ExpectedUrls); - Assert.Equal("Store", result.Controller); - Assert.Equal("GetOrders", result.Action); - } - - // There's no [HttpGet] on the action here. - [Theory] - [InlineData("PUT")] - [InlineData("PATCH")] - public async Task AttributeRoutedAction_ControllerLevelRoute_WithAcceptVerbs_IsReachable(string verb) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/api/Employee", result.ExpectedUrls); - Assert.Equal("Employee", result.Controller); - Assert.Equal("UpdateEmployee", result.Action); - } - - [Theory] - [InlineData("PUT")] - [InlineData("PATCH")] - public async Task AttributeRoutedAction_ControllerLevelRoute_WithAcceptVerbsAndRouteTemplate_IsReachable(string verb) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee/Manager"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/api/Employee/Manager", result.ExpectedUrls); - Assert.Equal("Employee", result.Controller); - Assert.Equal("UpdateManager", result.Action); - } - - [Theory] - [InlineData("PUT", "Bank")] - [InlineData("PATCH", "Bank")] - [InlineData("PUT", "Bank/Update")] - [InlineData("PATCH", "Bank/Update")] - public async Task AttributeRoutedAction_AcceptVerbsAndRouteTemplate_IsReachable(string verb, string path) - { - // Arrange - var expectedUrl = "/Bank/Update"; - var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/" + path); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal(new string[] { expectedUrl, expectedUrl }, result.ExpectedUrls); - Assert.Equal("Banks", result.Controller); - Assert.Equal("UpdateBank", result.Action); - } - - [Fact] - public async Task AttributeRoutedAction_WithCustomHttpAttributes_IsReachable() - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod("MERGE"), "http://localhost/api/Employee/5"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/api/Employee/5", result.ExpectedUrls); - Assert.Equal("Employee", result.Controller); - Assert.Equal("MergeEmployee", result.Action); - } - - // There's an [HttpGet] with its own template on the action here. - [Theory] - [InlineData("GET", "GetAdministrator")] - [InlineData("DELETE", "DeleteAdministrator")] - public async Task AttributeRoutedAction_ControllerLevelRoute_CombinedWithActionRoute_IsReachable(string verb, string action) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee/5/Administrator"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/api/Employee/5/Administrator", result.ExpectedUrls); - Assert.Equal("Employee", result.Controller); - Assert.Equal(action, result.Action); - - Assert.Contains( - new KeyValuePair("id", "5"), - result.RouteValues); - } - - [Fact] - public async Task AttributeRoutedAction_ActionLevelRouteWithTildeSlash_OverridesControllerLevelRoute() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Manager/5"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Manager/5", result.ExpectedUrls); - Assert.Equal("Employee", result.Controller); - Assert.Equal("GetManager", result.Action); - - Assert.Contains( - new KeyValuePair("id", "5"), - result.RouteValues); - } - - [Fact] - public async Task AttributeRoutedAction_OverrideActionOverridesOrderOnController() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Team/5"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Team/5", result.ExpectedUrls); - Assert.Equal("Team", result.Controller); - Assert.Equal("GetOrganization", result.Action); - - Assert.Contains( - new KeyValuePair("teamId", "5"), - result.RouteValues); - } - - [Fact] - public async Task AttributeRoutedAction_OrderOnActionOverridesOrderOnController() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/Teams"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/Teams", result.ExpectedUrls); - Assert.Equal("Team", result.Controller); - Assert.Equal("GetOrganizations", result.Action); - } - - [Fact] - public async Task AttributeRoutedAction_LinkGeneration_OverrideActionOverridesOrderOnController() - { - // Arrange & Act - var response = await Client.GetStringAsync("http://localhost/Organization/5"); - - // Assert - Assert.NotNull(response); - Assert.Equal("/Club/5", response); - } - - [Fact] - public async Task AttributeRoutedAction_LinkGeneration_OrderOnActionOverridesOrderOnController() - { - // Arrange & Act - var response = await Client.GetStringAsync("http://localhost/Teams/AllTeams"); - - // Assert - Assert.NotNull(response); - Assert.Equal("/Teams/AllOrganizations", response); - } - - [Theory] - [InlineData("", "/TeamName/DefaultName")] - [InlineData("CustomName", "/TeamName/CustomName")] - public async Task AttributeRoutedAction_PreservesDefaultValue_IfRouteValueIsNull(string teamName, string expected) - { - // Arrange & Act - var body = await Client.GetStringAsync("http://localhost/TeamName/" + teamName); - - // Assert - Assert.NotNull(body); - var result = JsonConvert.DeserializeObject(body); - Assert.Single(result.ExpectedUrls, expected); - } - - [Fact] - public async Task AttributeRoutedAction_LinkToSelf() - { - // Arrange - var url = LinkFrom("http://localhost/api/Employee").To(new { }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Employee", result.Controller); - Assert.Equal("List", result.Action); - - Assert.Equal("/api/Employee", result.Link); - } - - [Fact] - public async Task AttributeRoutedAction_LinkWithAmbientController() - { - // Arrange - var url = LinkFrom("http://localhost/api/Employee").To(new { action = "Get", id = 5 }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Employee", result.Controller); - Assert.Equal("List", result.Action); - - Assert.Equal("/api/Employee/5", result.Link); - } - - [Fact] - public async Task AttributeRoutedAction_LinkToAttributeRoutedController() - { - // Arrange - var url = LinkFrom("http://localhost/api/Employee").To(new { action = "ShowPosts", controller = "Blog" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Employee", result.Controller); - Assert.Equal("List", result.Action); - - Assert.Equal("/Blog/ShowPosts", result.Link); - } - - [Fact] - public async Task AttributeRoutedAction_LinkToConventionalController() - { - // Arrange - var url = LinkFrom("http://localhost/api/Employee").To(new { action = "Index", controller = "Home" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Employee", result.Controller); - Assert.Equal("List", result.Action); - - Assert.Equal("/", result.Link); - } - - [Theory] - [InlineData("GET", "Get")] - [InlineData("PUT", "Put")] - public async Task AttributeRoutedAction_LinkWithName_WithNameInheritedFromControllerRoute( - string method, - string actionName) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/api/Company/5"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Company", result.Controller); - Assert.Equal(actionName, result.Action); - - Assert.Equal("/api/Company/5", result.ExpectedUrls.Single()); - Assert.Equal("Company", result.RouteName); - } - - [Fact] - public async Task AttributeRoutedAction_LinkWithName_WithNameOverrridenFromController() - { - // Arrange & Act - var response = await Client.DeleteAsync("http://localhost/api/Company/5"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Company", result.Controller); - Assert.Equal("Delete", result.Action); - - Assert.Equal("/api/Company/5", result.ExpectedUrls.Single()); - Assert.Equal("RemoveCompany", result.RouteName); - } - - [Fact] - public async Task AttributeRoutedAction_Link_WithNonEmptyActionRouteTemplateAndNoActionRouteName() - { - // Arrange - var url = LinkFrom("http://localhost") - .To(new { id = 5 }); - - // Act - var response = await Client.GetAsync("http://localhost/api/Company/5/Employees"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Company", result.Controller); - Assert.Equal("GetEmployees", result.Action); - - Assert.Equal("/api/Company/5/Employees", result.ExpectedUrls.Single()); - Assert.Null(result.RouteName); - } - - [Fact] - public async Task AttributeRoutedAction_LinkWithName_WithNonEmptyActionRouteTemplateAndActionRouteName() - { - // Arrange & Act - var response = await Client.GetAsync("http://localhost/api/Company/5/Departments"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Company", result.Controller); - Assert.Equal("GetDepartments", result.Action); - - Assert.Equal("/api/Company/5/Departments", result.ExpectedUrls.Single()); - Assert.Equal("Departments", result.RouteName); - } - - [Fact] - public async Task ConventionalRoutedAction_LinkToArea() - { - // Arrange - var url = LinkFrom("http://localhost/") - .To(new { action = "BuyTickets", controller = "Flight", area = "Travel" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Home", result.Controller); - Assert.Equal("Index", result.Action); - - Assert.Equal("/Travel/Flight/BuyTickets", result.Link); - } - - [Fact] - public async Task ConventionalRoutedAction_InArea_ImplicitLinkToArea() - { - // Arrange - var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "BuyTickets" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Flight", result.Controller); - Assert.Equal("Index", result.Action); - - Assert.Equal("/Travel/Flight/BuyTickets", result.Link); - } - - [Fact] - public async Task ConventionalRoutedAction_InArea_ExplicitLeaveArea() - { - // Arrange - var url = LinkFrom("http://localhost/Travel/Flight") - .To(new { action = "Index", controller = "Home", area = "" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Flight", result.Controller); - Assert.Equal("Index", result.Action); - - Assert.Equal("/", result.Link); - } - - [Fact] - public async Task ConventionalRoutedAction_InArea_StaysInArea() - { - // Arrange - var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "Contact", controller = "Home", }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Flight", result.Controller); - Assert.Equal("Index", result.Action); - - Assert.Equal("/Travel/Home/Contact", result.Link); - } - - [Fact] - public async Task AttributeRoutedAction_LinkToArea() - { - // Arrange - var url = LinkFrom("http://localhost/api/Employee") - .To(new { action = "Schedule", controller = "Rail", area = "Travel" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Employee", result.Controller); - Assert.Equal("List", result.Action); - - Assert.Equal("/ContosoCorp/Trains/CheckSchedule", result.Link); - } - - [Fact] - public async Task AttributeRoutedAction_InArea_ImplicitLinkToArea() - { - // Arrange - var url = LinkFrom("http://localhost/ContosoCorp/Trains/CheckSchedule").To(new { action = "Index" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Rail", result.Controller); - Assert.Equal("Schedule", result.Action); - - Assert.Equal("/ContosoCorp/Trains", result.Link); - } - - [Fact] - public async Task AttributeRoutedAction_InArea_ExplicitLeaveArea() - { - // Arrange - var url = LinkFrom("http://localhost/ContosoCorp/Trains/CheckSchedule") - .To(new { action = "Index", controller = "Home", area = "" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Rail", result.Controller); - Assert.Equal("Schedule", result.Action); - - Assert.Equal("/", result.Link); + Assert.False(result); } + // Legacy routing supports linking to actions that don't exist [Fact] public async Task AttributeRoutedAction_InArea_StaysInArea_ActionDoesntExist() { @@ -1048,32 +56,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests } [Fact] - public async Task AttributeRoutedAction_InArea_LinkToConventionalRoutedActionInArea() + public async Task ConventionalRoutedAction_InArea_StaysInArea() { // Arrange - var url = LinkFrom("http://localhost/ContosoCorp/Trains") - .To(new { action = "Index", controller = "Flight", }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Rail", result.Controller); - Assert.Equal("Index", result.Action); - - Assert.Equal("/Travel/Flight", result.Link); - } - - [Fact] - public async Task ConventionalRoutedAction_InArea_LinkToAttributeRoutedActionInArea() - { - // Arrange - var url = LinkFrom("http://localhost/Travel/Flight") - .To(new { action = "Index", controller = "Rail", }); + var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "Contact", controller = "Home", }); // Act var response = await Client.GetAsync(url); @@ -1086,174 +72,64 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("Flight", result.Controller); Assert.Equal("Index", result.Action); - Assert.Equal("/ContosoCorp/Trains", result.Link); + Assert.Equal("/Travel/Home/Contact", result.Link); } + // Legacy routing returns 404 when an action does not support a HTTP method. [Fact] - public async Task ConventionalRoutedAction_InArea_LinkToAnotherArea() + public override async Task AttributeRoutedAction_MultipleRouteAttributes_RouteAttributeTemplatesIgnoredForOverrideActions() { // Arrange - var url = LinkFrom("http://localhost/Travel/Flight") - .To(new { action = "ListUsers", controller = "UserManagement", area = "Admin" }); + var url = "http://localhost/api/v1/Maps"; // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Flight", result.Controller); - Assert.Equal("Index", result.Action); - - Assert.Equal("/Admin/Users/All", result.Link); - } - - [Fact] - public async Task AttributeRoutedAction_InArea_LinkToAnotherArea() - { - // Arrange - var url = LinkFrom("http://localhost/ContosoCorp/Trains") - .To(new { action = "ListUsers", controller = "UserManagement", area = "Admin" }); - - // Act - var response = await Client.GetAsync(url); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Rail", result.Controller); - Assert.Equal("Index", result.Action); - - Assert.Equal("/Admin/Users/All", result.Link); - } - - [Theory] - [InlineData("/Bank/Deposit", "PUT", "Deposit")] - [InlineData("/Bank/Deposit", "POST", "Deposit")] - [InlineData("/Bank/Deposit/5", "PUT", "Deposit")] - [InlineData("/Bank/Deposit/5", "POST", "Deposit")] - [InlineData("/Bank/Withdraw/5", "POST", "Withdraw")] - public async Task AttributeRouting_MixedAcceptVerbsAndRoute_Reachable(string path, string verb, string actionName) - { - // Arrange - var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); - - // Act - var response = await Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains(path, result.ExpectedUrls); - Assert.Equal("Banks", result.Controller); - Assert.Equal(actionName, result.Action); - } - - // These verbs don't match - [Theory] - [InlineData("/Bank/Deposit", "GET")] - [InlineData("/Bank/Deposit/5", "DELETE")] - [InlineData("/Bank/Withdraw/5", "GET")] - public async Task AttributeRouting_MixedAcceptVerbsAndRoute_Unreachable(string path, string verb) - { - // Arrange - var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); - - // Act - var response = await Client.SendAsync(request); + var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod("POST"), url)); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - [Theory] - [InlineData("/Order/Add/1", "GET", "Add")] - [InlineData("/Order/Add", "POST", "Add")] - [InlineData("/Order/Edit/1", "PUT", "Edit")] - [InlineData("/Order/GetOrder", "GET", "GetOrder")] - public async Task AttributeRouting_RouteNameTokenReplace_Reachable(string path, string verb, string actionName) + [Fact] + public async override Task RouteData_Routers_ConventionalRoute() { - // Arrange - var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); - - // Act - var response = await Client.SendAsync(request); + // Arrange & Act + var response = await Client.GetAsync("http://localhost/RouteData/Conventional"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); + var result = JsonConvert.DeserializeObject(body); - Assert.Contains(path, result.ExpectedUrls); - Assert.Equal("Order", result.Controller); - Assert.Equal(actionName, result.Action); - } - - private static LinkBuilder LinkFrom(string url) - { - return new LinkBuilder(url); - } - - // See TestResponseGenerator in RoutingWebSite for the code that generates this data. - private class RoutingResult - { - public string[] ExpectedUrls { get; set; } - - public string ActualUrl { get; set; } - - public Dictionary RouteValues { get; set; } - - public string RouteName { get; set; } - - public string Action { get; set; } - - public string Controller { get; set; } - - public string Link { get; set; } - } - - private class LinkBuilder - { - public LinkBuilder(string url) - { - Url = url; - - Values = new Dictionary(); - Values.Add("link", string.Empty); - } - - public string Url { get; set; } - - public Dictionary Values { get; set; } - - public LinkBuilder To(object values) - { - var dictionary = new RouteValueDictionary(values); - foreach (var kvp in dictionary) + Assert.Equal( + new string[] { - Values.Add("link_" + kvp.Key, kvp.Value); - } + typeof(RouteCollection).FullName, + typeof(Route).FullName, + typeof(MvcRouteHandler).FullName, + }, + result.Routers); + } - return this; - } + [Fact] + public async override Task RouteData_Routers_AttributeRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/RouteData/Attribute"); - public override string ToString() - { - return Url + "?" + string.Join("&", Values.Select(kvp => kvp.Key + "=" + kvp.Value)); - } + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); - public static implicit operator string(LinkBuilder builder) - { - return builder.ToString(); - } + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal(new string[] + { + typeof(RouteCollection).FullName, + typeof(AttributeRoute).FullName, + typeof(MvcAttributeRouteHandler).FullName, + }, + result.Routers); } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs new file mode 100644 index 0000000000..a58e745b47 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs @@ -0,0 +1,1483 @@ +// 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 System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public abstract class RoutingTestsBase : IClassFixture> where TStartup : class + { + protected RoutingTestsBase(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public async Task ConventionalRoutedAction_RouteContainsPage_RouteNotMatched() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/PageRoute/ConventionalRoute/pagevalue"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("PageRoute", result.Controller); + Assert.Equal("ConventionalRoute", result.Action); + + // pagevalue is not used in "page" route value because it is a required value + Assert.False(result.RouteValues.ContainsKey("page")); + } + + [Fact] + public abstract Task HasEndpointMatch(); + + [Fact] + public abstract Task RouteData_Routers_ConventionalRoute(); + + [Fact] + public abstract Task RouteData_Routers_AttributeRoute(); + + // Verifies that components in the MVC pipeline can modify datatokens + // without impacting any static data. + // + // This does two request, to verify that the data in the route is not modified + [Fact] + public async Task RouteData_DataTokens_FilterCanSetDataTokens() + { + // Arrange + var response = await Client.GetAsync("http://localhost/RouteData/DataTokens"); + + // Guard + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + Assert.Single(result.DataTokens); + Assert.Single(result.DataTokens, kvp => kvp.Key == "actionName" && ((string)kvp.Value) == "DataTokens"); + + // Act + response = await Client.GetAsync("http://localhost/RouteData/Conventional"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + body = await response.Content.ReadAsStringAsync(); + result = JsonConvert.DeserializeObject(body); + + Assert.Single(result.DataTokens); + Assert.Single(result.DataTokens, kvp => kvp.Key == "actionName" && ((string)kvp.Value) == "Conventional"); + } + + protected class ResultData + { + public Dictionary DataTokens { get; set; } + + public string[] Routers { get; set; } + } + + [Fact] + public async Task DataTokens_ReturnsDataTokensForRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/DataTokensRoute/DataTokens/Index"); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + Assert.Single(result, kvp => kvp.Key == "hasDataTokens" && ((bool)kvp.Value) == true); + } + + [Fact] + public async Task DataTokens_ReturnsNoDataTokensForRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/DataTokens/Index"); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + Assert.Empty(result); + } + + [Fact] + public async Task Page_PageRouteTransformer() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/page-route-transformer/index"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Page_PageRouteTransformer_WithoutIndex() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/page-route-transformer"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Page_PageRouteTransformer_RouteParameter() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/page-route-transformer/test-page/ExtraPath/World"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello from World", body); + } + + [Fact] + public async Task Page_PageRouteTransformer_PageWithConfiguredRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/PageRouteTransformer/NewConventionRoute/World"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello from World", body); + } + + [Fact] + public virtual async Task ConventionalRoutedController_ActionIsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Home/Index"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Home/Index", result.ExpectedUrls); + Assert.Equal("Home", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" }, + }, + result.RouteValues); + } + + [Fact] + public virtual async Task ConventionalRoutedController_ActionIsReachable_WithDefaults() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/", result.ExpectedUrls); + Assert.Equal("Home", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" }, + }, + result.RouteValues); + } + + [Fact] + public virtual async Task ConventionalRoutedController_NonActionIsNotReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Home/NotAnAction"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public virtual async Task ConventionalRoutedController_InArea_ActionIsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Travel/Flight/Index"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Travel/Flight/Index", result.ExpectedUrls); + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", "Travel" }, + { "controller", "Flight" }, + { "action", "Index" }, + }, + result.RouteValues); + } + + [Fact] + public virtual async Task ConventionalRoutedController_InArea_ActionBlockedByHttpMethod() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Travel/Flight/BuyTickets"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("", "/Home/OptionalPath/default")] + [InlineData("CustomPath", "/Home/OptionalPath/CustomPath")] + public virtual async Task ConventionalRoutedController_WithOptionalSegment(string optionalSegment, string expected) + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Home/OptionalPath/" + optionalSegment); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Single(result.ExpectedUrls, expected); + } + + [Fact] + public async Task AttributeRoutedAction_IsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Store/Shop/Products"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Store/Shop/Products", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("ListProducts", result.Action); + + Assert.Contains( + new KeyValuePair("controller", "Store"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("action", "ListProducts"), + result.RouteValues); + } + + [Theory] + [InlineData("Get", "/Friends")] + [InlineData("Get", "/Friends/Peter")] + [InlineData("Delete", "/Friends")] + public async Task AttributeRoutedAction_AcceptRequestsWithValidMethods_InRoutesWithoutExtraTemplateSegmentsOnTheAction( + string method, + string url) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(method), $"http://localhost{url}"); + + // Assert + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains(url, result.ExpectedUrls); + Assert.Equal("Friends", result.Controller); + Assert.Equal(method, result.Action); + + Assert.Contains( + new KeyValuePair("controller", "Friends"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("action", method), + result.RouteValues); + + if (result.RouteValues.ContainsKey("id")) + { + Assert.Contains( + new KeyValuePair("id", "Peter"), + result.RouteValues); + } + } + + public static TheoryData AttributeRoutedAction_RejectsRequestsWithWrongMethods_InRoutesWithoutExtraTemplateSegmentsOnTheActionData + { + get + { + return new TheoryData + { + { "Post", "/Friends" }, + { "Put", "/Friends" }, + { "Patch", "/Friends" }, + { "Options", "/Friends" }, + { "Head", "/Friends" }, + }; + } + } + + [Theory] + [MemberData(nameof(AttributeRoutedAction_RejectsRequestsWithWrongMethods_InRoutesWithoutExtraTemplateSegmentsOnTheActionData))] + public virtual async Task AttributeRoutedAction_RejectsRequestsWithWrongMethods_InRoutesWithoutExtraTemplateSegmentsOnTheAction( + string method, + string url) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(method), $"http://localhost{url}"); + + // Assert + var response = await Client.SendAsync(request); + + // Assert + AssertCorsRejectionStatusCode(response); + } + + [Theory] + [InlineData("http://localhost/api/v1/Maps")] + [InlineData("http://localhost/api/v2/Maps")] + public virtual async Task AttributeRoutedAction_MultipleRouteAttributes_WorksWithNameAndOrder(string url) + { + // Arrange & Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Maps", result.Controller); + Assert.Equal("Get", result.Action); + + Assert.Equal(new string[] + { + "/api/v2/Maps", + "/api/v1/Maps", + "/api/v2/Maps" + }, + result.ExpectedUrls); + } + + [Fact] + public virtual async Task AttributeRoutedAction_MultipleRouteAttributes_WorksWithOverrideRoutes() + { + // Arrange + var url = "http://localhost/api/v2/Maps"; + + // Act + var response = await Client.SendAsync(new HttpRequestMessage(HttpMethod.Post, url)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Maps", result.Controller); + Assert.Equal("Post", result.Action); + + Assert.Equal(new string[] + { + "/api/v2/Maps", + "/api/v2/Maps" + }, + result.ExpectedUrls); + } + + [Fact] + public virtual async Task AttributeRoutedAction_MultipleRouteAttributes_RouteAttributeTemplatesIgnoredForOverrideActions() + { + // Arrange + var url = "http://localhost/api/v1/Maps"; + + // Act + var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod("POST"), url)); + + // Assert + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + + [Theory] + [InlineData("http://localhost/api/v1/Maps/5", "PUT")] + [InlineData("http://localhost/api/v2/Maps/5", "PUT")] + [InlineData("http://localhost/api/v1/Maps/PartialUpdate/5", "PATCH")] + [InlineData("http://localhost/api/v2/Maps/PartialUpdate/5", "PATCH")] + public virtual async Task AttributeRoutedAction_MultipleRouteAttributes_CombinesWithMultipleHttpAttributes( + string url, + string method) + { + // Arrange & Act + var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod(method), url)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Maps", result.Controller); + Assert.Equal("Update", result.Action); + + Assert.Equal(new string[] + { + "/api/v2/Maps/PartialUpdate/5", + "/api/v2/Maps/PartialUpdate/5" + }, + result.ExpectedUrls); + } + + [Theory] + [InlineData("http://localhost/Banks/Get/5")] + [InlineData("http://localhost/Bank/Get/5")] + public virtual async Task AttributeRoutedAction_MultipleHttpAttributesAndTokenReplacement(string url) + { + // Arrange + var expectedUrl = new Uri(url).AbsolutePath; + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Banks", result.Controller); + Assert.Equal("Get", result.Action); + + Assert.Equal(new string[] + { + "/Bank/Get/5", + "/Bank/Get/5" + }, + result.ExpectedUrls); + } + + public static TheoryData AttributeRoutedAction_MultipleRouteAttributes_WithMultipleHttpAttributes_RespectsConstraintsData + { + get + { + return new TheoryData + { + { "http://localhost/api/v1/Maps/5", "PATCH" }, + { "http://localhost/api/v2/Maps/5", "PATCH" }, + { "http://localhost/api/v1/Maps/PartialUpdate/5", "PUT" }, + { "http://localhost/api/v2/Maps/PartialUpdate/5", "PUT" }, + }; + } + } + + [Theory] + [MemberData(nameof(AttributeRoutedAction_MultipleRouteAttributes_WithMultipleHttpAttributes_RespectsConstraintsData))] + public virtual async Task AttributeRoutedAction_MultipleRouteAttributes_WithMultipleHttpAttributes_RespectsConstraints( + string url, + string method) + { + // Arrange + var expectedUrl = new Uri(url).AbsolutePath; + + // Act + var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod(method), url)); + + // Assert + AssertCorsRejectionStatusCode(response); + } + + // The url would be /Store/ListProducts with conventional routes + [Fact] + public async Task AttributeRoutedAction_IsNotReachableWithTraditionalRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Store/ListProducts"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // There's two actions at this URL - but attribute routes go in the route table + // first. + [Fact] + public async Task AttributeRoutedAction_TriedBeforeConventionalRouting() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Home/About"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Home/About", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("About", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_ControllerLevelRoute_WithActionParameter_IsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Blog/Edit/5"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Blog/Edit/5", result.ExpectedUrls); + Assert.Equal("Blog", result.Controller); + Assert.Equal("Edit", result.Action); + + Assert.Contains( + new KeyValuePair("controller", "Blog"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("action", "Edit"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("postId", "5"), + result.RouteValues); + } + + // There's no [HttpGet] on the action here. + [Fact] + public async Task AttributeRoutedAction_ControllerLevelRoute_IsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/api/Employee"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + } + + // We are intentionally skipping GET because we have another method with [HttpGet] on the same controller + // and a test that verifies that if you define another action with a specific verb we'll route to that + // more specific action. + [Theory] + [InlineData("PUT")] + [InlineData("POST")] + [InlineData("PATCH")] + [InlineData("DELETE")] + public async Task AttributeRoutedAction_RouteAttributeOnAction_IsReachable(string method) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Store/Shop/Orders"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Store/Shop/Orders", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("Orders", result.Action); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + public async Task AttributeRoutedAction_RouteAttributeOnActionAndController_IsReachable(string method) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/api/Employee/5/Salary"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee/5/Salary", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("Salary", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_RouteAttributeOnActionAndHttpGetOnDifferentAction_ReachesHttpGetAction() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Store/Shop/Orders"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Store/Shop/Orders", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("GetOrders", result.Action); + } + + // There's no [HttpGet] on the action here. + [Theory] + [InlineData("PUT")] + [InlineData("PATCH")] + public async Task AttributeRoutedAction_ControllerLevelRoute_WithAcceptVerbs_IsReachable(string verb) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("UpdateEmployee", result.Action); + } + + [Theory] + [InlineData("PUT")] + [InlineData("PATCH")] + public async Task AttributeRoutedAction_ControllerLevelRoute_WithAcceptVerbsAndRouteTemplate_IsReachable(string verb) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee/Manager"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee/Manager", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("UpdateManager", result.Action); + } + + [Theory] + [InlineData("PUT", "Bank")] + [InlineData("PATCH", "Bank")] + [InlineData("PUT", "Bank/Update")] + [InlineData("PATCH", "Bank/Update")] + public virtual async Task AttributeRoutedAction_AcceptVerbsAndRouteTemplate_IsReachable(string verb, string path) + { + // Arrange + var expectedUrl = "/Bank/Update"; + var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/" + path); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal(new string[] { expectedUrl, expectedUrl }, result.ExpectedUrls); + Assert.Equal("Banks", result.Controller); + Assert.Equal("UpdateBank", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_WithCustomHttpAttributes_IsReachable() + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod("MERGE"), "http://localhost/api/Employee/5"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee/5", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("MergeEmployee", result.Action); + } + + // There's an [HttpGet] with its own template on the action here. + [Theory] + [InlineData("GET", "GetAdministrator")] + [InlineData("DELETE", "DeleteAdministrator")] + public async Task AttributeRoutedAction_ControllerLevelRoute_CombinedWithActionRoute_IsReachable(string verb, string action) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee/5/Administrator"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee/5/Administrator", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal(action, result.Action); + + Assert.Contains( + new KeyValuePair("id", "5"), + result.RouteValues); + } + + [Fact] + public async Task AttributeRoutedAction_ActionLevelRouteWithTildeSlash_OverridesControllerLevelRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Manager/5"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Manager/5", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("GetManager", result.Action); + + Assert.Contains( + new KeyValuePair("id", "5"), + result.RouteValues); + } + + [Fact] + public async Task AttributeRoutedAction_OverrideActionOverridesOrderOnController() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Team/5"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Team/5", result.ExpectedUrls); + Assert.Equal("Team", result.Controller); + Assert.Equal("GetOrganization", result.Action); + + Assert.Contains( + new KeyValuePair("teamId", "5"), + result.RouteValues); + } + + [Fact] + public async Task AttributeRoutedAction_OrderOnActionOverridesOrderOnController() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Teams"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Teams", result.ExpectedUrls); + Assert.Equal("Team", result.Controller); + Assert.Equal("GetOrganizations", result.Action); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkGeneration_OverrideActionOverridesOrderOnController() + { + // Arrange & Act + var response = await Client.GetStringAsync("http://localhost/Organization/5"); + + // Assert + Assert.NotNull(response); + Assert.Equal("/Club/5", response); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkGeneration_OrderOnActionOverridesOrderOnController() + { + // Arrange & Act + var response = await Client.GetStringAsync("http://localhost/Teams/AllTeams"); + + // Assert + Assert.NotNull(response); + Assert.Equal("/Teams/AllOrganizations", response); + } + + [Theory] + [InlineData("", "/TeamName/DefaultName")] + [InlineData("CustomName", "/TeamName/CustomName")] + public virtual async Task AttributeRoutedAction_PreservesDefaultValue_IfRouteValueIsNull(string teamName, string expected) + { + // Arrange & Act + var body = await Client.GetStringAsync("http://localhost/TeamName/" + teamName); + + // Assert + Assert.NotNull(body); + var result = JsonConvert.DeserializeObject(body); + Assert.Single(result.ExpectedUrls, expected); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkToSelf() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee").To(new { }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/api/Employee", result.Link); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkWithAmbientController() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "Get", id = 5 }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/api/Employee/5", result.Link); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkToAttributeRoutedController() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "ShowPosts", controller = "Blog" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/Blog/ShowPosts", result.Link); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkToConventionalController() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "Index", controller = "Home" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/", result.Link); + } + + [Theory] + [InlineData("GET", "Get")] + [InlineData("PUT", "Put")] + public virtual async Task AttributeRoutedAction_LinkWithName_WithNameInheritedFromControllerRoute( + string method, + string actionName) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/api/Company/5"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Company", result.Controller); + Assert.Equal(actionName, result.Action); + + Assert.Equal("/api/Company/5", result.ExpectedUrls.Single()); + Assert.Equal("Company", result.RouteName); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkWithName_WithNameOverridenFromController() + { + // Arrange & Act + var response = await Client.DeleteAsync("http://localhost/api/Company/5"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Company", result.Controller); + Assert.Equal("Delete", result.Action); + + Assert.Equal("/api/Company/5", result.ExpectedUrls.Single()); + Assert.Equal("RemoveCompany", result.RouteName); + } + + [Fact] + public virtual async Task AttributeRoutedAction_Link_WithNonEmptyActionRouteTemplateAndNoActionRouteName() + { + // Arrange + var url = LinkFrom("http://localhost") + .To(new { id = 5 }); + + // Act + var response = await Client.GetAsync("http://localhost/api/Company/5/Employees"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Company", result.Controller); + Assert.Equal("GetEmployees", result.Action); + + Assert.Equal("/api/Company/5/Employees", result.ExpectedUrls.Single()); + Assert.Null(result.RouteName); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkWithName_WithNonEmptyActionRouteTemplateAndActionRouteName() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/api/Company/5/Departments"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Company", result.Controller); + Assert.Equal("GetDepartments", result.Action); + + Assert.Equal("/api/Company/5/Departments", result.ExpectedUrls.Single()); + Assert.Equal("Departments", result.RouteName); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_OptionalParameter_LinkToDefaultValuePath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Optional") + .To(new { }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("OptionalParameter", result.Action); + + Assert.Equal("/DefaultValuesRoute/Optional", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_OptionalParameter_LinkToFullPath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Optional") + .To(new { id = "123" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("OptionalParameter", result.Action); + + Assert.Equal("/DefaultValuesRoute/Optional/DEFAULTVALUES/OPTIONALPARAMETER/123", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_DefaultParameter_LinkToDefaultValuePath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Default") + .To(new { }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("DefaultParameter", result.Action); + Assert.Equal("17", result.RouteValues["id"]); + + Assert.Equal("/DefaultValuesRoute/Default", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_DefaultParameterWithCatchAll_LinkToDefaultValuePath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Default") + .To(new { catchAll = "CatchAll" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("DefaultParameter", result.Action); + Assert.Equal("17", result.RouteValues["id"]); + + Assert.Equal("/DefaultValuesRoute/Default/DEFAULTVALUES/DEFAULTPARAMETER/17/CatchAll", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_DefaultParameter_LinkToFullPath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Default") + .To(new { id = "123" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("DefaultParameter", result.Action); + Assert.Equal("17", result.RouteValues["id"]); + + Assert.Equal("/DefaultValuesRoute/Default/DEFAULTVALUES/DEFAULTPARAMETER/123", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_DefaultParameterMatches_LinkToShortenedPath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Default/DefaultValues/DefaultParameter/123") + .To(new { id = "17" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("DefaultParameter", result.Action); + Assert.Equal("123", result.RouteValues["id"]); + + Assert.Equal("/DefaultValuesRoute/Default", result.Link); + } + + [Fact] + public virtual async Task ConventionalRoutedAction_LinkToArea() + { + // Arrange + var url = LinkFrom("http://localhost/") + .To(new { action = "BuyTickets", controller = "Flight", area = "Travel" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Home", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight/BuyTickets", result.Link); + } + + [Fact] + public virtual async Task ConventionalRoutedAction_InArea_ImplicitLinkToArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "BuyTickets" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight/BuyTickets", result.Link); + } + + [Fact] + public virtual async Task ConventionalRoutedAction_InArea_ExplicitLeaveArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight") + .To(new { action = "Index", controller = "Home", area = "" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/", result.Link); + } + + [Fact] + public virtual async Task AttributeRoutedAction_LinkToArea() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee") + .To(new { action = "Schedule", controller = "Rail", area = "Travel" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/ContosoCorp/Trains/CheckSchedule", result.Link); + } + + [Fact] + public virtual async Task AttributeRoutedAction_InArea_ImplicitLinkToArea() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains/CheckSchedule").To(new { action = "Index" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Schedule", result.Action); + + Assert.Equal("/ContosoCorp/Trains", result.Link); + } + + [Fact] + public virtual async Task AttributeRoutedAction_InArea_ExplicitLeaveArea() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains/CheckSchedule") + .To(new { action = "Index", controller = "Home", area = "" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Schedule", result.Action); + + Assert.Equal("/", result.Link); + } + + + + [Fact] + public virtual async Task AttributeRoutedAction_InArea_LinkToConventionalRoutedActionInArea() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains") + .To(new { action = "Index", controller = "Flight", }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight", result.Link); + } + + [Fact] + public virtual async Task ConventionalRoutedAction_InArea_LinkToAttributeRoutedActionInArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight") + .To(new { action = "Index", controller = "Rail", }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/ContosoCorp/Trains", result.Link); + } + + [Fact] + public virtual async Task ConventionalRoutedAction_InArea_LinkToAnotherArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight") + .To(new { action = "ListUsers", controller = "UserManagement", area = "Admin" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Admin/Users/All", result.Link); + } + + [Fact] + public virtual async Task AttributeRoutedAction_InArea_LinkToAnotherArea() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains") + .To(new { action = "ListUsers", controller = "UserManagement", area = "Admin" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Admin/Users/All", result.Link); + } + + [Theory] + [InlineData("/Bank/Deposit", "PUT", "Deposit")] + [InlineData("/Bank/Deposit", "POST", "Deposit")] + [InlineData("/Bank/Deposit/5", "PUT", "Deposit")] + [InlineData("/Bank/Deposit/5", "POST", "Deposit")] + [InlineData("/Bank/Withdraw/5", "POST", "Withdraw")] + public async Task AttributeRouting_MixedAcceptVerbsAndRoute_Reachable(string path, string verb, string actionName) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains(path, result.ExpectedUrls); + Assert.Equal("Banks", result.Controller); + Assert.Equal(actionName, result.Action); + } + + // These verbs don't match + public static TheoryData AttributeRouting_MixedAcceptVerbsAndRoute_UnreachableData + { + get + { + return new TheoryData + { + { "/Bank/Deposit", "GET" }, + { "/Bank/Deposit/5", "DELETE" }, + { "/Bank/Withdraw/5", "GET" }, + }; + } + } + + [Theory] + [MemberData(nameof(AttributeRouting_MixedAcceptVerbsAndRoute_UnreachableData))] + public virtual async Task AttributeRouting_MixedAcceptVerbsAndRoute_Unreachable(string path, string verb) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); + + // Act + var response = await Client.SendAsync(request); + + // Assert + AssertCorsRejectionStatusCode(response); + } + + [Theory] + [InlineData("/Order/Add/1", "GET", "Add")] + [InlineData("/Order/Add", "POST", "Add")] + [InlineData("/Order/Edit/1", "PUT", "Edit")] + [InlineData("/Order/GetOrder", "GET", "GetOrder")] + public async Task AttributeRouting_RouteNameTokenReplace_Reachable(string path, string verb, string actionName) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains(path, result.ExpectedUrls); + Assert.Equal("Order", result.Controller); + Assert.Equal(actionName, result.Action); + } + + [Fact] + public async Task CanRunMiddlewareAfterRouting() + { + // Act + var response = await Client.GetAsync("/afterrouting"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello from middleware after routing", content); + } + + + protected static LinkBuilder LinkFrom(string url) + { + return new LinkBuilder(url); + } + + protected virtual void AssertCorsRejectionStatusCode(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingWithoutRazorPagesTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingWithoutRazorPagesTests.cs new file mode 100644 index 0000000000..6e90048fc6 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingWithoutRazorPagesTests.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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class RoutingWithoutRazorPagesTests : RoutingWithoutRazorPagesTestsBase + { + public RoutingWithoutRazorPagesTests(MvcTestFixture fixture) + : base(fixture) + { + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingWithoutRazorPagesTestsBase.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingWithoutRazorPagesTestsBase.cs new file mode 100644 index 0000000000..061bf537fe --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingWithoutRazorPagesTestsBase.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public abstract class RoutingWithoutRazorPagesTestsBase : IClassFixture> where TStartup : class + { + protected RoutingWithoutRazorPagesTestsBase(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public async Task AttributeRoutedAction_ContainsPage_RouteMatched() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/PageRoute/Attribute/pagevalue"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/PageRoute/Attribute/pagevalue", result.ExpectedUrls); + Assert.Equal("PageRoute", result.Controller); + Assert.Equal("AttributeRoute", result.Action); + + Assert.Contains( + new KeyValuePair("page", "pagevalue"), + result.RouteValues); + } + + [Fact] + public async Task ConventionalRoutedAction_RouteContainsPage_RouteNotMatched() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/PageRoute/ConventionalRoute/pagevalue"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("PageRoute", result.Controller); + Assert.Equal("ConventionalRoute", result.Action); + + Assert.Equal("pagevalue", result.RouteValues["page"]); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/SimpleTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/SimpleTests.cs index b5169c96ab..214fcc0eb5 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/SimpleTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/SimpleTests.cs @@ -19,7 +19,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public HttpClient Client { get; } [Fact] - public async Task JsonSerializeFormated() + public async Task JsonSerializeFormatted() { // Arrange var expected = "{" + Environment.NewLine diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/TagHelpersTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/TagHelpersTest.cs index b3c4d051aa..e6559100f4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/TagHelpersTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/TagHelpersTest.cs @@ -89,7 +89,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests } [Fact] - public async Task ReregisteringAntiforgeryTokenInsideFormTagHelper_DoesNotAddDuplicateAntiforgeryTokenFields() + public async Task ReRegisteringAntiforgeryTokenInsideFormTagHelper_DoesNotAddDuplicateAntiforgeryTokenFields() { // Arrange var expectedMediaType = MediaTypeHeaderValue.Parse("text/html; charset=utf-8"); @@ -116,9 +116,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests ResourceFile.UpdateFile(_resourcesAssembly, outputFile, expectedContent, responseContent); #else expectedContent = string.Format(expectedContent, forgeryToken); - // Mono issue - https://github.com/aspnet/External/issues/19 Assert.Equal( - PlatformNormalizer.NormalizeContent(expectedContent.Trim()), + expectedContent.Trim(), responseContent, ignoreLineEndingDifferences: true); #endif @@ -217,9 +216,8 @@ page:root-content" #if GENERATE_BASELINES ResourceFile.UpdateFile(_resourcesAssembly, outputFile, expectedContent, responseContent); #else - // Mono issue - https://github.com/aspnet/External/issues/19 Assert.Equal( - PlatformNormalizer.NormalizeContent(expectedContent), + expectedContent, responseContent, ignoreLineEndingDifferences: true); #endif @@ -285,9 +283,8 @@ page:root-content" #if GENERATE_BASELINES ResourceFile.UpdateFile(_resourcesAssembly, outputFile, expectedContent, responseContent); #else - // Mono issue - https://github.com/aspnet/External/issues/19 Assert.Equal( - PlatformNormalizer.NormalizeContent(expectedContent), + expectedContent, responseContent, ignoreLineEndingDifferences: true); #endif diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/TestingInfrastructureTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/TestingInfrastructureTests.cs index 9e5be478f6..54089a8e59 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/TestingInfrastructureTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/TestingInfrastructureTests.cs @@ -1,14 +1,18 @@ -using System.Linq; +using System; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Formatting; +using System.Threading; using System.Threading.Tasks; using BasicWebSite; using BasicWebSite.Controllers; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Mvc.Testing.Handlers; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using RazorPagesClassLibrary; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -17,13 +21,14 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests { public TestingInfrastructureTests(WebApplicationFactory fixture) { - var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); - Client = factory.CreateClient(); + Factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = Factory.CreateClient(); } private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => builder.ConfigureTestServices(s => s.AddSingleton()); + public WebApplicationFactory Factory { get; } public HttpClient Client { get; } [Fact] @@ -36,6 +41,15 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("Test", response); } + [Fact] + public void TestingInfrastructure_CreateClientThrowsInvalidOperationForNonEntryPoint() + { + var factory = new WebApplicationFactory(); + var ex = Assert.Throws(() => factory.CreateClient()); + Assert.Equal($"The provided Type '{typeof(RazorPagesClassLibrary.ClassLibraryStartup).Name}' does not belong to an assembly with an entry point. A common cause for this error is providing a Type from a class library.", + ex.Message); + } + [Fact] public async Task TestingInfrastructure_RedirectHandlerWorksWithPreserveMethod() { @@ -57,6 +71,22 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(5, handlerResponse.Body); } + [Fact] + public async Task TestingInfrastructure_RedirectHandlerUsesOriginalRequestHeaders() + { + // Act + var request = new HttpRequestMessage(HttpMethod.Get, "Testing/RedirectHandler/Headers"); + var client = Factory.CreateDefaultClient( + new RedirectHandler(), new TestHandler()); + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var modifiedHeaderWasSent = await response.Content.ReadAsStringAsync(); + + Assert.Equal("false", modifiedHeaderWasSent); + } + [Fact] public async Task TestingInfrastructure_PostRedirectGetWorksWithCookies() { @@ -93,5 +123,29 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Message = "Test"; } } + + private class TestHandler : DelegatingHandler + { + public TestHandler() + { + } + + public TestHandler(HttpMessageHandler innerHandler) : base(innerHandler) + { + } + + public bool HeaderAdded { get; set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!HeaderAdded) + { + request.Headers.Add("X-Added-Header", "true"); + HeaderAdded = true; + } + + return base.SendAsync(request, cancellationToken); + } + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningEndpointRoutingTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningEndpointRoutingTests.cs new file mode 100644 index 0000000000..30f2ea33ba --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningEndpointRoutingTests.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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class VersioningEndpointRoutingTests : VersioningTestsBase + { + public VersioningEndpointRoutingTests(MvcTestFixture fixture) + : base(fixture) + { + } + + [Fact] + public async override Task HasEndpointMatch() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Routing/HasEndpointMatch"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.True(result); + } + + // This behaves differently right now because the action/endpoint constraints are always + // executed after the DFA nodes like (HttpMethodMatcherPolicy). You don't have the flexibility + // to do what this test is doing in old-style routing. + [Fact] + public override async Task VersionedApi_CanUseConstraintOrder_ToChangeSelectedAction() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Delete, "http://localhost/" + "Customers/5?version=2"); + + // Act + var response = await Client.SendAsync(message); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + Assert.Equal("Customers", result.Controller); + Assert.Equal("Delete", result.Action); + } + + // This behaves differently right now because the action/endpoint constraints are always + // executed after the DFA nodes like (HttpMethodMatcherPolicy). You don't have the flexibility + // to do what this test is doing in old-style routing. + [Fact] + public override async Task VersionedApi_ConstraintOrder_IsRespected() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/" + "Customers?version=2"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Customers", result.Controller); + Assert.Equal("Post", result.Action); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningTests.cs index 02a86d0871..80e057bbd1 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningTests.cs @@ -1,562 +1,33 @@ // 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.Net; -using System.Net.Http; using System.Threading.Tasks; using Newtonsoft.Json; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { - public class VersioningTests : IClassFixture> + public class VersioningTests : VersioningTestsBase { - public VersioningTests(MvcTestFixture fixture) + public VersioningTests(MvcTestFixture fixture) + : base(fixture) { - Client = fixture.CreateDefaultClient(); - } - - public HttpClient Client { get; } - - [Theory] - [InlineData("1")] - [InlineData("2")] - public async Task AttributeRoutedAction_WithVersionedRoutes_IsNotAmbiguous(string version) - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/Addresses?version=" + version); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("api/addresses", result.ExpectedUrls); - Assert.Equal("Address", result.Controller); - Assert.Equal("GetV" + version, result.Action); - } - - [Theory] - [InlineData("1")] - [InlineData("2")] - public async Task AttributeRoutedAction_WithAmbiguousVersionedRoutes_CanBeDisambiguatedUsingOrder(string version) - { - // Arrange - var query = "?version=" + version; - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/Addresses/All" + query); - - // Act - - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Contains("/api/addresses/all?version=" + version, result.ExpectedUrls); - Assert.Equal("Address", result.Controller); - Assert.Equal("GetAllV" + version, result.Action); } [Fact] - public async Task VersionedApi_CanReachV1Operations_OnTheSameController_WithNoVersionSpecified() + public async override Task HasEndpointMatch() { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Tickets"); - - // Act - var response = await Client.SendAsync(message); + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Routing/HasEndpointMatch"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); + var result = JsonConvert.DeserializeObject(body); - Assert.Equal("Tickets", result.Controller); - Assert.Equal("Get", result.Action); - - Assert.DoesNotContain("id", result.RouteValues.Keys); - } - - [Fact] - public async Task VersionedApi_CanReachV1Operations_OnTheSameController_WithVersionSpecified() - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Tickets?version=2"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Tickets", result.Controller); - Assert.Equal("Get", result.Action); - } - - [Fact] - public async Task VersionedApi_CanReachV1OperationsWithParameters_OnTheSameController() - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Tickets/5"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Tickets", result.Controller); - Assert.Equal("GetById", result.Action); - } - - [Fact] - public async Task VersionedApi_CanReachV1OperationsWithParameters_OnTheSameController_WithVersionSpecified() - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Tickets/5?version=2"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Tickets", result.Controller); - Assert.Equal("GetById", result.Action); - Assert.NotEmpty(result.RouteValues); - - Assert.Contains( - new KeyValuePair("id", "5"), - result.RouteValues); - } - - [Theory] - [InlineData("2")] - [InlineData("3")] - [InlineData("4")] - public async Task VersionedApi_CanReachOtherVersionOperations_OnTheSameController(string version) - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Tickets?version=" + version); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Tickets", result.Controller); - Assert.Equal("Post", result.Action); - Assert.NotEmpty(result.RouteValues); - - Assert.DoesNotContain( - new KeyValuePair("id", "5"), - result.RouteValues); - } - - [Fact] - public async Task VersionedApi_CanNotReachOtherVersionOperations_OnTheSameController_WithNoVersionSpecified() - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Tickets"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var body = await response.Content.ReadAsByteArrayAsync(); - Assert.Empty(body); - } - - [Theory] - [InlineData("PUT", "Put", "2")] - [InlineData("PUT", "Put", "3")] - [InlineData("PUT", "Put", "4")] - [InlineData("DELETE", "Delete", "2")] - [InlineData("DELETE", "Delete", "3")] - [InlineData("DELETE", "Delete", "4")] - public async Task VersionedApi_CanReachOtherVersionOperationsWithParameters_OnTheSameController( - string method, - string action, - string version) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Tickets/5?version=" + version); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Tickets", result.Controller); - Assert.Equal(action, result.Action); - Assert.NotEmpty(result.RouteValues); - - Assert.Contains( - new KeyValuePair("id", "5"), - result.RouteValues); - } - - [Theory] - [InlineData("PUT")] - [InlineData("DELETE")] - public async Task VersionedApi_CanNotReachOtherVersionOperationsWithParameters_OnTheSameController_WithNoVersionSpecified(string method) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Tickets/5"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var body = await response.Content.ReadAsByteArrayAsync(); - Assert.Empty(body); - } - - [Theory] - [InlineData("3")] - [InlineData("4")] - [InlineData("5")] - public async Task VersionedApi_CanUseOrderToDisambiguate_OverlappingVersionRanges(string version) - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Books?version=" + version); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Books", result.Controller); - Assert.Equal("GetBreakingChange", result.Action); - } - - [Theory] - [InlineData("1")] - [InlineData("2")] - [InlineData("6")] - public async Task VersionedApi_OverlappingVersionRanges_FallsBackToLowerOrderAction(string version) - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Books?version=" + version); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Books", result.Controller); - Assert.Equal("Get", result.Action); - } - - - [Theory] - [InlineData("GET", "Get")] - [InlineData("POST", "Post")] - public async Task VersionedApi_CanReachV1Operations_OnTheOriginalController_WithNoVersionSpecified(string method, string action) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Movies"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Movies", result.Controller); - Assert.Equal(action, result.Action); - } - - [Theory] - [InlineData("GET", "Get")] - [InlineData("POST", "Post")] - public async Task VersionedApi_CanReachV1Operations_OnTheOriginalController_WithVersionSpecified(string method, string action) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Movies?version=2"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Movies", result.Controller); - Assert.Equal(action, result.Action); - } - - [Theory] - [InlineData("GET", "GetById")] - [InlineData("PUT", "Put")] - [InlineData("DELETE", "Delete")] - public async Task VersionedApi_CanReachV1OperationsWithParameters_OnTheOriginalController(string method, string action) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Movies/5"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Movies", result.Controller); - Assert.Equal(action, result.Action); - } - - [Theory] - [InlineData("GET", "GetById")] - [InlineData("DELETE", "Delete")] - public async Task VersionedApi_CanReachV1OperationsWithParameters_OnTheOriginalController_WithVersionSpecified(string method, string action) - { - // Arrange - var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Movies/5?version=2"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Movies", result.Controller); - Assert.Equal(action, result.Action); - } - - [Fact] - public async Task VersionedApi_CanReachOtherVersionOperationsWithParameters_OnTheV2Controller() - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Put, "http://localhost/Movies/5?version=2"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("MoviesV2", result.Controller); - Assert.Equal("Put", result.Action); - Assert.NotEmpty(result.RouteValues); - } - - [Theory] - [InlineData("v1/Pets")] - [InlineData("v2/Pets")] - public async Task VersionedApi_CanHaveTwoRoutesWithVersionOnTheUrl_OnTheSameAction(string url) - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/" + url); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Pets", result.Controller); - Assert.Equal("Get", result.Action); - } - - [Theory] - [InlineData("v1/Pets/5", "V1")] - [InlineData("v2/Pets/5", "V2")] - public async Task VersionedApi_CanHaveTwoRoutesWithVersionOnTheUrl_OnDifferentActions(string url, string version) - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/" + url); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Pets", result.Controller); - Assert.Equal("Get" + version, result.Action); - } - - [Theory] - [InlineData("v1/Pets", "V1")] - [InlineData("v2/Pets", "V2")] - public async Task VersionedApi_CanHaveTwoRoutesWithVersionOnTheUrl_OnDifferentActions_WithInlineConstraint(string url, string version) - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/" + url); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Pets", result.Controller); - Assert.Equal("Post" + version, result.Action); - } - - [Theory] - [InlineData("Customers/5", "?version=1", "Get")] - [InlineData("Customers/5", "?version=2", "Get")] - [InlineData("Customers/5", "?version=3", "GetV3ToV5")] - [InlineData("Customers/5", "?version=4", "GetV3ToV5")] - [InlineData("Customers/5", "?version=5", "GetV3ToV5")] - public async Task VersionedApi_CanProvideVersioningInformation_UsingPlainActionConstraint(string url, string query, string actionName) - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/" + url + query); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Customers", result.Controller); - Assert.Equal(actionName, result.Action); - } - - [Fact] - public async Task VersionedApi_ConstraintOrder_IsRespected() - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/" + "Customers?version=2"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Customers", result.Controller); - Assert.Equal("AnyV2OrHigher", result.Action); - } - - [Fact] - public async Task VersionedApi_CanUseConstraintOrder_ToChangeSelectedAction() - { - // Arrange - var message = new HttpRequestMessage(HttpMethod.Delete, "http://localhost/" + "Customers/5?version=2"); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Customers", result.Controller); - Assert.Equal("Delete", result.Action); - } - - [Theory] - [InlineData("1")] - [InlineData("2")] - public async Task VersionedApi_MultipleVersionsUsingAttributeRouting_OnTheSameMethod(string version) - { - // Arrange - var path = "/" + version + "/Vouchers?version=" + version; - var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost" + path); - - // Act - var response = await Client.SendAsync(message); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - - Assert.Equal("Vouchers", result.Controller); - Assert.Equal("GetVouchersMultipleVersions", result.Action); - - var actualUrl = Assert.Single(result.ExpectedUrls); - Assert.Equal(path, actualUrl); - } - - private class RoutingResult - { - public string[] ExpectedUrls { get; set; } - - public string ActualUrl { get; set; } - - public Dictionary RouteValues { get; set; } - - public string RouteName { get; set; } - - public string Action { get; set; } - - public string Controller { get; set; } - - public string Link { get; set; } + Assert.False(result); } } } \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningTestsBase.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningTestsBase.cs new file mode 100644 index 0000000000..a2b7c36325 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/VersioningTestsBase.cs @@ -0,0 +1,571 @@ +// 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; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public abstract class VersioningTestsBase : IClassFixture> where TStartup : class + { + protected VersioningTestsBase(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public abstract Task HasEndpointMatch(); + + [Theory] + [InlineData("1")] + [InlineData("2")] + public async Task AttributeRoutedAction_WithVersionedRoutes_IsNotAmbiguous(string version) + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/Addresses?version=" + version); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("api/addresses", result.ExpectedUrls); + Assert.Equal("Address", result.Controller); + Assert.Equal("GetV" + version, result.Action); + } + + [Theory] + [InlineData("1")] + [InlineData("2")] + public async Task AttributeRoutedAction_WithAmbiguousVersionedRoutes_CanBeDisambiguatedUsingOrder(string version) + { + // Arrange + var query = "?version=" + version; + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/Addresses/All" + query); + + // Act + + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/addresses/all?version=" + version, result.ExpectedUrls); + Assert.Equal("Address", result.Controller); + Assert.Equal("GetAllV" + version, result.Action); + } + + [Fact] + public async Task VersionedApi_CanReachV1Operations_OnTheSameController_WithNoVersionSpecified() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Tickets"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Tickets", result.Controller); + Assert.Equal("Get", result.Action); + + Assert.DoesNotContain("id", result.RouteValues.Keys); + } + + [Fact] + public async Task VersionedApi_CanReachV1Operations_OnTheSameController_WithVersionSpecified() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Tickets?version=2"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Tickets", result.Controller); + Assert.Equal("Get", result.Action); + } + + [Fact] + public async Task VersionedApi_CanReachV1OperationsWithParameters_OnTheSameController() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Tickets/5"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Tickets", result.Controller); + Assert.Equal("GetById", result.Action); + } + + [Fact] + public async Task VersionedApi_CanReachV1OperationsWithParameters_OnTheSameController_WithVersionSpecified() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Tickets/5?version=2"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Tickets", result.Controller); + Assert.Equal("GetById", result.Action); + Assert.NotEmpty(result.RouteValues); + + Assert.Contains( + new KeyValuePair("id", "5"), + result.RouteValues); + } + + [Theory] + [InlineData("2")] + [InlineData("3")] + [InlineData("4")] + public async Task VersionedApi_CanReachOtherVersionOperations_OnTheSameController(string version) + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Tickets?version=" + version); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Tickets", result.Controller); + Assert.Equal("Post", result.Action); + Assert.NotEmpty(result.RouteValues); + + Assert.DoesNotContain( + new KeyValuePair("id", "5"), + result.RouteValues); + } + + [Fact] + public async Task VersionedApi_CanNotReachOtherVersionOperations_OnTheSameController_WithNoVersionSpecified() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Tickets"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var body = await response.Content.ReadAsByteArrayAsync(); + Assert.Empty(body); + } + + [Theory] + [InlineData("PUT", "Put", "2")] + [InlineData("PUT", "Put", "3")] + [InlineData("PUT", "Put", "4")] + [InlineData("DELETE", "Delete", "2")] + [InlineData("DELETE", "Delete", "3")] + [InlineData("DELETE", "Delete", "4")] + public async Task VersionedApi_CanReachOtherVersionOperationsWithParameters_OnTheSameController( + string method, + string action, + string version) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Tickets/5?version=" + version); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Tickets", result.Controller); + Assert.Equal(action, result.Action); + Assert.NotEmpty(result.RouteValues); + + Assert.Contains( + new KeyValuePair("id", "5"), + result.RouteValues); + } + + [Theory] + [InlineData("PUT")] + [InlineData("DELETE")] + public async Task VersionedApi_CanNotReachOtherVersionOperationsWithParameters_OnTheSameController_WithNoVersionSpecified(string method) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Tickets/5"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var body = await response.Content.ReadAsByteArrayAsync(); + Assert.Empty(body); + } + + [Theory] + [InlineData("3")] + [InlineData("4")] + [InlineData("5")] + public async Task VersionedApi_CanUseOrderToDisambiguate_OverlappingVersionRanges(string version) + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Books?version=" + version); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Books", result.Controller); + Assert.Equal("GetBreakingChange", result.Action); + } + + [Theory] + [InlineData("1")] + [InlineData("2")] + [InlineData("6")] + public async Task VersionedApi_OverlappingVersionRanges_FallsBackToLowerOrderAction(string version) + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Books?version=" + version); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Books", result.Controller); + Assert.Equal("Get", result.Action); + } + + + [Theory] + [InlineData("GET", "Get")] + [InlineData("POST", "Post")] + public async Task VersionedApi_CanReachV1Operations_OnTheOriginalController_WithNoVersionSpecified(string method, string action) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Movies"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Movies", result.Controller); + Assert.Equal(action, result.Action); + } + + [Theory] + [InlineData("GET", "Get")] + [InlineData("POST", "Post")] + public async Task VersionedApi_CanReachV1Operations_OnTheOriginalController_WithVersionSpecified(string method, string action) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Movies?version=2"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Movies", result.Controller); + Assert.Equal(action, result.Action); + } + + [Theory] + [InlineData("GET", "GetById")] + [InlineData("PUT", "Put")] + [InlineData("DELETE", "Delete")] + public async Task VersionedApi_CanReachV1OperationsWithParameters_OnTheOriginalController(string method, string action) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Movies/5"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Movies", result.Controller); + Assert.Equal(action, result.Action); + } + + [Theory] + [InlineData("GET", "GetById")] + [InlineData("DELETE", "Delete")] + public async Task VersionedApi_CanReachV1OperationsWithParameters_OnTheOriginalController_WithVersionSpecified(string method, string action) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Movies/5?version=2"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Movies", result.Controller); + Assert.Equal(action, result.Action); + } + + [Fact] + public async Task VersionedApi_CanReachOtherVersionOperationsWithParameters_OnTheV2Controller() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Put, "http://localhost/Movies/5?version=2"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("MoviesV2", result.Controller); + Assert.Equal("Put", result.Action); + Assert.NotEmpty(result.RouteValues); + } + + [Theory] + [InlineData("v1/Pets")] + [InlineData("v2/Pets")] + public async Task VersionedApi_CanHaveTwoRoutesWithVersionOnTheUrl_OnTheSameAction(string url) + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/" + url); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Pets", result.Controller); + Assert.Equal("Get", result.Action); + } + + [Theory] + [InlineData("v1/Pets/5", "V1")] + [InlineData("v2/Pets/5", "V2")] + public async Task VersionedApi_CanHaveTwoRoutesWithVersionOnTheUrl_OnDifferentActions(string url, string version) + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/" + url); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Pets", result.Controller); + Assert.Equal("Get" + version, result.Action); + } + + [Theory] + [InlineData("v1/Pets", "V1")] + [InlineData("v2/Pets", "V2")] + public async Task VersionedApi_CanHaveTwoRoutesWithVersionOnTheUrl_OnDifferentActions_WithInlineConstraint(string url, string version) + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/" + url); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Pets", result.Controller); + Assert.Equal("Post" + version, result.Action); + } + + [Theory] + [InlineData("Customers/5", "?version=1", "Get")] + [InlineData("Customers/5", "?version=2", "Get")] + [InlineData("Customers/5", "?version=3", "GetV3ToV5")] + [InlineData("Customers/5", "?version=4", "GetV3ToV5")] + [InlineData("Customers/5", "?version=5", "GetV3ToV5")] + public async Task VersionedApi_CanProvideVersioningInformation_UsingPlainActionConstraint(string url, string query, string actionName) + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/" + url + query); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Customers", result.Controller); + Assert.Equal(actionName, result.Action); + } + + [Fact] + public virtual async Task VersionedApi_ConstraintOrder_IsRespected() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Post, "http://localhost/" + "Customers?version=2"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Customers", result.Controller); + Assert.Equal("AnyV2OrHigher", result.Action); + } + + [Fact] + public virtual async Task VersionedApi_CanUseConstraintOrder_ToChangeSelectedAction() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Delete, "http://localhost/" + "Customers/5?version=2"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Customers", result.Controller); + Assert.Equal("Delete", result.Action); + } + + [Theory] + [InlineData("1")] + [InlineData("2")] + public async Task VersionedApi_MultipleVersionsUsingAttributeRouting_OnTheSameMethod(string version) + { + // Arrange + var path = "/" + version + "/Vouchers?version=" + version; + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost" + path); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Vouchers", result.Controller); + Assert.Equal("GetVouchersMultipleVersions", result.Action); + + var actualUrl = Assert.Single(result.ExpectedUrls); + Assert.Equal(path, actualUrl); + } + + protected class RoutingResult + { + public string[] ExpectedUrls { get; set; } + + public string ActualUrl { get; set; } + + public Dictionary RouteValues { get; set; } + + public string RouteName { get; set; } + + public string Action { get; set; } + + public string Controller { get; set; } + + public string Link { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs index 363f534469..7c23e4c377 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs @@ -270,28 +270,6 @@ Hello from Shared/_EmbeddedPartial Assert.Equal(expected, body.Trim(), ignoreLineEndingDifferences: true); } - [Fact] - public async Task RazorViewEngine_UpdatesViewsReferencedViaRelativePathsOnChange() - { - // Arrange - var expected1 = "Original content"; - var expected2 = "New content"; - - // Act - 1 - var body = await Client.GetStringAsync("/UpdateableFileProvider"); - - // Assert - 1 - Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true); - - // Act - 2 - var response = await Client.PostAsync("/UpdateableFileProvider/Update", new StringContent(string.Empty)); - response.EnsureSuccessStatusCode(); - body = await Client.GetStringAsync("/UpdateableFileProvider"); - - // Assert - 1 - Assert.Equal(expected2, body.Trim(), ignoreLineEndingDifferences: true); - } - [Fact] public async Task LayoutValueIsPassedBetweenNestedViewStarts() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/WebApiCompatShimBasicTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/WebApiCompatShimBasicTest.cs index 9080f544e9..63f2e26a9d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/WebApiCompatShimBasicTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/WebApiCompatShimBasicTest.cs @@ -104,7 +104,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests } [Fact] - public async Task ActionThrowsHttpResponseException_EnsureGlobalHttpresponseExceptionActionFilter_IsInvoked() + public async Task ActionThrowsHttpResponseException_EnsureGlobalHttpResponseExceptionActionFilter_IsInvoked() { // Arrange & Act var response = await Client.GetAsync( @@ -358,10 +358,11 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("text/json", response.Content.Headers.ContentType.MediaType); } + // Use int for HttpStatusCode data because xUnit cannot serialize a GAC'd enum when running on .NET Framework. [Theory] - [InlineData("http://localhost/Mvc/Index", HttpStatusCode.OK)] - [InlineData("http://localhost/api/Blog/Mvc/Index", HttpStatusCode.NotFound)] - public async Task WebApiRouting_AccessMvcController(string url, HttpStatusCode expected) + [InlineData("http://localhost/Mvc/Index", (int)HttpStatusCode.OK)] + [InlineData("http://localhost/api/Blog/Mvc/Index", (int)HttpStatusCode.NotFound)] + public async Task WebApiRouting_AccessMvcController(string url, int expected) { // Arrange var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -370,13 +371,14 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var response = await Client.SendAsync(request); // Assert - Assert.Equal(expected, response.StatusCode); + Assert.Equal(expected, (int)response.StatusCode); } + // Use int for HttpStatusCode data because xUnit cannot serialize a GAC'd enum when running on .NET Framework. [Theory] - [InlineData("http://localhost/BasicApi/GenerateUrl", HttpStatusCode.NotFound)] - [InlineData("http://localhost/api/Blog/BasicApi/GenerateUrl", HttpStatusCode.OK)] - public async Task WebApiRouting_AccessWebApiController(string url, HttpStatusCode expected) + [InlineData("http://localhost/BasicApi/GenerateUrl", (int)HttpStatusCode.NotFound)] + [InlineData("http://localhost/api/Blog/BasicApi/GenerateUrl", (int)HttpStatusCode.OK)] + public async Task WebApiRouting_AccessWebApiController(string url, int expected) { // Arrange var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -385,7 +387,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var response = await Client.SendAsync(request); // Assert - Assert.Equal(expected, response.StatusCode); + Assert.Equal(expected, (int)response.StatusCode); } [Fact] diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs index 5e6443a862..5c781c6652 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs @@ -1,12 +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 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 @@ -15,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 @@ -208,5 +215,151 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests "", result); } + + [Fact] + public async Task ProblemDetails_IsSerialized() + { + // Arrange + using (new ActivityReplacer()) + { + 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"); + + // 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 = "" + + "instance" + + "404" + + "title" + + "correlation" + + "Account1 Account2" + + ""; + + // 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 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." + + $"{Activity.Current.Id}" + + "" + + "The State field is required." + + "" + + ""; + + // Act + var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningValidationProblem"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var content = await response.Content.ReadAsStringAsync(); + XmlAssert.Equal(expected, content); + } + } + + [Fact] + public async Task ValidationProblemDetails_WithExtensionMembers_IsSerialized() + { + // Arrange + var expected = "" + + "some detail" + + "400" + + "One or more validation errors occurred." + + "some type" + + "correlation" + + "" + + "ErrorValue" + + "" + + ""; + + // 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); + } + + [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); + } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerInputFormatterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerInputFormatterTest.cs index 3b97473fdd..1b1b74a591 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerInputFormatterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerInputFormatterTest.cs @@ -78,7 +78,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Verifies that the model state has errors related to body model validation. [Fact] - public async Task DataMissingForRefereneceTypeProperties_AndModelIsBound_AndHasMixedValidationErrors() + public async Task DataMissingForReferenceTypeProperties_AndModelIsBound_AndHasMixedValidationErrors() { // Arrange var input = " 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] @@ -183,5 +190,151 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests "key2-error", result); } + + [Fact] + public async Task ProblemDetails_IsSerialized() + { + // Arrange + using (new ActivityReplacer()) + { + 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"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NotFound); + var content = await response.Content.ReadAsStringAsync(); + 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/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 = "" + + "instance" + + "404" + + "title" + + "correlation" + + "Account1 Account2" + + ""; + + // 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 ValidationProblemDetails_IsSerialized() + { + // Arrange + using (new ActivityReplacer()) + { + 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"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var content = await response.Content.ReadAsStringAsync(); + XmlAssert.Equal(expected, content); + } + } + + [Fact] + public async Task ValidationProblemDetails_WithExtensionMembers_IsSerialized() + { + // Arrange + var expected = "" + + "some detail" + + "400" + + "One or more validation errors occurred." + + "some type" + + "correlation" + + "" + + "ErrorValue" + + "" + + ""; + + // 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); + } + + [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/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index.Encoded.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index.Encoded.html index dfed464b45..c518a8fea6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index.Encoded.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index.Encoded.html @@ -13,7 +13,7 @@ Default Controller
Product Submit Fragment @@ -39,32 +39,32 @@
Non-existent Area diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index.html index e80ec4969e..1800bea8a1 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index.html @@ -13,7 +13,7 @@ Default Controller
Product Submit Fragment @@ -39,32 +39,32 @@
Non-existent Area diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index21Compat.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index21Compat.html new file mode 100644 index 0000000000..59380d72d6 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index21Compat.html @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Link.Encoded.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Link.Encoded.html index 30f65f5ccd..00ae75a1c4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Link.Encoded.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Link.Encoded.html @@ -11,10 +11,10 @@ - + - + @@ -41,6 +41,18 @@ + + + + + + + + + + + + @@ -81,9 +93,9 @@ - + - + @@ -101,7 +113,7 @@ - + @@ -129,7 +141,7 @@ - + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Link.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Link.html index cb7acce37c..b0a2e41eb6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Link.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Link.html @@ -11,10 +11,10 @@ - + - + @@ -41,6 +41,18 @@ + + + + + + + + + + + + @@ -82,9 +94,9 @@ - + - + @@ -102,7 +114,7 @@ - + @@ -130,7 +142,7 @@ - + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Script.Encoded.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Script.Encoded.html index 680a5729a0..08d2e6d60a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Script.Encoded.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Script.Encoded.html @@ -19,11 +19,27 @@ // Fallback to globbed src ]]")); + + +]]")); + + +]]")); + + +]]")); -]]")); +]]")); - + - + @@ -110,9 +126,9 @@ - + - + \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Script.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Script.html index 12b71d6555..ea0f2da3ac 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Script.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Script.html @@ -19,11 +19,27 @@ // Fallback to globbed src + + + + + + + + + - + - + - + @@ -110,9 +126,9 @@ - + - + \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/RazorPageExecutionInstrumentationWebSite.Home.ViewWithPartial.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/RazorPageExecutionInstrumentationWebSite.Home.ViewWithPartial.html deleted file mode 100644 index 81cbd4fb84..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/RazorPageExecutionInstrumentationWebSite.Home.ViewWithPartial.html +++ /dev/null @@ -1,20 +0,0 @@ -/Views/_ViewStart.cshtml: Non-literal at 96 contains 16 characters. -/Views/_ViewStart.cshtml: Literal at 112 contains 2 characters. -/Views/Home/ViewWithPartial.cshtml: Literal at 0 contains 27 characters. -/Views/Home/ViewWithPartial.cshtml: Non-literal at 28 contains 39 characters. -/Views/Home/_PartialView.cshtml: Literal at 31 contains 2 characters. -/Views/Home/_PartialView.cshtml: Literal at 33 contains 8 characters. -/Views/Home/_PartialView.cshtml: Non-literal at 41 contains 4 characters. -/Views/Home/_PartialView.cshtml: Literal at 45 contains 1 characters. -/Views/Home/_PartialView.cshtml: Literal at 46 contains 20 characters. -/Views/Home/ViewWithPartial.cshtml: Literal at 67 contains 2 characters. -/Views/Home/_PartialView.cshtml: Literal at 31 contains 2 characters. -/Views/Home/_PartialView.cshtml: Literal at 33 contains 8 characters. -/Views/Home/_PartialView.cshtml: Non-literal at 41 contains 4 characters. -/Views/Home/_PartialView.cshtml: Literal at 45 contains 1 characters. -/Views/Home/_PartialView.cshtml: Literal at 46 contains 20 characters. -/Views/_Layout.cshtml: Literal at 0 contains 7 characters. -/Views/_Layout.cshtml: Non-literal at 8 contains 12 characters. -/Views/_Layout.cshtml: Literal at 20 contains 2 characters. -/Views/_Layout.cshtml: Non-literal at 23 contains 12 characters. -/Views/_Layout.cshtml: Literal at 35 contains 10 characters. diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.About.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.About.html index e675616a89..72ba50bb99 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.About.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.About.html @@ -12,8 +12,8 @@

ASP.NET vNext - About

| My Home - | My About - | My Help |

+ | My About + | My Help |

diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.Help.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.Help.html index 0a20fbb435..a3d85388ba 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.Help.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.Help.html @@ -12,8 +12,8 @@

ASP.NET vNext - Help

| My Home - | My About - | My Help |

+ | My About + | My Help |

diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.Index.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.Index.html index 27bc4cf95a..d10ba0b6f4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.Index.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.Index.html @@ -19,8 +19,8 @@

ASP.NET vNext - Home Page

| My Home - | My About - | My Help |

+ | My About + | My Help |

diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.ViewComponentTagHelpers.html b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.ViewComponentTagHelpers.html index b6caedd04c..c9bec6c144 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.ViewComponentTagHelpers.html +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Home.ViewComponentTagHelpers.html @@ -12,8 +12,8 @@

ASP.NET vNext -

| My Home - | My About - | My Help |

+ | My About + | My Help |

Items:
diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/xunit.runner.json b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/xunit.runner.json index 1c72a421ad..74f4888c0b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/xunit.runner.json +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/xunit.runner.json @@ -1,3 +1,5 @@ { - "shadowCopy": false + "diagnosticMessages": true, + "shadowCopy": false, + "longRunningTestSeconds": 60 } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ActionParametersIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ActionParametersIntegrationTest.cs index fcd5923101..6f84a74c88 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ActionParametersIntegrationTest.cs +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/AuthorizeFilterIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/AuthorizeFilterIntegrationTest.cs index f19a67d45b..596d5b8ab3 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/AuthorizeFilterIntegrationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/AuthorizeFilterIntegrationTest.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; using Xunit; @@ -53,11 +54,47 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Equal(2, policyProvider.GetPolicyCount); } - private HttpContext GetHttpContext() + // This is a test for security, because we can't assume that any IAuthorizationPolicyProvider other than + // DefaultAuthorizationPolicyProvider will return the same result for the same input. So a cache could cause + // undesired access. + [Fact] + public async Task CombinedAuthorizeFilter_AlwaysCalledWithNonDefaultProvider() + { + // Arrange + var applicationModelProviderContext = GetProviderContext(typeof(AuthorizeController)); + + var policyProvider = new TestAuthorizationPolicyProvider(); + + var controller = Assert.Single(applicationModelProviderContext.Result.Controllers); + var action = Assert.Single(controller.Actions); + var authorizeData = action.Attributes.OfType(); + var authorizeFilter = new AuthorizeFilter(policyProvider, authorizeData); + + var actionContext = new ActionContext(GetHttpContext(combineAuthorize: true), new RouteData(), new ControllerActionDescriptor()); + + var authorizationFilterContext = new AuthorizationFilterContext(actionContext, action.Filters); + + authorizationFilterContext.Filters.Add(authorizeFilter); + + var secondFilter = new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAssertion(a => true).Build()); + authorizationFilterContext.Filters.Add(secondFilter); + + var thirdFilter = new AuthorizeFilter(policyProvider, authorizeData); + authorizationFilterContext.Filters.Add(thirdFilter); + + // Act + await thirdFilter.OnAuthorizationAsync(authorizationFilterContext); + await thirdFilter.OnAuthorizationAsync(authorizationFilterContext); + + // Assert + Assert.Equal(4, policyProvider.GetPolicyCount); + } + + private HttpContext GetHttpContext(bool combineAuthorize = false) { var httpContext = new DefaultHttpContext(); - httpContext.RequestServices = GetServices(); + httpContext.RequestServices = GetServices(combineAuthorize); return httpContext; } @@ -72,13 +109,13 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests return context; } - private static IServiceProvider GetServices() + private static IServiceProvider GetServices(bool combineAuthorize) { var serviceCollection = new ServiceCollection(); serviceCollection.AddAuthorization(); - serviceCollection.AddMvc(); + serviceCollection.AddMvc(o => o.AllowCombiningAuthorizeFilters = combineAuthorize); serviceCollection - .AddTransient() + .AddSingleton(NullLoggerFactory.Instance) .AddTransient, Logger>() .AddSingleton(); return serviceCollection.BuildServiceProvider(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BindPropertyIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BindPropertyIntegrationTest.cs index 1b42c219f0..d054633f72 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BindPropertyIntegrationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BindPropertyIntegrationTest.cs @@ -1,15 +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 System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Extensions.Primitives; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Mvc.RazorPages.Internal; using Xunit; namespace Microsoft.AspNetCore.Mvc.IntegrationTests @@ -179,6 +181,74 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } } + [Theory] + [InlineData(null, false)] + [InlineData(123, true)] + public async Task BindModelAsync_WithBindPageProperty_EnforcesBindRequired(int? input, bool isValid) + { + // Arrange + var propertyInfo = typeof(TestPage).GetProperty(nameof(TestPage.BindRequiredProperty)); + var propertyDescriptor = new PageBoundPropertyDescriptor + { + BindingInfo = BindingInfo.GetBindingInfo(new[] + { + new FromQueryAttribute { Name = propertyInfo.Name }, + }), + Name = propertyInfo.Name, + ParameterType = propertyInfo.PropertyType, + Property = propertyInfo, + }; + + var typeInfo = typeof(TestPage).GetTypeInfo(); + var actionDescriptor = new CompiledPageActionDescriptor + { + BoundProperties = new[] { propertyDescriptor }, + HandlerTypeInfo = typeInfo, + ModelTypeInfo = typeInfo, + PageTypeInfo = typeInfo, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.Method = "POST"; + if (input.HasValue) + { + request.QueryString = new QueryString($"?{propertyDescriptor.Name}={input.Value}"); + } + }); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + var modelBinderFactory = ModelBindingTestHelper.GetModelBinderFactory(modelMetadataProvider); + var modelMetadata = modelMetadataProvider + .GetMetadataForProperty(typeof(TestPage), propertyDescriptor.Name); + + var pageBinder = PageBinderFactory.CreatePropertyBinder( + parameterBinder, + modelMetadataProvider, + modelBinderFactory, + actionDescriptor); + var pageContext = new PageContext + { + ActionDescriptor = actionDescriptor, + HttpContext = testContext.HttpContext, + RouteData = testContext.RouteData, + ValueProviderFactories = testContext.ValueProviderFactories, + }; + + var page = new TestPage(); + + // Act + await pageBinder(pageContext, page); + + // Assert + Assert.Equal(isValid, pageContext.ModelState.IsValid); + if (isValid) + { + Assert.Equal(input.Value, page.BindRequiredProperty); + } + } + [Theory] [InlineData("RequiredAndStringLengthProp", null, false)] [InlineData("RequiredAndStringLengthProp", "", false)] @@ -231,12 +301,18 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } } - class TestController + private class TestController { [BindNever] public string BindNeverProp { get; set; } [BindRequired] public int BindRequiredProp { get; set; } [Required, StringLength(3)] public string RequiredAndStringLengthProp { get; set; } [DisplayName("My Display Name"), StringLength(3)] public string DisplayNameStringLengthProp { get; set; } } + + private class TestPage : PageModel + { + [BindRequired] + public int BindRequiredProperty { get; set; } + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/CollectionModelBinderIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/CollectionModelBinderIntegrationTest.cs index 7cb0c782d2..aec3949a3d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/CollectionModelBinderIntegrationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/CollectionModelBinderIntegrationTest.cs @@ -333,13 +333,13 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); var error = Assert.Single(entry.Errors); - Assert.Equal("A value for the 'Name' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); entry = Assert.Single(modelState, kvp => kvp.Key == "parameter[1].Name").Value; Assert.Null(entry.RawValue); Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); error = Assert.Single(entry.Errors); - Assert.Equal("A value for the 'Name' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); } [Fact] diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs index e3f693f58a..beffb6d9a1 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs @@ -16,7 +16,7 @@ using Xunit; namespace Microsoft.AspNetCore.Mvc.IntegrationTests { - // Integration tests targeting the behavior of the MutableObjectModelBinder and related classes + // Integration tests targeting the behavior of the ComplexTypeModelBinder and related classes // with other model binders. public class ComplexTypeModelBinderIntegrationTest { @@ -50,7 +50,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -65,10 +64,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -92,7 +100,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithEmptyPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -107,10 +114,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -150,11 +166,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests testContext.MvcOptions.AllowEmptyInputInBodyModelBinding = true; var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -173,13 +197,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Equal("bill", entry.RawValue); } - // We don't provide enough data in this test for the 'Person' model to be created. So even though there is - // body data in the request, it won't be used. [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_PartialData() + public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_PartialData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -194,34 +215,41 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Customer); + Assert.NotNull(model.Customer); + Assert.Equal("1 Microsoft Way", model.Customer.Address.Street); + Assert.Equal(10, model.ProductId); - Assert.Single(modelState); Assert.Equal(0, modelState.ErrorCount); Assert.True(modelState.IsValid); - var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value; + var entry = Assert.Single(modelState).Value; Assert.Equal("10", entry.AttemptedValue); Assert.Equal("10", entry.RawValue); } - // We don't provide enough data in this test for the 'Person' model to be created. So even though there is - // body data in the request, it won't be used. [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoData() + public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -236,16 +264,26 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Customer); + Assert.NotNull(model.Customer); + Assert.Equal("1 Microsoft Way", model.Customer.Address.Street); Assert.Empty(modelState); Assert.Equal(0, modelState.ErrorCount); @@ -270,7 +308,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -285,10 +322,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -315,7 +361,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithEmptyPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -329,10 +374,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -359,7 +413,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_NoData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -373,10 +426,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -413,7 +475,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -428,10 +489,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -458,7 +528,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithEmptyPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -473,10 +542,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -503,7 +581,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoBodyData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -519,10 +596,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -542,13 +628,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Equal("bill", entry.RawValue); } - // We don't provide enough data in this test for the 'Person' model to be created. So even though there are - // form files in the request, it won't be used. [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_PartialData() + public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_PartialData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -563,34 +646,49 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Customer); + Assert.NotNull(model.Customer); + + var document = Assert.Single(model.Customer.Documents); + Assert.Equal("text.txt", document.FileName); + using (var reader = new StreamReader(document.OpenReadStream())) + { + Assert.Equal("Hello, World!", await reader.ReadToEndAsync()); + } + Assert.Equal(10, model.ProductId); - Assert.Single(modelState); + Assert.Equal(2, modelState.Count); Assert.Equal(0, modelState.ErrorCount); Assert.True(modelState.IsValid); + Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents"); var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value; Assert.Equal("10", entry.AttemptedValue); Assert.Equal("10", entry.RawValue); } - // We don't provide enough data in this test for the 'Person' model to be created. So even though there is - // body data in the request, it won't be used. [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoData() + public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -601,24 +699,42 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var testContext = ModelBindingTestHelper.GetTestContext(request => { request.QueryString = new QueryString("?"); - SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents"); + SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents"); }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Customer); + Assert.NotNull(model.Customer); + + var document = Assert.Single(model.Customer.Documents); + Assert.Equal("text.txt", document.FileName); + using (var reader = new StreamReader(document.OpenReadStream())) + { + Assert.Equal("Hello, World!", await reader.ReadToEndAsync()); + } - Assert.Empty(modelState); Assert.Equal(0, modelState.ErrorCount); Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState); + Assert.Equal("Customer.Documents", entry.Key); } private class Order5 @@ -632,7 +748,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsArrayProperty_WithPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -647,10 +762,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -680,7 +804,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsArrayProperty_EmptyPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -694,10 +817,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -727,7 +859,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsArrayProperty_NoCollectionData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -741,10 +872,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -766,7 +906,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsArrayProperty_NoData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -780,10 +919,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -808,7 +956,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsListProperty_WithPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -823,10 +970,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -856,7 +1012,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsListProperty_EmptyPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -870,10 +1025,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -903,7 +1067,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsListProperty_NoCollectionData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -917,10 +1080,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -942,7 +1114,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsListProperty_NoData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -956,10 +1127,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -984,7 +1164,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsDictionaryProperty_WithPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -999,10 +1178,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1032,7 +1220,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsDictionaryProperty_EmptyPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1046,10 +1233,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1079,7 +1275,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsDictionaryProperty_NoCollectionData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1093,10 +1288,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1118,7 +1322,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsDictionaryProperty_NoData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1132,10 +1335,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1181,7 +1393,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsDictionaryProperty_WithIEnumerableComplexTypeValue_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "p", @@ -1201,10 +1412,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1280,7 +1500,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsDictionaryProperty_WithArrayOfComplexTypeValue_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "p", @@ -1300,10 +1519,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1379,7 +1607,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsDictionaryProperty_WithIEnumerableOfKeyValuePair_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "p", @@ -1399,10 +1626,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1485,7 +1721,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_WithPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1500,10 +1735,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1533,7 +1777,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_EmptyPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1547,10 +1790,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1580,7 +1832,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_NoCollectionData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1594,10 +1845,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1619,7 +1879,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_NoData() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1633,10 +1892,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1661,7 +1929,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task Foo_MutableObjectModelBinder_BindsKeyValuePairProperty_WithPrefix_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "p", @@ -1682,10 +1949,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1752,7 +2028,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsNestedPOCO_WithAllGreedyBoundProperties() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1767,10 +2042,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1801,7 +2085,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_WithRequiredComplexProperty_NoData_GetsErrors() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1812,10 +2095,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var testContext = ModelBindingTestHelper.GetTestContext(); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1831,7 +2123,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Null(entry.AttemptedValue); var error = Assert.Single(modelState["Customer"].Errors); - Assert.Equal("A value for the 'Customer' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'Customer' parameter or property was not provided.", error.ErrorMessage); } [Fact] @@ -1848,7 +2140,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests name => $"Hurts when '{ name }' is not provided."); })); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(metadataProvider); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1856,13 +2147,22 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }; // No Data - var testContext = ModelBindingTestHelper.GetTestContext(); + var testContext = ModelBindingTestHelper.GetTestContext(metadataProvider: metadataProvider); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1898,7 +2198,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_WithNestedRequiredProperty_WithPartialData_GetsErrors() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1912,10 +2211,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1937,14 +2245,13 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Null(entry.AttemptedValue); var error = Assert.Single(modelState["parameter.Customer.Name"].Errors); - Assert.Equal("A value for the 'Name' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); } [Fact] public async Task MutableObjectModelBinder_WithNestedRequiredProperty_WithData_EmptyPrefix_GetsErrors() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -1958,10 +2265,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -1983,14 +2299,13 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Null(entry.AttemptedValue); var error = Assert.Single(modelState["Customer.Name"].Errors); - Assert.Equal("A value for the 'Name' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); } [Fact] public async Task MutableObjectModelBinder_WithNestedRequiredProperty_WithData_CustomPrefix_GetsErrors() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2008,10 +2323,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2033,7 +2357,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Null(entry.AttemptedValue); var error = Assert.Single(modelState["customParameter.Customer.Name"].Errors); - Assert.Equal("A value for the 'Name' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); } private class Order12 @@ -2046,7 +2370,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_WithRequiredProperty_NoData_GetsErrors() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2060,10 +2383,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2079,14 +2411,13 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Null(entry.AttemptedValue); var error = Assert.Single(modelState["ProductName"].Errors); - Assert.Equal("A value for the 'ProductName' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage); } [Fact] - public async Task MutableObjectModelBinder_WithRequiredProperty_NoData_CustomPrefix_GetsErros() + public async Task MutableObjectModelBinder_WithRequiredProperty_NoData_CustomPrefix_GetsErrors() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2104,10 +2435,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2123,14 +2463,13 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Null(entry.AttemptedValue); var error = Assert.Single(modelState["customParameter.ProductName"].Errors); - Assert.Equal("A value for the 'ProductName' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage); } [Fact] public async Task MutableObjectModelBinder_WithRequiredProperty_WithData_EmptyPrefix_GetsBound() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2144,10 +2483,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2171,10 +2519,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } [Fact] - public async Task MutableObjectModelBinder_WithRequiredCollectionProperty_NoData_GetsErros() + public async Task MutableObjectModelBinder_WithRequiredCollectionProperty_NoData_GetsErrors() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2188,10 +2535,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2207,14 +2563,13 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Null(entry.AttemptedValue); var error = Assert.Single(modelState["OrderIds"].Errors); - Assert.Equal("A value for the 'OrderIds' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage); } [Fact] - public async Task MutableObjectModelBinder_WithRequiredCollectionProperty_NoData_CustomPrefix_GetsErros() + public async Task MutableObjectModelBinder_WithRequiredCollectionProperty_NoData_CustomPrefix_GetsErrors() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2232,10 +2587,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2251,31 +2615,38 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Null(entry.RawValue); Assert.Null(entry.AttemptedValue); var error = Assert.Single(modelState["customParameter.OrderIds"].Errors); - Assert.Equal("A value for the 'OrderIds' property was not provided.", error.ErrorMessage); + Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage); } [Fact] public async Task MutableObjectModelBinder_WithRequiredCollectionProperty_WithData_EmptyPrefix_GetsBound() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", ParameterType = typeof(Order13), }; - // No Data var testContext = ModelBindingTestHelper.GetTestContext(request => { request.QueryString = new QueryString("?OrderIds[0]=123"); }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2300,10 +2671,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests // This covers the case where a key is present, but has an empty value. The type converter // will report an error. [Fact] - public async Task MutableObjectModelBinder_BindsPOCO_TypeConvertedPropertyNonConvertableValue_GetsError() + public async Task MutableObjectModelBinder_BindsPOCO_TypeConvertedPropertyNonConvertibleValue_GetsError() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2317,10 +2687,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2349,7 +2728,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsPOCO_TypeConvertedPropertyWithEmptyValue_Error() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2363,10 +2741,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2408,7 +2795,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task ModelNameOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo) { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor { Name = "parameter-name", @@ -2418,11 +2804,21 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var testContext = ModelBindingTestHelper.GetTestContext( request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet")); + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2448,7 +2844,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task ModelNameOnParameterType_WithData_Succeeds(BindingInfo bindingInfo) { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor { Name = "parameter-name", @@ -2458,11 +2853,21 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var testContext = ModelBindingTestHelper.GetTestContext( request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet")); + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2506,7 +2911,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task BindAttributeOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo) { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor { Name = "parameter-name", @@ -2517,11 +2921,21 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var testContext = ModelBindingTestHelper.GetTestContext( request => request.QueryString = new QueryString( "?Address.Number=23&Address.Street=someStreet&Address.City=Redmond&Address.State=WA")); + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2552,7 +2966,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task BindAttributeOnParameterType_WithData_Succeeds(BindingInfo bindingInfo) { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor { Name = "parameter-name", @@ -2562,11 +2975,21 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var testContext = ModelBindingTestHelper.GetTestContext( request => request.QueryString = new QueryString("?Number=23&Street=someStreet&City=Redmond&State=WA")); + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2601,7 +3024,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task ComplexTypeModelBinder_BindsSettableProperties(string queryString) { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2614,10 +3036,20 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests request.QueryString = new QueryString(queryString); SetJsonBodyContent(request, AddressBodyContent); }); + + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2648,7 +3080,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_HavingFromHeaderProperty_Success() { // Arrange - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { Name = "parameter", @@ -2663,10 +3094,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests }); var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); // Act - var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter); + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); // Assert Assert.True(modelBindingResult.IsModelSet); @@ -2724,5 +3164,28 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Headers = request.Headers }); } + + private ModelMetadata GetMetadata(ModelBindingTestContext context, ParameterDescriptor parameter) + { + return context.MetadataProvider.GetMetadataForType(parameter.ParameterType); + } + + private IModelBinder GetModelBinder( + ModelBindingTestContext context, + ParameterDescriptor parameter, + ModelMetadata metadata) + { + var factory = ModelBindingTestHelper.GetModelBinderFactory( + context.MetadataProvider, + context.HttpContext.RequestServices); + var factoryContext = new ModelBinderFactoryContext + { + BindingInfo = parameter.BindingInfo, + CacheToken = parameter, + Metadata = metadata, + }; + + return factory.CreateBinder(factoryContext); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/HasValidatorsValidationMetadataProviderIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/HasValidatorsValidationMetadataProviderIntegrationTest.cs new file mode 100644 index 0000000000..102ddff705 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/Microsoft.AspNetCore.Mvc.IntegrationTests.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/Microsoft.AspNetCore.Mvc.IntegrationTests.csproj index 8e52e2a34f..be6dd3242f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/Microsoft.AspNetCore.Mvc.IntegrationTests.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/Microsoft.AspNetCore.Mvc.IntegrationTests.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) @@ -6,9 +6,8 @@ - + - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestHelper.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestHelper.cs index 6aa9da7cf0..3a882567d0 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestHelper.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestHelper.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Routing; @@ -64,6 +63,11 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } } + public static ParameterBinder GetParameterBinder(ModelBindingTestContext testContext) + { + return GetParameterBinder(testContext.HttpContext.RequestServices); + } + public static ParameterBinder GetParameterBinder(IServiceProvider serviceProvider) { var metadataProvider = serviceProvider.GetRequiredService(); @@ -74,7 +78,8 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests new ModelBinderFactory(metadataProvider, options, serviceProvider), new DefaultObjectValidator( metadataProvider, - new[] { new CompositeModelValidatorProvider(GetModelValidatorProviders(options)) }), + new[] { new CompositeModelValidatorProvider(GetModelValidatorProviders(options)) }, + options.Value), options, NullLoggerFactory.Instance); } @@ -97,7 +102,8 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests new ModelBinderFactory(metadataProvider, options, services), new DefaultObjectValidator( metadataProvider, - new[] { new CompositeModelValidatorProvider(GetModelValidatorProviders(options)) }), + new[] { new CompositeModelValidatorProvider(GetModelValidatorProviders(options)) }, + options.Value), options, NullLoggerFactory.Instance); } @@ -122,7 +128,8 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests { return new DefaultObjectValidator( metadataProvider, - GetModelValidatorProviders(options)); + GetModelValidatorProviders(options), + options?.Value ?? new MvcOptions { AllowShortCircuitingValidationWhenNoValidatorsArePresent = true }); } private static IList GetModelValidatorProviders(IOptions options) @@ -189,8 +196,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); serviceCollection .AddSingleton() - .AddTransient() - .AddTransient, Logger>(); + .AddSingleton(NullLoggerFactory.Instance) + .AddTransient, Logger>() + .Configure(options => options.AllowShortCircuitingValidationWhenNoValidatorsArePresent = true); if (updateOptions != null) { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/SimpleTypeModelBinderIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/SimpleTypeModelBinderIntegrationTest.cs index 420939c66b..0999087bec 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/SimpleTypeModelBinderIntegrationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/SimpleTypeModelBinderIntegrationTest.cs @@ -241,7 +241,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } [Fact] - public async Task BindParameter_NonConvertableValue_GetsError() + public async Task BindParameter_NonConvertibleValue_GetsError() { // Arrange var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); @@ -290,7 +290,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } [Fact] - public async Task BindParameter_NonConvertableValue_GetsCustomErrorMessage() + public async Task BindParameter_NonConvertibleValue_GetsCustomErrorMessage() { // Arrange var parameterType = typeof(int); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TestMvcOptions.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TestMvcOptions.cs index ef11bd7f1c..615e8685cb 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TestMvcOptions.cs +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs index 200d95914d..0bf1131a36 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs +++ b/src/Mvc/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/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryValidateModelIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryValidateModelIntegrationTest.cs index 68f63432a0..cae150842f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryValidateModelIntegrationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/TryValidateModelIntegrationTest.cs @@ -115,7 +115,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests var categoryRequired = ValidationAttributeUtil.GetRequiredErrorMessage("Category"); var priceRange = ValidationAttributeUtil.GetRangeErrorMessage(20, 100, "Price"); var contactUsMax = ValidationAttributeUtil.GetStringLengthErrorMessage(null, 20, "Contact Us"); - var contactusRegEx = ValidationAttributeUtil.GetRegExErrorMessage("^[0-9]*$", "Contact Us"); + var contactUsRegEx = ValidationAttributeUtil.GetRegExErrorMessage("^[0-9]*$", "Contact Us"); // Act var result = controller.TryValidateModel(model); @@ -128,11 +128,11 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Equal("CompanyName cannot be null or empty.", modelStateErrors["[0].CompanyName"]); Assert.Equal(priceRange, modelStateErrors["[0].Price"]); Assert.Equal(categoryRequired, modelStateErrors["[0].Category"]); - AssertErrorEquals(contactUsMax + contactusRegEx, modelStateErrors["[0].Contact"]); + AssertErrorEquals(contactUsMax + contactUsRegEx, modelStateErrors["[0].Contact"]); Assert.Equal("CompanyName cannot be null or empty.", modelStateErrors["[1].CompanyName"]); Assert.Equal(priceRange, modelStateErrors["[1].Price"]); Assert.Equal(categoryRequired, modelStateErrors["[1].Category"]); - AssertErrorEquals(contactUsMax + contactusRegEx, modelStateErrors["[1].Contact"]); + AssertErrorEquals(contactUsMax + contactUsRegEx, modelStateErrors["[1].Contact"]); } [Fact] @@ -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/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ValidationIntegrationTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ValidationIntegrationTests.cs index e5df3d1233..25deb2477d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ValidationIntegrationTests.cs +++ b/src/Mvc/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; @@ -1023,7 +1028,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } [Fact] - public async Task Validation_StringLengthAttribute_OnProperyOfCollectionElement_Valid() + public async Task Validation_StringLengthAttribute_OnPropertyOfCollectionElement_Valid() { // Arrange var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); @@ -1060,7 +1065,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } [Fact] - public async Task Validation_StringLengthAttribute_OnProperyOfCollectionElement_Invalid() + public async Task Validation_StringLengthAttribute_OnPropertyOfCollectionElement_Invalid() { // Arrange var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); @@ -1100,7 +1105,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } [Fact] - public async Task Validation_StringLengthAttribute_OnProperyOfCollectionElement_NoData() + public async Task Validation_StringLengthAttribute_OnPropertyOfCollectionElement_NoData() { // Arrange var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); @@ -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; } @@ -1701,7 +1754,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests // basically result in clearing out all model errors, which is BAD. // // The fix is to treat non-user-input as have a key of null, which means that the MSD - // isn't even examined when it comes to supressing validation. + // isn't even examined when it comes to suppressing validation. [Fact] public async Task CancellationToken_WithEmptyPrefix_DoesNotSuppressUnrelatedErrors() { @@ -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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/Microsoft.AspNetCore.Mvc.Localization.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/Microsoft.AspNetCore.Mvc.Localization.Test.csproj index 1ca6c1dee7..28b41219a9 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/Microsoft.AspNetCore.Mvc.Localization.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/Microsoft.AspNetCore.Mvc.Localization.Test.csproj @@ -1,14 +1,12 @@ - + $(StandardTestTfms) - - - - + + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationMvcBuilderExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationMvcBuilderExtensionsTest.cs index 1b0b4e947f..8b6fe4bebe 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationMvcBuilderExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationMvcBuilderExtensionsTest.cs @@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; @@ -104,6 +106,9 @@ namespace Microsoft.AspNetCore.Mvc.Localization.Test { // Arrange var builder = new TestMvcBuilder(); + + builder.Services.AddSingleton(NullLoggerFactory.Instance); + var dataAnnotationLocalizerProvider = new Func((type, factory) => { return null; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationMvcCoreBuilderExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationMvcCoreBuilderExtensionsTest.cs index 1412d35c94..3956d79f17 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationMvcCoreBuilderExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationMvcCoreBuilderExtensionsTest.cs @@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; @@ -104,6 +106,9 @@ namespace Microsoft.AspNetCore.Mvc.Localization.Test { // Arrange var builder = new TestMvcCoreBuilder(); + + builder.Services.AddSingleton(NullLoggerFactory.Instance); + var dataAnnotationLocalizerProvider = new Func((type, factory) => { return null; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/Internal/MvcLocalizationServiceCollectionExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationServiceCollectionExtensionsTest.cs similarity index 82% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/Internal/MvcLocalizationServiceCollectionExtensionsTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationServiceCollectionExtensionsTest.cs index 50f4c7626a..f67e5f466d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/Internal/MvcLocalizationServiceCollectionExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Localization.Test/MvcLocalizationServiceCollectionExtensionsTest.cs @@ -5,16 +5,13 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Text.Encodings.Web; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.WebEncoders.Testing; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Localization.Internal +namespace Microsoft.AspNetCore.Mvc.Localization { public class MvcLocalizationServicesTest { @@ -27,8 +24,7 @@ namespace Microsoft.AspNetCore.Mvc.Localization.Internal // Act MvcLocalizationServices.AddMvcViewLocalizationServices( collection, - LanguageViewLocationExpanderFormat.Suffix, - setupAction: null); + LanguageViewLocationExpanderFormat.Suffix); // Assert AssertContainsSingle(collection, typeof(IHtmlLocalizerFactory), typeof(HtmlLocalizerFactory)); @@ -49,8 +45,7 @@ namespace Microsoft.AspNetCore.Mvc.Localization.Internal MvcLocalizationServices.AddMvcViewLocalizationServices( collection, - LanguageViewLocationExpanderFormat.Suffix, - setupAction: null); + LanguageViewLocationExpanderFormat.Suffix); AssertContainsSingle(collection, typeof(IHtmlLocalizerFactory), typeof(TestHtmlLocalizerFactory)); AssertContainsSingle(collection, typeof(IHtmlLocalizer<>), typeof(TestHtmlLocalizer<>)); @@ -84,21 +79,10 @@ namespace Microsoft.AspNetCore.Mvc.Localization.Internal public class TestViewLocalizer : IViewLocalizer { - public LocalizedHtmlString this[string name] - { - get - { - throw new NotImplementedException(); - } - } + public LocalizedHtmlString this[string name] => throw new NotImplementedException(); public LocalizedHtmlString this[string name, params object[] arguments] - { - get - { - throw new NotImplementedException(); - } - } + => throw new NotImplementedException(); public LocalizedString GetString(string name) { @@ -123,21 +107,10 @@ namespace Microsoft.AspNetCore.Mvc.Localization.Internal public class TestHtmlLocalizer : IHtmlLocalizer { - public LocalizedHtmlString this[string name] - { - get - { - throw new NotImplementedException(); - } - } + public LocalizedHtmlString this[string name] => throw new NotImplementedException(); public LocalizedHtmlString this[string name, params object[] arguments] - { - get - { - throw new NotImplementedException(); - } - } + => throw new NotImplementedException(); public LocalizedString GetString(string name) { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Compilation/MetadataReferenceFeatureProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Compilation/MetadataReferenceFeatureProviderTest.cs index fe05fdb561..8f9653c70d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Compilation/MetadataReferenceFeatureProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Compilation/MetadataReferenceFeatureProviderTest.cs @@ -8,6 +8,7 @@ using Xunit; namespace Microsoft.AspNetCore.Mvc.Razor.Compilation { +#pragma warning disable CS0618 // Type or member is obsolete public class MetadataReferenceFeatureProviderTest { [Fact] @@ -48,4 +49,5 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation reference => reference.Display.Equals(currentAssembly.Location)); } } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/DependencyInjection/MvcRazorMvcBuilderExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/DependencyInjection/MvcRazorMvcBuilderExtensionsTest.cs index eecee7664c..858de6a312 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/DependencyInjection/MvcRazorMvcBuilderExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/DependencyInjection/MvcRazorMvcBuilderExtensionsTest.cs @@ -1,13 +1,11 @@ // 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; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; -using Microsoft.AspNetCore.Razor.Runtime.TagHelpers; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -21,12 +19,11 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test.DependencyInjection { // Arrange var services = new ServiceCollection(); - var builder = services - .AddMvc() - .ConfigureApplicationPartManager(manager => - { - manager.ApplicationParts.Add(new TestApplicationPart()); - }); + + var manager = new ApplicationPartManager(); + manager.ApplicationParts.Add(new TestApplicationPart()); + + var builder = new MvcBuilder(services, manager); // Act builder.AddTagHelpersAsServices(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/DependencyInjection/MvcRazorMvcCoreBuilderExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/DependencyInjection/MvcRazorMvcCoreBuilderExtensionsTest.cs index 6ea2e2afb5..bb0ae3360b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/DependencyInjection/MvcRazorMvcCoreBuilderExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/DependencyInjection/MvcRazorMvcCoreBuilderExtensionsTest.cs @@ -66,7 +66,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test.DependencyInjection builder.AddRazorViewEngine(); // Assert +#pragma warning disable CS0618 // Type or member is obsolete Assert.Single(builder.PartManager.FeatureProviders.OfType()); +#pragma warning restore CS0618 // Type or member is obsolete } [Fact] @@ -83,7 +85,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test.DependencyInjection builder.AddRazorViewEngine(); // Assert +#pragma warning disable CS0618 // Type or member is obsolete Assert.Single(builder.PartManager.FeatureProviders.OfType()); +#pragma warning restore CS0618 // Type or member is obsolete } [Fact] @@ -92,7 +96,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test.DependencyInjection // Arrange var services = new ServiceCollection(); var builder = services.AddMvcCore(); +#pragma warning disable CS0618 // Type or member is obsolete var metadataReferenceFeatureProvider = new MetadataReferenceFeatureProvider(); +#pragma warning restore CS0618 // Type or member is obsolete builder.PartManager.FeatureProviders.Add(metadataReferenceFeatureProvider); // Act @@ -100,7 +106,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test.DependencyInjection // Assert var actual = Assert.Single( - builder.PartManager.FeatureProviders.OfType()); +#pragma warning disable CS0618 // Type or member is obsolete + collection: builder.PartManager.FeatureProviders.OfType()); +#pragma warning restore CS0618 // Type or member is obsolete Assert.Same(metadataReferenceFeatureProvider, actual); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/CSharpCompilerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/CSharpCompilerTest.cs index 3faddaa0e6..8c3440db2a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/CSharpCompilerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/CSharpCompilerTest.cs @@ -17,6 +17,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal { public class CSharpCompilerTest { +#pragma warning disable CS0618 // Type or member is obsolete + private readonly RazorReferenceManager ReferenceManager = Mock.Of(); +#pragma warning restore CS0618 // Type or member is obsolete + [Theory] [InlineData(null)] [InlineData("")] @@ -24,8 +28,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal { // Arrange var hostingEnvironment = Mock.Of(e => e.ApplicationName == name); - var referenceManager = Mock.Of(); - var compiler = new CSharpCompiler(referenceManager, hostingEnvironment); + var compiler = new CSharpCompiler(ReferenceManager, hostingEnvironment); // Act var options = compiler.GetDependencyContextCompilationOptions(); @@ -41,8 +44,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var hostingEnvironment = new Mock(); hostingEnvironment.SetupGet(e => e.ApplicationName) .Returns(typeof(Controller).GetTypeInfo().Assembly.GetName().Name); - var referenceManager = Mock.Of(); - var compiler = new CSharpCompiler(referenceManager, hostingEnvironment.Object); + var compiler = new CSharpCompiler(ReferenceManager, hostingEnvironment.Object); // Act var options = compiler.GetDependencyContextCompilationOptions(); @@ -58,7 +60,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var hostingEnvironment = new Mock(); hostingEnvironment.SetupGet(e => e.ApplicationName) .Returns(GetType().GetTypeInfo().Assembly.GetName().Name); - var compiler = new CSharpCompiler(Mock.Of(), hostingEnvironment.Object); + var compiler = new CSharpCompiler(ReferenceManager, hostingEnvironment.Object); // Act & Assert var parseOptions = compiler.ParseOptions; @@ -78,7 +80,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var hostingEnvironment = new Mock(); hostingEnvironment.SetupGet(e => e.EnvironmentName) .Returns(environment); - var compiler = new CSharpCompiler(Mock.Of(), hostingEnvironment.Object); + var compiler = new CSharpCompiler(ReferenceManager, hostingEnvironment.Object); // Act & Assert var compilationOptions = compiler.CSharpCompilationOptions; @@ -96,7 +98,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var hostingEnvironment = new Mock(); hostingEnvironment.SetupGet(e => e.EnvironmentName) .Returns(environment); - var compiler = new CSharpCompiler(Mock.Of(), hostingEnvironment.Object); + var compiler = new CSharpCompiler(ReferenceManager, hostingEnvironment.Object); // Act & Assert var parseOptions = compiler.ParseOptions; @@ -108,7 +110,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal { // Arrange var hostingEnvironment = Mock.Of(h => h.EnvironmentName == "Development"); - var compiler = new CSharpCompiler(Mock.Of(), hostingEnvironment); + var compiler = new CSharpCompiler(ReferenceManager, hostingEnvironment); // Act & Assert var compilationOptions = compiler.CSharpCompilationOptions; @@ -138,7 +140,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal { // Arrange var hostingEnvironment = Mock.Of(h => h.EnvironmentName == "Development"); - var compiler = new CSharpCompiler(Mock.Of(), hostingEnvironment); + var compiler = new CSharpCompiler(ReferenceManager, hostingEnvironment); // Act & Assert var parseOptions = compiler.ParseOptions; @@ -163,10 +165,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: null, emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act & Assert var compilationOptions = compiler.ParseOptions; @@ -191,10 +192,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: "portable", emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act & Assert var emitOptions = compiler.EmitOptions; @@ -219,10 +219,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: "embedded", emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act & Assert var emitOptions = compiler.EmitOptions; @@ -247,10 +246,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: "none", emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act & Assert Assert.False(compiler.EmitPdb); @@ -273,10 +271,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: null, emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act & Assert var compilationOptions = compiler.CSharpCompilationOptions; @@ -300,10 +297,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: null, emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act & Assert var compilationOptions = compiler.CSharpCompilationOptions; @@ -327,10 +323,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: null, emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act & Assert var compilationOptions = compiler.CSharpCompilationOptions; @@ -354,10 +349,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: null, emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act & Assert var parseOptions = compiler.ParseOptions; @@ -383,9 +377,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal debugType: null, emitEntryPoint: null, generateXmlDocumentation: null); - var referenceManager = Mock.Of(); var hostingEnvironment = Mock.Of(); - var compiler = new TestCSharpCompiler(referenceManager, hostingEnvironment, dependencyContextOptions); + var compiler = new TestCSharpCompiler(ReferenceManager, hostingEnvironment, dependencyContextOptions); // Act var syntaxTree = compiler.CreateSyntaxTree(SourceText.From(content)); @@ -399,7 +392,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal private readonly DependencyContextCompilationOptions _options; public TestCSharpCompiler( +#pragma warning disable CS0618 // Type or member is obsolete RazorReferenceManager referenceManager, +#pragma warning restore CS0618 // Type or member is obsolete IHostingEnvironment hostingEnvironment, DependencyContextCompilationOptions options) : base(referenceManager, hostingEnvironment) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/CompilerFailedExceptionFactoryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/CompilerFailedExceptionFactoryTest.cs index 05e156a6ef..394bc2ae7d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/CompilerFailedExceptionFactoryTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/CompilerFailedExceptionFactoryTest.cs @@ -2,10 +2,12 @@ // 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.Text; using Microsoft.AspNetCore.Mvc.Razor.Extensions; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; using Xunit; @@ -43,6 +45,29 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal message.Message)); } + [Fact] + public void GetCompilationFailedResult_WithMissingReferences() + { + // Arrange + var expected = "One or more compilation references may be missing. If you're seeing this in a published application, set 'CopyRefAssembliesToPublishDirectory' to true in your project file to ensure files in the refs directory are published."; + var compilation = CSharpCompilation.Create("Test", options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var syntaxTree = CSharpSyntaxTree.ParseText("@class Test { public string Test { get; set; } }"); + compilation = compilation.AddSyntaxTrees(syntaxTree); + var emitResult = compilation.Emit(new MemoryStream()); + + // Act + var exception = CompilationFailedExceptionFactory.Create( + RazorCodeDocument.Create(RazorSourceDocument.Create("Test", "Index.cshtml"), Enumerable.Empty()), + syntaxTree.ToString(), + "Test", + emitResult.Diagnostics); + + // Assert + Assert.Collection( + exception.CompilationFailures, + failure => Assert.Equal(expected, failure.FailureSummary)); + } + [Fact] public void GetCompilationFailedResult_UsesPhysicalPath() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/DefaultRazorReferenceManagerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/DefaultRazorReferenceManagerTest.cs index dc8cd4a3d0..c6435cd36e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/DefaultRazorReferenceManagerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/DefaultRazorReferenceManagerTest.cs @@ -12,6 +12,7 @@ using Xunit; namespace Microsoft.AspNetCore.Mvc.Razor.Test.Internal { +#pragma warning disable CS0618 // Type or member is obsolete public class DefaultRazorReferenceManagerTest { [Fact] @@ -52,4 +53,5 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test.Internal return applicationPartManager; } } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorPagePropertyActivatorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorPagePropertyActivatorTest.cs index c0221fc68d..c1c68a7d8d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorPagePropertyActivatorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorPagePropertyActivatorTest.cs @@ -17,7 +17,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var activator = new RazorPagePropertyActivator( typeof(TestPage), typeof(TestModel), - new TestModelMetadataProvider(), + new EmptyModelMetadataProvider(), propertyValueAccessors: null); var viewContext = new ViewContext(); @@ -36,7 +36,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var activator = new RazorPagePropertyActivator( typeof(TestPage), declaredModelType: null, - metadataProvider: new TestModelMetadataProvider(), + metadataProvider: new EmptyModelMetadataProvider(), propertyValueAccessors: null); var viewContext = new ViewContext(); @@ -52,7 +52,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal public void CreateViewDataDictionary_CreatesNestedViewDataDictionary_WhenContextInstanceIsNonGeneric() { // Arrange - var modelMetadataProvider = new TestModelMetadataProvider(); + var modelMetadataProvider = new EmptyModelMetadataProvider(); var activator = new RazorPagePropertyActivator( typeof(TestPage), declaredModelType: typeof(TestModel), @@ -81,7 +81,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal public void CreateViewDataDictionary_UsesDeclaredTypeOverModelType_WhenCreatingTheViewDataDictionary() { // Arrange - var modelMetadataProvider = new TestModelMetadataProvider(); + var modelMetadataProvider = new EmptyModelMetadataProvider(); var activator = new RazorPagePropertyActivator( typeof(TestPage), declaredModelType: typeof(TestModel), @@ -110,7 +110,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal public void CreateViewDataDictionary_CreatesNestedViewDataDictionary_WhenModelTypeDoesNotMatch() { // Arrange - var modelMetadataProvider = new TestModelMetadataProvider(); + var modelMetadataProvider = new EmptyModelMetadataProvider(); var activator = new RazorPagePropertyActivator( typeof(TestPage), declaredModelType: typeof(TestModel), @@ -139,7 +139,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal public void CreateViewDataDictionary_CreatesNestedViewDataDictionary_WhenNullModelTypeDoesNotMatch() { // Arrange - var modelMetadataProvider = new TestModelMetadataProvider(); + var modelMetadataProvider = new EmptyModelMetadataProvider(); var activator = new RazorPagePropertyActivator( typeof(TestPage), declaredModelType: null, @@ -168,7 +168,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal public void CreateViewDataDictionary_ReturnsInstanceOnContext_IfModelTypeMatches() { // Arrange - var modelMetadataProvider = new TestModelMetadataProvider(); + var modelMetadataProvider = new EmptyModelMetadataProvider(); var activator = new RazorPagePropertyActivator( typeof(TestPage), declaredModelType: typeof(TestModel), @@ -195,7 +195,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal public void CreateViewDataDictionary_ReturnsInstanceOnContext_WithNullModelType() { // Arrange - var modelMetadataProvider = new TestModelMetadataProvider(); + var modelMetadataProvider = new EmptyModelMetadataProvider(); var activator = new RazorPagePropertyActivator( typeof(TestPage), declaredModelType: null, diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerProviderTest.cs index c00237b990..9b3656aaee 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerProviderTest.cs @@ -4,6 +4,7 @@ using System; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.AspNetCore.Razor.Language; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging.Abstractions; @@ -39,6 +40,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal accessor, new CSharpCompiler(referenceManager, Mock.Of()), options, + new RazorViewCompilationMemoryCacheProvider(), NullLoggerFactory.Instance); // Act & Assert diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs index c4df0280aa..ce99154a00 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs @@ -13,6 +13,8 @@ using Microsoft.AspNetCore.Razor.Hosting; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Emit; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; @@ -35,6 +37,25 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var result1 = await viewCompiler.CompileAsync(path); var result2 = await viewCompiler.CompileAsync(path); + // Assert + Assert.Same(result1, result2); + Assert.Null(result1.ViewAttribute); + Assert.Empty(result1.ExpirationTokens); + } + + [Fact] + public async Task CompileAsync_ReturnsResultWithExpirationToken_WhenWatchingForFileChanges() + { + // Arrange + var path = "/file/does-not-exist"; + var fileProvider = new TestFileProvider(); + var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; + + // Act + var result1 = await viewCompiler.CompileAsync(path); + var result2 = await viewCompiler.CompileAsync(path); + // Assert Assert.Same(result1, result2); Assert.Null(result1.ViewAttribute); @@ -55,6 +76,24 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal // Act var result = await viewCompiler.CompileAsync(path); + // Assert + Assert.NotNull(result.ViewAttribute); + Assert.Empty(result.ExpirationTokens); + } + + [Fact] + public async Task CompileAsync_AddsChangeTokensForViewStartsIfFileExists_WhenWatchingForFileChanges() + { + // Arrange + var path = "/file/exists/FilePath.cshtml"; + var fileProvider = new TestFileProvider(); + fileProvider.AddFile(path, "Content"); + var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; + + // Act + var result = await viewCompiler.CompileAsync(path); + // Assert Assert.NotNull(result.ViewAttribute); Assert.Collection( @@ -70,7 +109,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal [InlineData(@"Areas\Finances\Views\Home\Index.cshtml")] [InlineData(@"\Areas\Finances\Views\Home\Index.cshtml")] [InlineData(@"\Areas\Finances\Views/Home\Index.cshtml")] - public async Task CompileAsync_NormalizesPathSepartorForPaths(string relativePath) + public async Task CompileAsync_NormalizesPathSeparatorForPaths(string relativePath) { // Arrange var viewPath = "/Areas/Finances/Views/Home/Index.cshtml"; @@ -90,13 +129,40 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal } [Fact] - public async Task CompileAsync_InvalidatesCache_IfChangeTokenExpires() + public async Task CompileAsync_DoesNotInvalidCache_IfChangeTokenChanges() { // Arrange var path = "/Views/Home/Index.cshtml"; var fileProvider = new TestFileProvider(); var fileInfo = fileProvider.AddFile(path, "some content"); var viewCompiler = GetViewCompiler(fileProvider); + var changeToken = fileProvider.Watch(path); + + // Act 1 + var result1 = await viewCompiler.CompileAsync(path); + + // Assert 1 + Assert.NotNull(result1.ViewAttribute); + + // Act 2 + fileProvider.DeleteFile(path); + fileProvider.GetChangeToken(path).HasChanged = true; + viewCompiler.Compile = _ => throw new Exception("Can't call me"); + var result2 = await viewCompiler.CompileAsync(path); + + // Assert 2 + Assert.Same(result1, result2); + } + + [Fact] + public async Task CompileAsync_InvalidatesCache_IfChangeTokenExpires_WhenWatchingForFileChanges() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; // Act 1 var result1 = await viewCompiler.CompileAsync(path); @@ -123,6 +189,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var fileProvider = new TestFileProvider(); var fileInfo = fileProvider.AddFile(path, "some content"); var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; var expected2 = new CompiledViewDescriptor(); // Act 1 @@ -149,6 +216,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var fileProvider = new TestFileProvider(); var fileInfo = fileProvider.AddFile(path, "some content"); var viewCompiler = GetViewCompiler(fileProvider); + viewCompiler.AllowRecompilingViewsOnFileChange = true; var expected2 = new CompiledViewDescriptor(); // Act 1 @@ -325,6 +393,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; // Act var result = await viewCompiler.CompileAsync(path); @@ -369,7 +438,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal } [Fact] - public async Task CompileAsync_PrecompiledViewWithChecksum_CanRecompile() + public async Task CompileAsync_PrecompiledViewWithChecksum_DoesNotAddExpirationTokens() { // Arrange var path = "/Views/Home/Index.cshtml"; @@ -390,11 +459,43 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + // Act + var result = await viewCompiler.CompileAsync(path); + + // Assert + Assert.Same(precompiledView.Item, result.Item); + Assert.Empty(result.ExpirationTokens); + } + + [Fact] + public async Task CompileAsync_PrecompiledViewWithChecksum_CanRecompile() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + + var expected2 = new CompiledViewDescriptor(); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), path), + }), + }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; + // Act - 1 var result = await viewCompiler.CompileAsync(path); // Assert - 1 Assert.Same(precompiledView.Item, result.Item); + Assert.NotEmpty(result.ExpirationTokens); // Act - 2 fileInfo.Content = "some other content"; @@ -425,6 +526,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; // Act - 1 var result = await viewCompiler.CompileAsync(path); @@ -461,6 +563,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; viewCompiler.Compile = _ => expected1; // Act - 1 @@ -502,6 +605,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.AllowRecompilingViewsOnFileChange = true; // Act - 1 var result = await viewCompiler.CompileAsync(path); @@ -729,7 +833,7 @@ this should fail"; } [Fact] - public void Compile_InvokessCallback() + public void Compile_InvokesCallback() { // Arrange var content = "public class MyTestType {}"; @@ -822,7 +926,9 @@ this should fail"; private static TestRazorViewCompiler GetViewCompiler( TestFileProvider fileProvider = null, Action compilationCallback = null, +#pragma warning disable CS0618 // Type or member is obsolete RazorReferenceManager referenceManager = null, +#pragma warning restore CS0618 // Type or member is obsolete IList precompiledViews = null, CSharpCompiler csharpCompiler = null) { @@ -856,6 +962,7 @@ this should fail"; return viewCompiler; } +#pragma warning disable CS0618 // Type or member is obsolete private static RazorReferenceManager CreateReferenceManager(IOptions options) { var applicationPartManager = new ApplicationPartManager(); @@ -865,6 +972,7 @@ this should fail"; return new DefaultRazorReferenceManager(applicationPartManager, options); } +#pragma warning restore CS0618 // Type or member is obsolete private class TestRazorViewCompiler : RazorViewCompiler { @@ -875,7 +983,7 @@ this should fail"; Action compilationCallback, IList precompiledViews, Func compile = null) : - base(fileProvider, projectEngine, csharpCompiler, compilationCallback, precompiledViews, NullLogger.Instance) + base(fileProvider, projectEngine, csharpCompiler, compilationCallback, precompiledViews, new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance) { Compile = compile; if (Compile == null) @@ -898,7 +1006,9 @@ this should fail"; private class TestCSharpCompiler : CSharpCompiler { +#pragma warning disable CS0618 // Type or member is obsolete public TestCSharpCompiler(RazorReferenceManager manager, IHostingEnvironment hostingEnvironment) +#pragma warning restore CS0618 // Type or member is obsolete : base(manager, hostingEnvironment) { } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewEngineOptionsSetupTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewEngineOptionsSetupTest.cs index 1351b64fa5..fc28afe325 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewEngineOptionsSetupTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewEngineOptionsSetupTest.cs @@ -2,7 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -19,7 +23,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var hostingEnv = new Mock(); hostingEnv.SetupGet(e => e.ContentRootFileProvider) .Returns(expected); - var optionsSetup = new RazorViewEngineOptionsSetup(hostingEnv.Object); + + var optionsSetup = GetSetup(hostingEnvironment: hostingEnv.Object); // Act optionsSetup.Configure(options); @@ -28,5 +33,128 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var fileProvider = Assert.Single(options.FileProviders); Assert.Same(expected, fileProvider); } + + [Fact] + public void PostConfigure_SetsAllowRecompilingViewsOnFileChange_For21() + { + // Arrange + var options = new RazorViewEngineOptions(); + var optionsSetup = GetSetup(CompatibilityVersion.Version_2_1); + + // Act + optionsSetup.Configure(options); + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.True(options.AllowRecompilingViewsOnFileChange); + } + + [Theory] + [InlineData(CompatibilityVersion.Version_2_2)] + [InlineData(CompatibilityVersion.Latest)] + public void PostConfigure_SetsAllowRecompilingViewsOnFileChange_InDevelopmentMode(CompatibilityVersion compatibilityVersion) + { + // Arrange + var options = new RazorViewEngineOptions(); + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Development); + var optionsSetup = GetSetup(compatibilityVersion, hostingEnv); + + // Act + optionsSetup.Configure(options); + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.True(options.AllowRecompilingViewsOnFileChange); + } + + [Theory] + [InlineData(CompatibilityVersion.Version_2_2)] + [InlineData(CompatibilityVersion.Latest)] + public void PostConfigure_DoesNotSetAllowRecompilingViewsOnFileChange_WhenNotInDevelopment(CompatibilityVersion compatibilityVersion) + { + // Arrange + var options = new RazorViewEngineOptions(); + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Staging); + var optionsSetup = GetSetup(compatibilityVersion, hostingEnv); + + // Act + optionsSetup.Configure(options); + optionsSetup.PostConfigure(string.Empty, options); + + // Assert + Assert.False(options.AllowRecompilingViewsOnFileChange); + } + + [Fact] + public void RazorViewEngineOptionsSetup_DoesNotOverwriteAllowRecompilingViewsOnFileChange_In21CompatMode() + { + // Arrange + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Staging); + var compatibilityVersion = new MvcCompatibilityOptions { CompatibilityVersion = CompatibilityVersion.Version_2_1 }; + var optionsSetup = GetSetup(CompatibilityVersion.Version_2_1, hostingEnv); + var serviceProvider = new ServiceCollection() + .AddOptions() + .AddSingleton>(optionsSetup) + .Configure(o => o.AllowRecompilingViewsOnFileChange = false) + .BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.False(options.Value.AllowRecompilingViewsOnFileChange); + } + + [Fact] + public void RazorViewEngineOptionsSetup_ConfiguresAllowRecompilingViewsOnFileChange() + { + // Arrange + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Production); + var compatibilityVersion = new MvcCompatibilityOptions { CompatibilityVersion = CompatibilityVersion.Version_2_2 }; + var optionsSetup = GetSetup(CompatibilityVersion.Version_2_2, hostingEnv); + var serviceProvider = new ServiceCollection() + .AddOptions() + .AddSingleton>(optionsSetup) + .BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.False(options.Value.AllowRecompilingViewsOnFileChange); + } + + [Fact] + public void RazorViewEngineOptionsSetup_DoesNotOverwriteAllowRecompilingViewsOnFileChange() + { + // Arrange + var hostingEnv = Mock.Of(env => env.EnvironmentName == EnvironmentName.Production); + var optionsSetup = GetSetup(CompatibilityVersion.Version_2_2, hostingEnv); + var serviceProvider = new ServiceCollection() + .AddOptions() + .AddSingleton>(optionsSetup) + .Configure(o => o.AllowRecompilingViewsOnFileChange = true) + .BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.True(options.Value.AllowRecompilingViewsOnFileChange); + } + + private static RazorViewEngineOptionsSetup GetSetup( + CompatibilityVersion compatibilityVersion = CompatibilityVersion.Latest, + IHostingEnvironment hostingEnvironment = null) + { + hostingEnvironment = hostingEnvironment ?? Mock.Of(); + var compatibilityOptions = new MvcCompatibilityOptions { CompatibilityVersion = compatibilityVersion }; + + return new RazorViewEngineOptionsSetup( + hostingEnvironment, + NullLoggerFactory.Instance, + Options.Create(compatibilityOptions)); + } + } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Microsoft.AspNetCore.Mvc.Razor.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Microsoft.AspNetCore.Mvc.Razor.Test.csproj index 88f06a4cbb..4e4f07c50f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Microsoft.AspNetCore.Mvc.Razor.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/Microsoft.AspNetCore.Mvc.Razor.Test.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) @@ -11,13 +11,11 @@ - - + + - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageActivatorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageActivatorTest.cs index 17b0999b58..f96d2ba439 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageActivatorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageActivatorTest.cs @@ -101,7 +101,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Same(DiagnosticSource, instance.DiagnosticSource); Assert.Same(HtmlEncoder, instance.HtmlEncoder); - // When we don't have a model property, the activator will just leave viewdata alone. + // When we don't have a model property, the activator will just leave ViewData alone. Assert.NotNull(viewContext.ViewData); } @@ -165,6 +165,69 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Throws(() => activator.Activate(instance, viewContext)); } + [Fact] + public void Activate_UsesModelFromModelTypeProvider() + { + // Arrange + var activator = CreateActivator(); + + var viewData = new ViewDataDictionary(MetadataProvider, new ModelStateDictionary()) + { + { "key", "value" }, + }; + var viewContext = CreateViewContext(viewData); + var page = new ModelTypeProviderRazorPage(); + + // Act + activator.Activate(page, viewContext); + + // Assert + Assert.Same(viewContext.ViewData, page.ViewData); + Assert.NotSame(viewData, viewContext.ViewData); + + Assert.IsType>(viewContext.ViewData); + Assert.Equal("value", viewContext.ViewData["key"]); + } + + [Fact] + public void GetOrAddCacheEntry_CachesPages() + { + // Arrange + var activator = CreateActivator(); + var page = new TestRazorPage(); + + // Act + var result1 = activator.GetOrAddCacheEntry(page); + var result2 = activator.GetOrAddCacheEntry(page); + + // Assert + Assert.Same(result1, result2); + } + + [Fact] + public void GetOrAddCacheEntry_VariesByModelType_IfPageIsModelTypeProvider() + { + // Arrange + var activator = CreateActivator(); + var page = new ModelTypeProviderRazorPage(); + + // Act - 1 + var result1 = activator.GetOrAddCacheEntry(page); + var result2 = activator.GetOrAddCacheEntry(page); + + // Assert - 1 + Assert.Same(result1, result2); + + // Act - 2 + page.ModelType = typeof(string); + var result3 = activator.GetOrAddCacheEntry(page); + var result4 = activator.GetOrAddCacheEntry(page); + + // Assert - 2 + Assert.Same(result3, result4); + Assert.NotSame(result1, result3); + } + private RazorPageActivator CreateActivator() { return new RazorPageActivator(MetadataProvider, UrlHelperFactory, JsonHelper, DiagnosticSource, HtmlEncoder, ModelExpressionProvider); @@ -225,6 +288,21 @@ namespace Microsoft.AspNetCore.Mvc.Razor } } + private class ModelTypeProviderRazorPage : RazorPage, IModelTypeProvider + { + [RazorInject] + public ViewDataDictionary ViewData { get; set; } + + public Type ModelType { get; set; } = typeof(Guid); + + public override Task ExecuteAsync() + { + throw new NotImplementedException(); + } + + public Type GetModelType() => ModelType; + } + private abstract class NoModelPropertyBase : RazorPage { [RazorInject] diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageCreateModelExpressionTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageCreateModelExpressionTest.cs index b16c2fe500..015742b0c3 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageCreateModelExpressionTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageCreateModelExpressionTest.cs @@ -209,7 +209,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor private static IModelExpressionProvider CreateModelExpressionProvider() { - var provider = new TestModelMetadataProvider(); + var provider = new EmptyModelMetadataProvider(); var modelExpressionProvider = new ModelExpressionProvider( provider, new ExpressionTextCache()); @@ -219,7 +219,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor private static ViewContext CreateViewContext() { - var provider = new TestModelMetadataProvider(); + var provider = new EmptyModelMetadataProvider(); var viewData = new ViewDataDictionary(provider, new ModelStateDictionary()); var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(provider); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageTest.cs index 0484a805eb..73a92e8232 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorPageTest.cs @@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; @@ -304,7 +303,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal(1, bufferScope.CreatedBuffers.Count); Assert.Equal(0, bufferScope.ReturnedBuffers.Count); - v.Write("Level:1-A"); // Creates a new buffer for the taghelper. + v.Write("Level:1-A"); // Creates a new buffer for the TagHelper. Assert.Equal(2, bufferScope.CreatedBuffers.Count); Assert.Equal(0, bufferScope.ReturnedBuffers.Count); @@ -319,7 +318,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal(2, bufferScope.CreatedBuffers.Count); Assert.Equal(0, bufferScope.ReturnedBuffers.Count); - v.Write(outputLevel1); // Writing the taghelper to output returns a buffer. + v.Write(outputLevel1); // Writing the TagHelper to output returns a buffer. Assert.Equal(2, bufferScope.CreatedBuffers.Count); Assert.Equal(1, bufferScope.ReturnedBuffers.Count); } @@ -336,7 +335,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal(2, bufferScope.CreatedBuffers.Count); Assert.Equal(1, bufferScope.ReturnedBuffers.Count); - v.Write("Level:1-B"); // Creates a new buffer for the taghelper. + v.Write("Level:1-B"); // Creates a new buffer for the TagHelper. Assert.Equal(3, bufferScope.CreatedBuffers.Count); Assert.Equal(1, bufferScope.ReturnedBuffers.Count); @@ -352,7 +351,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal(3, bufferScope.CreatedBuffers.Count); Assert.Equal(1, bufferScope.ReturnedBuffers.Count); - v.Write("Level:2"); // Creates a new buffer for the taghelper. + v.Write("Level:2"); // Creates a new buffer for the TagHelper. Assert.Equal(4, bufferScope.CreatedBuffers.Count); Assert.Equal(1, bufferScope.ReturnedBuffers.Count); @@ -367,7 +366,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal(4, bufferScope.CreatedBuffers.Count); Assert.Equal(1, bufferScope.ReturnedBuffers.Count); - v.Write(outputLevel2); // Writing the taghelper to output returns a buffer. + v.Write(outputLevel2); // Writing the TagHelper to output returns a buffer. Assert.Equal(4, bufferScope.CreatedBuffers.Count); Assert.Equal(2, bufferScope.ReturnedBuffers.Count); } @@ -383,7 +382,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal(4, bufferScope.CreatedBuffers.Count); Assert.Equal(2, bufferScope.ReturnedBuffers.Count); - v.Write(outputLevel1); // Writing the taghelper to output returns a buffer. + v.Write(outputLevel1); // Writing the TagHelper to output returns a buffer. Assert.Equal(4, bufferScope.CreatedBuffers.Count); Assert.Equal(3, bufferScope.ReturnedBuffers.Count); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineOptionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineOptionsTest.cs index 6172036b6c..30197bae76 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineOptionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineOptionsTest.cs @@ -6,6 +6,7 @@ using System.Linq; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; using Xunit; @@ -94,4 +95,4 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Same(fileProvider, accessor.Value.FileProviders[0]); } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs index 378ca51d42..99240071cf 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs @@ -8,6 +8,7 @@ using System.Threading; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.Routing; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; @@ -748,7 +750,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor } [Fact] - public void FindView_NoramlizesPaths_ReturnedByViewLocationExpanders() + public void FindView_NormalizesPaths_ReturnedByViewLocationExpanders() { // Arrange var pageFactory = new Mock(); @@ -1612,6 +1614,30 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal("Route-Value", result); } + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void GetNormalizedRouteValue_UsesInvariantCulture() + { + // Arrange + var key = "some-key"; + var actionDescriptor = new ActionDescriptor(); + actionDescriptor.RouteValues.Add(key, "Route-Value"); + + var actionContext = new ActionContext + { + ActionDescriptor = actionDescriptor, + RouteData = new RouteData() + }; + + actionContext.RouteData.Values[key] = new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)); + + // Act + var result = RazorViewEngine.GetNormalizedRouteValue(actionContext, key); + + // Assert + Assert.Equal("10/31/2018 07:37:38 -07:00", result); + } + [Fact] public void GetNormalizedRouteValue_ReturnsRouteValue_IfValueDoesNotMatch() { @@ -1978,7 +2004,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor IEnumerable areaViewLocationFormats = null, IEnumerable pageViewLocationFormats = null) { - var optionsSetup = new RazorViewEngineOptionsSetup(Mock.Of()); + var optionsSetup = new RazorViewEngineOptionsSetup( + Mock.Of(), + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions())); var options = new RazorViewEngineOptions(); optionsSetup.Configure(options); @@ -2039,8 +2068,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor routeData.Values.Add(kvp.Key, kvp.Value); } - var actionDesciptor = new ActionDescriptor(); - return new ActionContext(httpContext, routeData, actionDesciptor); + var actionDescriptor = new ActionDescriptor(); + return new ActionContext(httpContext, routeData, actionDescriptor); } private static ActionContext GetActionContextWithActionDescriptor( diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewTest.cs index cd5e8835a2..71fa07b67e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewTest.cs @@ -1747,6 +1747,49 @@ namespace Microsoft.AspNetCore.Mvc.Razor Assert.Equal(expected, viewContext.Writer.ToString()); } + [Fact] + public async Task RenderAsync_InvokesOnAfterPageActivated() + { + // Arrange + var viewStart = new TestableRazorPage(_ => { }); + var page = new TestableRazorPage(p => { p.Layout = LayoutPath; }); + var layout = new TestableRazorPage(p => { p.RenderBodyPublic(); }); + var expected = new HashSet(); + var onAfterPageActivatedCalled = 0; + + var activated = new HashSet(); + var pageActivator = new Mock(); + pageActivator.Setup(p => p.Activate(It.IsAny(), It.IsAny())) + .Callback((IRazorPage p, ViewContext v) => activated.Add(p)); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.FindPage(It.IsAny(), LayoutPath)) + .Returns(new RazorPageResult(LayoutPath, layout)); + + var view = new RazorView( + viewEngine.Object, + pageActivator.Object, + new[] { viewStart }, + page, + new HtmlTestEncoder(), + new DiagnosticListener("Microsoft.AspNetCore.Mvc.Razor")) + { + OnAfterPageActivated = AssertActivated, + }; + var viewContext = CreateViewContext(view); + + // Act + await view.RenderAsync(viewContext); + Assert.Equal(3, onAfterPageActivatedCalled); + + void AssertActivated(IRazorPage p, ViewContext v) + { + onAfterPageActivatedCalled++; + expected.Add(p); + Assert.Equal(expected, activated); + } + } + private static ViewContext CreateViewContext(RazorView view) { var httpContext = new DefaultHttpContext(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/TagHelpers/UrlResolutionTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/TagHelpers/UrlResolutionTagHelperTest.cs index 84db2e9a8e..0eab7b1331 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/TagHelpers/UrlResolutionTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Razor.Test/TagHelpers/UrlResolutionTagHelperTest.cs @@ -7,9 +7,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Html; -using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.WebEncoders.Testing; using Moq; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/DefaultPageApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/DefaultPageApplicationModelProviderTest.cs similarity index 95% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/DefaultPageApplicationModelProviderTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/DefaultPageApplicationModelProviderTest.cs index 1a82c834ec..7661f32707 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/DefaultPageApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/DefaultPageApplicationModelProviderTest.cs @@ -6,15 +6,16 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Mvc.RazorPages.Internal; using Microsoft.Extensions.Options; using Xunit; -namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal +namespace Microsoft.AspNetCore.Mvc.ApplicationModels { public class DefaultPageApplicationModelProviderTest { @@ -455,7 +456,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal Assert.Same(typeof(EmptyPage).GetTypeInfo(), pageModel.PageType); } - // We want to test the 'empty' page and pagemodel has no bound properties, and no handler methods. + // We want to test the 'empty' page and PageModel has no bound properties, and no handler methods. [Fact] public void OnProvidersExecuting_EmptyPageModel() { @@ -887,7 +888,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Arrange var provider = new DefaultPageApplicationModelProvider( TestModelMetadataProvider.CreateDefaultProvider(), - Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = false })); + Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = false }), + Options.Create(new RazorPagesOptions())); var typeInfo = typeof(PageWithHandlerParameters).GetTypeInfo(); var expected = typeInfo.GetMethod(nameof(PageWithHandlerParameters.OnPost)); var pageModel = new PageApplicationModel(new PageActionDescriptor(), typeInfo, new object[0]); @@ -921,11 +923,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnPost(string name, [ModelBinder(Name = "personId")] int id) { } } - // We're using PropertyHelper from Common to find the properties here, which implements + // We're using PropertyHelper from Common to find the properties here, which implements // out standard set of semantics for properties that the framework interacts with. - // - // One of the desirable consequences of that is we only find 'visible' properties. We're not - // retesting all of the details of PropertyHelper here, just the visibility part as a quick check + // + // One of the desirable consequences of that is we only find 'visible' properties. We're not + // retesting all of the details of PropertyHelper here, just the visibility part as a quick check // that we're using PropertyHelper as expected. [Fact] public void PopulateHandlerProperties_UsesPropertyHelpers_ToFindProperties() @@ -1071,6 +1073,41 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnGetUser() { } } + [Fact] + public void PopulateFilters_With21CompatBehavior_DoesNotAddDisallowOptionsRequestsPageFilter() + { + // Arrange + var provider = new DefaultPageApplicationModelProvider( + TestModelMetadataProvider.CreateDefaultProvider(), + Options.Create(new MvcOptions()), + Options.Create(new RazorPagesOptions())); + var typeInfo = typeof(object).GetTypeInfo(); + var pageModel = new PageApplicationModel(new PageActionDescriptor(), typeInfo, typeInfo.GetCustomAttributes(inherit: true)); + + // Act + provider.PopulateFilters(pageModel); + + // Assert + Assert.Empty(pageModel.Filters); + } + + [Fact] + public void PopulateFilters_AddsDisallowOptionsRequestsPageFilter() + { + // Arrange + var provider = CreateProvider(); + var typeInfo = typeof(object).GetTypeInfo(); + var pageModel = new PageApplicationModel(new PageActionDescriptor(), typeInfo, typeInfo.GetCustomAttributes(inherit: true)); + + // Act + provider.PopulateFilters(pageModel); + + // Assert + Assert.Collection( + pageModel.Filters, + filter => Assert.IsType(filter)); + } + [Fact] public void PopulateFilters_AddsIFilterMetadataAttributesToModel() { @@ -1085,7 +1122,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Assert Assert.Collection( pageModel.Filters, - filter => Assert.IsType(filter)); + filter => Assert.IsType(filter), + filter => Assert.IsType(filter)); } [PageModel] @@ -1109,7 +1147,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Assert Assert.Collection( pageModel.Filters, - filter => Assert.IsType(filter)); + filter => Assert.IsType(filter), + filter => Assert.IsType(filter)); } private class ModelImplementingAsyncPageFilter : IAsyncPageFilter @@ -1139,7 +1178,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Assert Assert.Collection( pageModel.Filters, - filter => Assert.IsType(filter)); + filter => Assert.IsType(filter), + filter => Assert.IsType(filter)); } private class ModelImplementingPageFilter : IPageFilter @@ -1175,7 +1215,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal Assert.Collection( pageModel.Filters, filter => Assert.IsType(filter), - filter => Assert.IsType(filter)); + filter => Assert.IsType(filter), + filter => Assert.IsType(filter)); } [ServiceFilter(typeof(IServiceProvider))] @@ -1185,7 +1226,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { return new DefaultPageApplicationModelProvider( TestModelMetadataProvider.CreateDefaultProvider(), - Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = true })); + Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = true }), + Options.Create(new RazorPagesOptions { AllowDefaultHandlingForOptionsRequests = true })); } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/PageRouteTransformerConventionTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/PageRouteTransformerConventionTest.cs new file mode 100644 index 0000000000..1fd04584b7 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/ApplicationModels/PageRouteTransformerConventionTest.cs @@ -0,0 +1,67 @@ +// 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.Reflection; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Routing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Test.ApplicationModels +{ + public class PageRouteTransformerConventionTest + { + [Fact] + public void Apply_SetTransformer() + { + // Arrange + var transformer = new TestParameterTransformer(); + var convention = new PageRouteTransformerConvention(transformer); + + var model = new PageRouteModel(string.Empty, string.Empty); + + // Act + convention.Apply(model); + + // Assert + Assert.True(model.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out var routeTokenTransformer)); + Assert.Equal(transformer, routeTokenTransformer); + } + + [Fact] + public void Apply_ShouldApplyFalse_NoOp() + { + // Arrange + var transformer = new TestParameterTransformer(); + var convention = new CustomPageRouteTransformerConvention(transformer); + + var model = new PageRouteModel(string.Empty, string.Empty); + + // Act + convention.Apply(model); + + // Assert + Assert.False(model.Properties.TryGetValue(typeof(IOutboundParameterTransformer), out _)); + } + + private class TestParameterTransformer : IOutboundParameterTransformer + { + public string TransformOutbound(object value) + { + return value?.ToString(); + } + } + + private class CustomPageRouteTransformerConvention : PageRouteTransformerConvention + { + public CustomPageRouteTransformerConvention(IOutboundParameterTransformer parameterTransformer) : base(parameterTransformer) + { + } + + protected override bool ShouldApply(PageRouteModel action) + { + return false; + } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/AutoValidateAntiforgeryPageApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/AutoValidateAntiforgeryPageApplicationModelProviderTest.cs new file mode 100644 index 0000000000..f3222ce36c --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/AutoValidateAntiforgeryPageApplicationModelProviderTest.cs @@ -0,0 +1,89 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.RazorPages +{ + public class AutoValidateAntiforgeryPageApplicationModelProviderTest + { + [Fact] + public void OnProvidersExecuting_AddsFiltersToModel() + { + // Arrange + var actionDescriptor = new PageActionDescriptor(); + var applicationModel = new PageApplicationModel( + actionDescriptor, + typeof(object).GetTypeInfo(), + new object[0]); + var applicationModelProvider = new AutoValidateAntiforgeryPageApplicationModelProvider(); + var context = new PageApplicationModelProviderContext(new PageActionDescriptor(), typeof(object).GetTypeInfo()) + { + PageApplicationModel = applicationModel, + }; + + // Act + applicationModelProvider.OnProvidersExecuting(context); + + // Assert + Assert.Collection( + applicationModel.Filters, + filter => Assert.IsType(filter)); + } + + [Fact] + public void OnProvidersExecuting_DoesNotAddAutoValidateAntiforgeryTokenAttribute_IfIgnoreAntiforgeryTokenAttributeExists() + { + // Arrange + var expected = new IgnoreAntiforgeryTokenAttribute(); + + var descriptor = new PageActionDescriptor(); + var provider = new AutoValidateAntiforgeryPageApplicationModelProvider(); + var context = new PageApplicationModelProviderContext(descriptor, typeof(object).GetTypeInfo()) + { + PageApplicationModel = new PageApplicationModel(descriptor, typeof(object).GetTypeInfo(), Array.Empty()) + { + Filters = { expected }, + }, + }; + + // Act + provider.OnProvidersExecuting(context); + + // Assert + Assert.Collection( + context.PageApplicationModel.Filters, + actual => Assert.Same(expected, actual)); + } + + [Fact] + public void OnProvidersExecuting_DoesNotAddAutoValidateAntiforgeryTokenAttribute_IfAntiforgeryPolicyExists() + { + // Arrange + var expected = Mock.Of(); + + var descriptor = new PageActionDescriptor(); + var provider = new AutoValidateAntiforgeryPageApplicationModelProvider(); + var context = new PageApplicationModelProviderContext(descriptor, typeof(object).GetTypeInfo()) + { + PageApplicationModel = new PageApplicationModel(descriptor, typeof(object).GetTypeInfo(), Array.Empty()) + { + Filters = { expected }, + }, + }; + + // Act + provider.OnProvidersExecuting(context); + + // Assert + Assert.Collection( + context.PageApplicationModel.Filters, + actual => Assert.Same(expected, actual)); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/DisallowOptionsRequestsPageFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/DisallowOptionsRequestsPageFilterTest.cs new file mode 100644 index 0000000000..a2be81c8c1 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/DisallowOptionsRequestsPageFilterTest.cs @@ -0,0 +1,135 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure +{ + public class DisallowOptionsRequestsPageFilterTest + { + [Fact] + public void OnPageHandlerExecuting_DoesNothing_IfHandlerIsSelected() + { + // Arrange + var context = GetContext(new HandlerMethodDescriptor()); + var filter = new HandleOptionsRequestsPageFilter(); + + // Act + filter.OnPageHandlerExecuting(context); + + // Assert + Assert.Null(context.Result); + } + + [Fact] + public void OnPageHandlerExecuting_DoesNotOverwriteResult_IfHandlerIsSelected() + { + // Arrange + var expected = new PageResult(); + var context = GetContext(new HandlerMethodDescriptor()); + context.Result = expected; + var filter = new HandleOptionsRequestsPageFilter(); + + // Act + filter.OnPageHandlerExecuting(context); + + // Assert + Assert.Same(expected, context.Result); + } + + [Fact] + public void OnPageHandlerExecuting_DoesNothing_IfHandlerIsNotSelected_WhenRequestsIsNotOptions() + { + // Arrange + var context = GetContext(handlerMethodDescriptor: null); + context.HttpContext.Request.Method = "PUT"; + var filter = new HandleOptionsRequestsPageFilter(); + + // Act + filter.OnPageHandlerExecuting(context); + + // Assert + Assert.Null(context.Result); + } + + [Fact] + public void OnPageHandlerExecuting_DoesNotOverwriteResult_IfHandlerIsNotSelected_WhenRequestsIsNotOptions() + { + // Arrange + var expected = new PageResult(); + var context = GetContext(handlerMethodDescriptor: null); + context.HttpContext.Request.Method = "DELETE"; + context.Result = expected; + + var filter = new HandleOptionsRequestsPageFilter(); + + // Act + filter.OnPageHandlerExecuting(context); + + // Assert + Assert.Same(expected, context.Result); + } + + [Fact] + public void OnPageHandlerExecuting_DoesNothing_ForOptionsRequestWhenHandlerIsSelected() + { + // Arrange + var context = GetContext(new HandlerMethodDescriptor()); + context.HttpContext.Request.Method = "Options"; + + var filter = new HandleOptionsRequestsPageFilter(); + + // Act + filter.OnPageHandlerExecuting(context); + + // Assert + Assert.Null(context.Result); + } + + [Fact] + public void OnPageHandlerExecuting_DoesNotOverwriteResult_ForOptionsRequestWhenNoHandler() + { + // Arrange + var expected = new NotFoundResult(); + var context = GetContext(new HandlerMethodDescriptor()); + context.Result = expected; + context.HttpContext.Request.Method = "Options"; + + var filter = new HandleOptionsRequestsPageFilter(); + + // Act + filter.OnPageHandlerExecuting(context); + + // Assert + Assert.Same(expected, context.Result); + } + + [Fact] + public void OnPageHandlerExecuting_SetsResult_ForOptionsRequestWhenNoHandlerIsSelected() + { + // Arrange + var context = GetContext(handlerMethodDescriptor: null); + context.HttpContext.Request.Method = "Options"; + + var filter = new HandleOptionsRequestsPageFilter(); + + // Act + filter.OnPageHandlerExecuting(context); + + // Assert + Assert.IsType(context.Result); + } + + private static PageHandlerExecutingContext GetContext(HandlerMethodDescriptor handlerMethodDescriptor) + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new PageActionDescriptor()); + var pageContext = new PageContext(actionContext); + return new PageHandlerExecutingContext(pageContext, Array.Empty(), handlerMethodDescriptor, new Dictionary(), new object()); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageActionDescriptorProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageActionDescriptorProviderTest.cs index 095a5f5415..2573072e01 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageActionDescriptorProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageActionDescriptorProviderTest.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.Extensions.Options; using Moq; @@ -167,6 +168,74 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure Assert.Equal("Accounts/Test/{id:int?}", descriptor.AttributeRouteInfo.Template); } + [Fact] + public void GetDescriptors_CopiesActionConstraintsFromModel() + { + // Arrange + var expected = Mock.Of(); + var model = new PageRouteModel("/Areas/Accounts/Pages/Test.cshtml", "/Test", "Accounts") + { + Selectors = + { + new SelectorModel + { + AttributeRouteModel = new AttributeRouteModel(), + ActionConstraints = { expected } + } + }, + }; + var applicationModelProvider = new TestPageRouteModelProvider(model); + var provider = new PageActionDescriptorProvider( + new[] { applicationModelProvider }, + GetAccessor(), + GetRazorPagesOptions()); + var context = new ActionDescriptorProviderContext(); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var result = Assert.Single(context.Results); + var descriptor = Assert.IsType(result); + Assert.Equal(model.RelativePath, descriptor.RelativePath); + var actual = Assert.Single(descriptor.ActionConstraints); + Assert.Same(expected, actual); + } + + [Fact] + public void GetDescriptors_CopiesEndpointMetadataFromModel() + { + // Arrange + var expected = new object(); + var model = new PageRouteModel("/Test.cshtml", "/Test", "Accounts") + { + Selectors = + { + new SelectorModel + { + AttributeRouteModel = new AttributeRouteModel(), + EndpointMetadata = { expected } + } + }, + }; + var applicationModelProvider = new TestPageRouteModelProvider(model); + var provider = new PageActionDescriptorProvider( + new[] { applicationModelProvider }, + GetAccessor(), + GetRazorPagesOptions()); + var context = new ActionDescriptorProviderContext(); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var result = Assert.Single(context.Results); + var descriptor = Assert.IsType(result); + Assert.Equal(model.RelativePath, descriptor.RelativePath); + var actual = Assert.Single(descriptor.EndpointMetadata); + Assert.Same(expected, actual); + } + [Fact] public void GetDescriptors_AddsActionDescriptorForEachSelector() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/ServiceBasedPageModelActivatorProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/ServiceBasedPageModelActivatorProviderTest.cs new file mode 100644 index 0000000000..1d045c8571 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/ServiceBasedPageModelActivatorProviderTest.cs @@ -0,0 +1,151 @@ +// 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.Reflection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure +{ + public class ServiceBasedPageModelActivatorProviderTest + { + [Fact] + public void CreateActivator_ThrowsIfModelTypeInfoOnActionDescriptorIsNull() + { + // Arrange + var activatorProvider = new ServiceBasedPageModelActivatorProvider(); + var descriptor = new CompiledPageActionDescriptor(); + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => activatorProvider.CreateActivator(descriptor), + "descriptor", + "The 'ModelTypeInfo' property of 'descriptor' must not be null."); + } + + [Fact] + public void Create_GetsServicesFromServiceProvider() + { + // Arrange + var simpleModel = new DISimpleModel(); + var serviceProvider = new Mock(MockBehavior.Strict); + serviceProvider.Setup(s => s.GetService(typeof(DISimpleModel))) + .Returns(simpleModel) + .Verifiable(); + + var activatorProvider = new ServiceBasedPageModelActivatorProvider(); + var pageContext = new PageContext + { + HttpContext = new DefaultHttpContext + { + RequestServices = serviceProvider.Object, + }, + ActionDescriptor = new CompiledPageActionDescriptor + { + ModelTypeInfo = typeof(DISimpleModel).GetTypeInfo(), + } + }; + + // Act + var activator = activatorProvider.CreateActivator(pageContext.ActionDescriptor); + var instance = activator(pageContext); + + // Assert + Assert.Same(simpleModel, instance); + serviceProvider.Verify(); + } + + [Fact] + public void CreateActivator_CreatesModelInstance() + { + // Arrange + var simpleModel = new DISimpleModel(); + var serviceProvider = new Mock(MockBehavior.Strict); + serviceProvider.Setup(s => s.GetService(typeof(DISimpleModel))) + .Returns(simpleModel) + .Verifiable(); + + var activatorProvider = new ServiceBasedPageModelActivatorProvider(); + var pageContext = new PageContext + { + HttpContext = new DefaultHttpContext + { + RequestServices = serviceProvider.Object, + }, + ActionDescriptor = new CompiledPageActionDescriptor + { + ModelTypeInfo = typeof(DISimpleModel).GetTypeInfo(), + } + }; + + // Act + var activator = activatorProvider.CreateActivator(pageContext.ActionDescriptor); + var model = activator(pageContext); + + // Assert + var simpleModel2 = Assert.IsType(model); + Assert.NotNull(simpleModel2); + } + + [Fact] + public void Create_ThrowsIfModelIsNotRegisteredInServiceProvider() + { + // Arrange + var expected = "No service for type '" + typeof(DISimpleModel) + "' has been registered."; + var model = new DISimpleModel(); + + var httpContext = new DefaultHttpContext + { + RequestServices = Mock.Of() + }; + + var activatorProvider = new ServiceBasedPageModelActivatorProvider(); + var context = new PageContext + { + HttpContext = httpContext, + ActionDescriptor = new CompiledPageActionDescriptor + { + ModelTypeInfo = typeof(DISimpleModel).GetTypeInfo(), + } + }; + + // Act and Assert + var activator = activatorProvider.CreateActivator(context.ActionDescriptor); + var ex = Assert.Throws( + () => activator(context)); + + Assert.Equal(expected, ex.Message); + } + + [Theory] + [InlineData(typeof(SimpleModel))] + [InlineData(typeof(object))] + public void CreateReleaser_ReturnsNullForPageModels(Type pageType) + { + // Arrange + var context = new PageContext(); + var activator = new ServiceBasedPageModelActivatorProvider(); + var actionDescriptor = new CompiledPageActionDescriptor + { + PageTypeInfo = pageType.GetTypeInfo(), + }; + + // Act + var releaser = activator.CreateReleaser(actionDescriptor); + + // Assert + Assert.Null(releaser); + } + + private class SimpleModel + { + } + + private class DISimpleModel : SimpleModel + { + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/AuthorizationPageApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/AuthorizationPageApplicationModelProviderTest.cs index 85b51820df..559a653985 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/AuthorizationPageApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/AuthorizationPageApplicationModelProviderTest.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.Extensions.Options; using Xunit; @@ -20,17 +21,18 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { // Arrange var policyProvider = new DefaultAuthorizationPolicyProvider(Options.Create(new AuthorizationOptions())); - var autorizationProvider = new AuthorizationPageApplicationModelProvider(policyProvider); + var authorizationProvider = new AuthorizationPageApplicationModelProvider(policyProvider); var typeInfo = typeof(PageWithAuthorizeHandlers).GetTypeInfo(); var context = GetApplicationProviderContext(typeInfo); // Act - autorizationProvider.OnProvidersExecuting(context); + authorizationProvider.OnProvidersExecuting(context); // Assert Assert.Collection( context.PageApplicationModel.Filters, - f => Assert.IsType(f)); + f => Assert.IsType(f), + f => Assert.IsType(f)); } private class PageWithAuthorizeHandlers : Page @@ -53,16 +55,17 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { // Arrange var policyProvider = new DefaultAuthorizationPolicyProvider(Options.Create(new AuthorizationOptions())); - var autorizationProvider = new AuthorizationPageApplicationModelProvider(policyProvider); + var authorizationProvider = new AuthorizationPageApplicationModelProvider(policyProvider); var context = GetApplicationProviderContext(typeof(TestPage).GetTypeInfo()); // Act - autorizationProvider.OnProvidersExecuting(context); + authorizationProvider.OnProvidersExecuting(context); // Assert Assert.Collection( context.PageApplicationModel.Filters, f => Assert.IsType(f), + f => Assert.IsType(f), f => Assert.IsType(f)); } @@ -90,18 +93,19 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal options.Value.AddPolicy("Derived", policy => policy.RequireClaim("Derived")); var policyProvider = new DefaultAuthorizationPolicyProvider(options); - var autorizationProvider = new AuthorizationPageApplicationModelProvider(policyProvider); + var authorizationProvider = new AuthorizationPageApplicationModelProvider(policyProvider); var context = GetApplicationProviderContext(typeof(TestPageWithDerivedModel).GetTypeInfo()); // Act - autorizationProvider.OnProvidersExecuting(context); + authorizationProvider.OnProvidersExecuting(context); // Assert AuthorizeFilter authorizeFilter = null; Assert.Collection( context.PageApplicationModel.Filters, f => Assert.IsType(f), + f => Assert.IsType(f), f => authorizeFilter = Assert.IsType(f)); // Basic + Basic2 + Derived authorize @@ -110,7 +114,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal private class TestPageWithDerivedModel : Page { - public DeriviedModel Model => null; + public DerivedModel Model => null; public override Task ExecuteAsync() =>throw new NotImplementedException(); } @@ -121,7 +125,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal } [Authorize(Policy = "Derived")] - private class DeriviedModel : BaseModel + private class DerivedModel : BaseModel { public virtual void OnGet() { @@ -133,16 +137,17 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { // Arrange var policyProvider = new DefaultAuthorizationPolicyProvider(Options.Create(new AuthorizationOptions())); - var autorizationProvider = new AuthorizationPageApplicationModelProvider(policyProvider); + var authorizationProvider = new AuthorizationPageApplicationModelProvider(policyProvider); var context = GetApplicationProviderContext(typeof(PageWithAnonymousModel).GetTypeInfo()); // Act - autorizationProvider.OnProvidersExecuting(context); + authorizationProvider.OnProvidersExecuting(context); // Assert Assert.Collection( context.PageApplicationModel.Filters, f => Assert.IsType(f), + f => Assert.IsType(f), f => Assert.IsType(f)); } @@ -163,7 +168,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { var defaultProvider = new DefaultPageApplicationModelProvider( TestModelMetadataProvider.CreateDefaultProvider(), - Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = true })); + Options.Create(new MvcOptions { AllowValidatingTopLevelNodes = true }), + Options.Create(new RazorPagesOptions { AllowDefaultHandlingForOptionsRequests = true })); var context = new PageApplicationModelProviderContext(new PageActionDescriptor(), typeInfo); defaultProvider.OnProvidersExecuting(context); return context; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/AutoValidateAntiforgeryPageApplicationModelProvider.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/AutoValidateAntiforgeryPageApplicationModelProvider.cs deleted file mode 100644 index 20d81e5c69..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/AutoValidateAntiforgeryPageApplicationModelProvider.cs +++ /dev/null @@ -1,36 +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.Reflection; -using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal -{ - public class AutoValidateAntiforgeryPageApplicationModelProviderTest - { - [Fact] - public void OnProvidersExecuting_AddsFiltersToModel() - { - // Arrange - var actionDescriptor = new PageActionDescriptor(); - var applicationModel = new PageApplicationModel( - actionDescriptor, - typeof(object).GetTypeInfo(), - new object[0]); - var applicationModelProvider = new AutoValidateAntiforgeryPageApplicationModelProvider(); - var context = new PageApplicationModelProviderContext(new PageActionDescriptor(), typeof(object).GetTypeInfo()) - { - PageApplicationModel = applicationModel, - }; - - // Act - applicationModelProvider.OnProvidersExecuting(context); - - // Assert - Assert.Collection( - applicationModel.Filters, - filter => Assert.IsType(filter)); - } - } -} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageActionDescriptorBuilderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageActionDescriptorBuilderTest.cs index 2d08aee3d8..4627d28c89 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageActionDescriptorBuilderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageActionDescriptorBuilderTest.cs @@ -25,6 +25,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { ActionConstraints = new List(), AttributeRouteInfo = new AttributeRouteInfo(), + EndpointMetadata = new List(), FilterDescriptors = new List(), RelativePath = "/Foo", RouteValues = new Dictionary(), @@ -40,6 +41,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Assert Assert.Same(actionDescriptor.ActionConstraints, actual.ActionConstraints); Assert.Same(actionDescriptor.AttributeRouteInfo, actual.AttributeRouteInfo); + Assert.Same(actionDescriptor.EndpointMetadata, actual.EndpointMetadata); Assert.Same(actionDescriptor.RelativePath, actual.RelativePath); Assert.Same(actionDescriptor.RouteValues, actual.RouteValues); Assert.Same(actionDescriptor.ViewEnginePath, actual.ViewEnginePath); @@ -249,7 +251,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal } [Fact] - public void CreateHandlerMethods_CopiesParameterDecriptorsFromParameterModel() + public void CreateHandlerMethods_CopiesParameterDescriptorsFromParameterModel() { // Arrange var actionDescriptor = new PageActionDescriptor(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs index fa00d8bd60..0ddf9f3af4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.AspNetCore.Razor.Hosting; @@ -520,7 +519,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnProvidersExecuting_UsesTheFirstDescriptorForEachPath() { // ViewsFeature may contain duplicate entries for the same Page - for instance when an app overloads a library's views. - // It picks the first entry for each path. In the ordinary case, this should ensure that the app's Razor Pages are prefered + // It picks the first entry for each path. In the ordinary case, this should ensure that the app's Razor Pages are preferred // to a Razor Page added by a library. // Arrange @@ -563,6 +562,57 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal }); } + [Fact] + public void OnProvidersExecuting_AllowsRazorFilesWithUnderscorePrefix() + { + // Arrange + var descriptors = new[] + { + CreateVersion_2_1_Descriptor("/Pages/_About.cshtml"), + CreateVersion_2_1_Descriptor("/Pages/Home.cshtml"), + }; + + var provider = CreateProvider(descriptors: descriptors); + var context = new PageRouteModelProviderContext(); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + Assert.Collection( + context.RouteModels, + result => + { + Assert.Equal("/Pages/_About.cshtml", result.RelativePath); + Assert.Equal("/_About", result.ViewEnginePath); + Assert.Collection( + result.Selectors, + selector => Assert.Equal("_About", selector.AttributeRouteModel.Template)); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/_About", kvp.Value); + }); + }, + result => + { + Assert.Equal("/Pages/Home.cshtml", result.RelativePath); + Assert.Equal("/Home", result.ViewEnginePath); + Assert.Collection( + result.Selectors, + selector => Assert.Equal("Home", selector.AttributeRouteModel.Template)); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Home", kvp.Value); + }); + }); + } + [Fact] public void GetRouteTemplate_ReturnsPathFromRazorPageAttribute() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/DefaultPageHandlerMethodSelectorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/DefaultPageHandlerMethodSelectorTest.cs index f4fe43fdf2..34b081d84d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/DefaultPageHandlerMethodSelectorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/DefaultPageHandlerMethodSelectorTest.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Options; using Xunit; @@ -419,6 +420,57 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal Assert.Same(descriptor1, actual); } + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void Select_ReturnsHandlerThatMatchesHandler_UsesInvariantCulture() + { + // Arrange + var descriptor1 = new HandlerMethodDescriptor + { + HttpMethod = "POST", + Name = "10/31/2018 07:37:38 -07:00", + }; + + var descriptor2 = new HandlerMethodDescriptor + { + HttpMethod = "POST", + Name = "Delete", + }; + + var pageContext = new PageContext + { + ActionDescriptor = new CompiledPageActionDescriptor + { + HandlerMethods = new List() + { + descriptor1, + descriptor2, + }, + }, + RouteData = new RouteData + { + Values = + { + { "handler", new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)) }, + } + }, + HttpContext = new DefaultHttpContext + { + Request = + { + Method = "Post" + }, + }, + }; + var selector = CreateSelector(); + + // Act + var actual = selector.Select(pageContext); + + // Assert + Assert.Same(descriptor1, actual); + } + [Fact] public void Select_HandlerFromQueryString() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs index 21f9db7215..48c7cb3454 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs @@ -30,8 +30,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal var templateEngine = new RazorTemplateEngine( RazorProjectEngine.Create(RazorConfiguration.Default, fileSystem).Engine, fileSystem); - var options = Options.Create(new RazorPagesOptions()); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var razorPageOptions = Options.Create(new RazorPagesOptions()); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, razorPageOptions, razorViewEngineOptions); // Act var changeToken = changeProvider.GetChangeToken(); @@ -57,8 +58,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal fileSystem); var options = Options.Create(new RazorPagesOptions()); options.Value.RootDirectory = rootDirectory; + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act var changeToken = changeProvider.GetChangeToken(); @@ -81,7 +83,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal RazorProjectEngine.Create(RazorConfiguration.Default, fileSystem).Engine, fileSystem); var options = Options.Create(new RazorPagesOptions { AllowAreas = true }); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act var changeToken = changeProvider.GetChangeToken(); @@ -104,8 +107,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal templateEngine.Options.ImportsFileName = "_ViewImports.cshtml"; var options = Options.Create(new RazorPagesOptions()); options.Value.RootDirectory = "/dir1/dir2"; + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act & Assert var compositeChangeToken = Assert.IsType(changeProvider.GetChangeToken()); @@ -131,7 +135,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal options.Value.RootDirectory = "/dir1/dir2"; options.Value.AllowAreas = true; - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act & Assert var compositeChangeToken = Assert.IsType(changeProvider.GetChangeToken()); @@ -155,8 +160,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal fileSystem); templateEngine.Options.ImportsFileName = "_ViewImports.cshtml"; var options = Options.Create(new RazorPagesOptions { AllowAreas = false }); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions { AllowRecompilingViewsOnFileChange = true }); - var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options); + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); // Act & Assert var compositeChangeToken = Assert.IsType(changeProvider.GetChangeToken()); @@ -164,5 +170,27 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal changeToken => Assert.Same(fileProvider.GetChangeToken("/_ViewImports.cshtml"), changeToken), changeToken => Assert.Same(fileProvider.GetChangeToken("/Pages/**/*.cshtml"), changeToken)); } + + [Fact] + public void GetChangeToken_DoesNotWatch_WhenOptionIsReset() + { + // Arrange + var fileProvider = new Mock(MockBehavior.Strict); + var accessor = Mock.Of(a => a.FileProvider == fileProvider.Object); + + var fileSystem = new FileProviderRazorProjectFileSystem(accessor, _hostingEnvironment); + var templateEngine = new RazorTemplateEngine( + RazorProjectEngine.Create(RazorConfiguration.Default, fileSystem).Engine, + fileSystem); + templateEngine.Options.ImportsFileName = "_ViewImports.cshtml"; + var options = Options.Create(new RazorPagesOptions()); + var razorViewEngineOptions = Options.Create(new RazorViewEngineOptions()); + + var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options, razorViewEngineOptions); + + // Act & Assert + var compositeChangeToken = Assert.IsType(changeProvider.GetChangeToken()); + fileProvider.Verify(f => f.Watch(It.IsAny()), Times.Never()); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionInvokerProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionInvokerProviderTest.cs index b2d6aa0186..955149bf03 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionInvokerProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionInvokerProviderTest.cs @@ -15,12 +15,12 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor.Compilation; -using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionInvokerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionInvokerTest.cs index 5b7899ac3e..45078ef126 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionInvokerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionInvokerTest.cs @@ -1555,7 +1555,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal return new ParameterBinder( metadataProvider, factory, - new DefaultObjectValidator(metadataProvider, new[] { validator }), + new DefaultObjectValidator(metadataProvider, new[] { validator }, new MvcOptions()), Options.Create(mvcOptions), NullLoggerFactory.Instance); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageBinderFactoryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageBinderFactoryTest.cs index eed043db10..6c983e1138 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageBinderFactoryTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageBinderFactoryTest.cs @@ -553,7 +553,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal modelBinderFactory, new DefaultObjectValidator( modelMetadataProvider, - new[] { TestModelValidatorProvider.CreateDefaultProvider() }), + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), _optionsAccessor, NullLoggerFactory.Instance); @@ -678,7 +679,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal modelBinderFactory, new DefaultObjectValidator( modelMetadataProvider, - new[] { TestModelValidatorProvider.CreateDefaultProvider() }), + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), _optionsAccessor, NullLoggerFactory.Instance); @@ -732,7 +734,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal modelBinderFactory, new DefaultObjectValidator( modelMetadataProvider, - new[] { TestModelValidatorProvider.CreateDefaultProvider() }), + new[] { TestModelValidatorProvider.CreateDefaultProvider() }, + new MvcOptions()), Options.Create(mvcOptions), NullLoggerFactory.Instance); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageHandlerResultFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageHandlerResultFilterTest.cs index 46b7996080..bbe92dd6da 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageHandlerResultFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageHandlerResultFilterTest.cs @@ -54,7 +54,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal } [Fact] - public async Task OnResultExecutionAsyn_ExecutesSyncFilters() + public async Task OnResultExecutionAsync_ExecutesSyncFilters() { // Arrange var pageContext = new PageContext(new ActionContext( diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageRouteModelFactoryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageRouteModelFactoryTest.cs index 64e9156fa5..7269743cc8 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageRouteModelFactoryTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageRouteModelFactoryTest.cs @@ -165,8 +165,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal }); } - [ConditionalTheory] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Theory] [InlineData("/Areas/About.cshtml")] [InlineData("/Areas/MyArea/Index.cshtml")] public void TryParseAreaPath_ReturnsFalse_IfPathDoesNotConform(string path) @@ -182,8 +181,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal Assert.False(success); } - [ConditionalTheory] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Theory] [InlineData("/Areas/MyArea/Views/About.cshtml")] [InlineData("/Areas/MyArea/SubDir/Pages/Index.cshtml")] [InlineData("/Areas/MyArea/NotPages/SubDir/About.cshtml")] @@ -200,8 +198,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal Assert.False(success); } - [ConditionalTheory] - [FrameworkSkipCondition(RuntimeFrameworks.CLR, SkipReason = "Fails due to dotnet/standard#567")] + [Theory] [InlineData("/Areas/MyArea/Pages/Index.cshtml", "MyArea", "/Index")] [InlineData("/Areas/Accounts/Pages/Manage/Edit.cshtml", "Accounts", "/Manage/Edit")] public void TryParseAreaPath_ParsesAreaPath( diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs index e1571e451f..b0690ea559 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs @@ -2,9 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Razor; -using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -186,7 +187,10 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal private static RazorViewEngineOptions GetViewEngineOptions() { - var defaultSetup = new RazorViewEngineOptionsSetup(Mock.Of()); + var defaultSetup = new RazorViewEngineOptionsSetup( + Mock.Of(), + NullLoggerFactory.Instance, + Options.Create(new MvcCompatibilityOptions())); var options = new RazorViewEngineOptions(); defaultSetup.Configure(options); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorProjectPageRouteModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorProjectPageRouteModelProviderTest.cs index e6bc51da23..4b8aa8e581 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorProjectPageRouteModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorProjectPageRouteModelProviderTest.cs @@ -53,7 +53,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal fileSystem.Add(new TestRazorProjectItem("/Areas/Products/Pages/Manage/Categories.cshtml", "@page")); fileSystem.Add(new TestRazorProjectItem("/Areas/Products/Pages/Index.cshtml", "@page")); fileSystem.Add(new TestRazorProjectItem("/Areas/Products/Pages/List.cshtml", "@page \"{sortOrder?}\"")); - fileSystem.Add(new TestRazorProjectItem("/Areas/Products/Pages/_ViewStart.cshtml", "@page")); + fileSystem.Add(new TestRazorProjectItem("/Areas/Products/Pages/_Test.cshtml", "@page")); var optionsManager = Options.Create(new RazorPagesOptions { AllowAreas = true }); var provider = new RazorProjectPageRouteModelProvider(fileSystem, optionsManager, NullLoggerFactory.Instance); @@ -102,6 +102,24 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal }); }, model => + { + Assert.Equal("/Areas/Products/Pages/_Test.cshtml", model.RelativePath); + Assert.Equal("/_Test", model.ViewEnginePath); + Assert.Collection(model.Selectors, + selector => Assert.Equal("Products/_Test", selector.AttributeRouteModel.Template)); + Assert.Collection(model.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("Products", kvp.Value); + }, + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/_Test", kvp.Value); + }); + }, + model => { Assert.Equal("/Areas/Products/Pages/Manage/Categories.cshtml", model.RelativePath); Assert.Equal("/Manage/Categories", model.ViewEnginePath); @@ -272,37 +290,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal }); } - [Fact] - public void OnProvidersExecuting_SkipsPagesStartingWithUnderscore() - { - // Arrange - var fileSystem = new VirtualRazorProjectFileSystem(); - fileSystem.Add(new TestRazorProjectItem("/Pages/Home.cshtml", "@page")); - fileSystem.Add(new TestRazorProjectItem("/Pages/_Layout.cshtml", "@page")); - - var optionsManager = Options.Create(new RazorPagesOptions()); - optionsManager.Value.RootDirectory = "/"; - var provider = new RazorProjectPageRouteModelProvider(fileSystem, optionsManager, NullLoggerFactory.Instance); - var context = new PageRouteModelProviderContext(); - - // Act - provider.OnProvidersExecuting(context); - - // Assert - Assert.Collection(context.RouteModels, - model => - { - Assert.Equal("/Pages/Home.cshtml", model.RelativePath); - }); - } - [Fact] public void OnProvidersExecuting_DiscoversFilesUnderBasePath() { // Arrange var fileSystem = new VirtualRazorProjectFileSystem(); fileSystem.Add(new TestRazorProjectItem("/Pages/Index.cshtml", "@page")); - fileSystem.Add(new TestRazorProjectItem("/Pages/_Layout.cshtml", "@page")); + fileSystem.Add(new TestRazorProjectItem("/Pages/_Layout.cshtml", "")); fileSystem.Add(new TestRazorProjectItem("/NotPages/Index.cshtml", "@page")); fileSystem.Add(new TestRazorProjectItem("/NotPages/_Layout.cshtml", "@page")); fileSystem.Add(new TestRazorProjectItem("/Index.cshtml", "@page")); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/ResponseCacheFilterApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/ResponseCacheFilterApplicationModelProviderTest.cs index cf6acab03f..6b08977e2d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/ResponseCacheFilterApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/ResponseCacheFilterApplicationModelProviderTest.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -31,7 +32,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Assert Assert.Collection( context.PageApplicationModel.Filters, - f => Assert.IsType(f)); + f => Assert.IsType(f), + f => Assert.IsType(f)); } private class PageWithoutResponseCache : Page @@ -66,6 +68,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal context.PageApplicationModel.Filters, f => { }, f => Assert.IsType(f), + f => Assert.IsType(f), f => { var filter = Assert.IsType(f); @@ -112,6 +115,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal context.PageApplicationModel.Filters, f => { }, f => Assert.IsType(f), + f => Assert.IsType(f), f => { var filter = Assert.IsType(f); @@ -139,7 +143,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { var defaultProvider = new DefaultPageApplicationModelProvider( TestModelMetadataProvider.CreateDefaultProvider(), - Options.Create(new MvcOptions())); + Options.Create(new MvcOptions()), + Options.Create(new RazorPagesOptions { AllowDefaultHandlingForOptionsRequests = true })); var context = new PageApplicationModelProviderContext(new PageActionDescriptor(), typeInfo); defaultProvider.OnProvidersExecuting(context); return context; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Microsoft.AspNetCore.Mvc.RazorPages.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Microsoft.AspNetCore.Mvc.RazorPages.Test.csproj index 26a80f2c7a..967a8c3ef4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Microsoft.AspNetCore.Mvc.RazorPages.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Microsoft.AspNetCore.Mvc.RazorPages.Test.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) @@ -6,12 +6,10 @@ - + - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageModelTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageModelTest.cs index 6d0913d394..bfb8d6991c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageModelTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageModelTest.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Testing; @@ -329,80 +328,80 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages [InlineData("")] [InlineData(null)] [InlineData("SampleController")] - public void RedirectToAction_WithParameterActionAndControllerName_SetsEqualNames(string pageModelName) + public void RedirectToAction_WithParameterActionAndControllerName_SetsEqualNames(string controllerName) { // Arrange var pageModel = new TestPageModel(); // Act - var resultTemporary = pageModel.RedirectToAction("SampleAction", pageModelName); + var resultTemporary = pageModel.RedirectToAction("SampleAction", controllerName); // Assert Assert.IsType(resultTemporary); Assert.False(resultTemporary.PreserveMethod); Assert.False(resultTemporary.Permanent); Assert.Equal("SampleAction", resultTemporary.ActionName); - Assert.Equal(pageModelName, resultTemporary.ControllerName); + Assert.Equal(controllerName, resultTemporary.ControllerName); } [Theory] [InlineData("")] [InlineData(null)] [InlineData("SampleController")] - public void RedirectToActionPreserveMethod_WithParameterActionAndControllerName_SetsEqualNames(string pageModelName) + public void RedirectToActionPreserveMethod_WithParameterActionAndControllerName_SetsEqualNames(string controllerName) { // Arrange var pageModel = new TestPageModel(); // Act - var resultTemporary = pageModel.RedirectToActionPreserveMethod(actionName: "SampleAction", controllerName: pageModelName); + var resultTemporary = pageModel.RedirectToActionPreserveMethod(actionName: "SampleAction", controllerName: controllerName); // Assert Assert.IsType(resultTemporary); Assert.True(resultTemporary.PreserveMethod); Assert.False(resultTemporary.Permanent); Assert.Equal("SampleAction", resultTemporary.ActionName); - Assert.Equal(pageModelName, resultTemporary.ControllerName); + Assert.Equal(controllerName, resultTemporary.ControllerName); } [Theory] [InlineData("")] [InlineData(null)] [InlineData("SampleController")] - public void RedirectToActionPermanent_WithParameterActionAndControllerName_SetsEqualNames(string pageModelName) + public void RedirectToActionPermanent_WithParameterActionAndControllerName_SetsEqualNames(string controllerName) { // Arrange var pageModel = new TestPageModel(); // Act - var resultPermanent = pageModel.RedirectToActionPermanent("SampleAction", pageModelName); + var resultPermanent = pageModel.RedirectToActionPermanent("SampleAction", controllerName); // Assert Assert.IsType(resultPermanent); Assert.False(resultPermanent.PreserveMethod); Assert.True(resultPermanent.Permanent); Assert.Equal("SampleAction", resultPermanent.ActionName); - Assert.Equal(pageModelName, resultPermanent.ControllerName); + Assert.Equal(controllerName, resultPermanent.ControllerName); } [Theory] [InlineData("")] [InlineData(null)] [InlineData("SampleController")] - public void RedirectToActionPermanentPreserveMethod_WithParameterActionAndControllerName_SetsEqualNames(string pageModelName) + public void RedirectToActionPermanentPreserveMethod_WithParameterActionAndControllerName_SetsEqualNames(string controllerName) { // Arrange var pageModel = new TestPageModel(); // Act - var resultPermanent = pageModel.RedirectToActionPermanentPreserveMethod(actionName: "SampleAction", controllerName: pageModelName); + var resultPermanent = pageModel.RedirectToActionPermanentPreserveMethod(actionName: "SampleAction", controllerName: controllerName); // Assert Assert.IsType(resultPermanent); Assert.True(resultPermanent.PreserveMethod); Assert.True(resultPermanent.Permanent); Assert.Equal("SampleAction", resultPermanent.ActionName); - Assert.Equal(pageModelName, resultPermanent.ControllerName); + Assert.Equal(controllerName, resultPermanent.ControllerName); } [Theory] @@ -1022,10 +1021,10 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages // Arrange var pageModel = new TestPageModel(); var pageName = "/Page-Name"; - var routeVaues = new { key = "value" }; + var routeValues = new { key = "value" }; // Act - var result = pageModel.RedirectToPage(pageName, routeVaues); + var result = pageModel.RedirectToPage(pageName, routeValues); // Assert Assert.IsType(result); @@ -1895,6 +1894,113 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages testPageModel.Verify(); } + [Fact] + public void PartialView_WithName() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPageModel + { + PageContext = new PageContext + { + ViewData = viewData + } + }; + + // Act + var result = pageModel.Partial("LoginStatus"); + + // Assert + Assert.NotNull(result); + Assert.Equal("LoginStatus", result.ViewName); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void PartialView_WithNameAndModel() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPageModel + { + PageContext = new PageContext + { + ViewData = viewData + } + }; + var model = new { Username = "Admin" }; + + // Act + var result = pageModel.Partial("LoginStatus", model); + + // Assert + Assert.NotNull(result); + Assert.Equal("LoginStatus", result.ViewName); + Assert.Equal(model, result.Model); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void ViewComponent_WithName() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPageModel + { + PageContext = new PageContext + { + ViewData = viewData, + }, + }; + + // Act + var result = pageModel.ViewComponent("TagCloud"); + + // Assert + Assert.NotNull(result); + Assert.Equal("TagCloud", result.ViewComponentName); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void ViewComponent_WithType() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPageModel + { + PageContext = new PageContext + { + ViewData = viewData, + }, + }; + + // Act + var result = pageModel.ViewComponent(typeof(Guid)); + + // Assert + Assert.NotNull(result); + Assert.Equal(typeof(Guid), result.ViewComponentType); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void ViewComponent_WithArguments() + { + // Arrange + var pageModel = new TestPageModel(); + var arguments = new { Arg1 = "Hi", Arg2 = "There" }; + + // Act + var result = pageModel.ViewComponent(typeof(Guid), arguments); + + // Assert + Assert.NotNull(result); + + Assert.Equal(typeof(Guid), result.ViewComponentType); + Assert.Same(arguments, result.Arguments); + } + private class ContentPageModel : PageModel { public IActionResult Content_WithNoEncoding() diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageTest.cs index aaefccfcc9..3a0e7ad2b1 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageTest.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; using Microsoft.AspNetCore.Routing; @@ -1097,10 +1096,10 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages // Arrange var page = new TestPage(); var pageName = "/Page-Name"; - var routeVaues = new { key = "value" }; + var routeValues = new { key = "value" }; // Act - var result = page.RedirectToPage(pageName, routeVaues); + var result = page.RedirectToPage(pageName, routeValues); // Assert Assert.IsType(result); @@ -1698,6 +1697,122 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages Assert.Equal(statusCode, result.StatusCode); } + [Fact] + public void PartialView_WithName() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPage + { + ViewContext = new ViewContext + { + ViewData = viewData + } + }; + + // Act + var result = pageModel.Partial("LoginStatus"); + + // Assert + Assert.NotNull(result); + Assert.Equal("LoginStatus", result.ViewName); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void PartialView_WithNameAndModel() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPage + { + ViewContext = new ViewContext + { + ViewData = viewData + } + }; + var model = new { Username = "Admin" }; + + // Act + var result = pageModel.Partial("LoginStatus", model); + + // Assert + Assert.NotNull(result); + Assert.Equal("LoginStatus", result.ViewName); + Assert.Equal(model, result.Model); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void ViewComponent_WithName() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var page = new TestPage + { + ViewContext = new ViewContext + { + ViewData = viewData, + }, + }; + + // Act + var result = page.ViewComponent("TagCloud"); + + // Assert + Assert.NotNull(result); + Assert.Equal("TagCloud", result.ViewComponentName); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void ViewComponent_WithType() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var page = new TestPage + { + ViewContext = new ViewContext + { + ViewData = viewData, + }, + }; + + // Act + var result = page.ViewComponent(typeof(Guid)); + + // Assert + Assert.NotNull(result); + Assert.Equal(typeof(Guid), result.ViewComponentType); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void ViewComponent_WithArguments() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var page = new TestPage + { + ViewContext = new ViewContext + { + ViewData = viewData, + }, + }; + + var arguments = new { Arg1 = "Hi", Arg2 = "There" }; + + // Act + var result = page.ViewComponent(typeof(Guid), arguments); + + // Assert + Assert.NotNull(result); + + Assert.Equal(typeof(Guid), result.ViewComponentType); + Assert.Same(arguments, result.Arguments); + Assert.Same(viewData, result.ViewData); + } + public static IEnumerable RedirectTestData { get diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs index 7cd1718b20..9f7190a6e0 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.WebEncoders.Testing; using Moq; @@ -303,6 +304,31 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Equal(expected, key); } + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void GenerateKey_UsesVaryByRoute_UsesInvariantCulture() + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper( + new CacheTagHelperMemoryCacheFactory(Mock.Of()), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByRoute = "Category", + }; + cacheTagHelper.ViewContext.RouteData.Values["id"] = 4; + cacheTagHelper.ViewContext.RouteData.Values["category"] = + new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)); + var expected = "CacheTagHelper||testid||VaryByRoute(Category||10/31/2018 07:37:38 -07:00)"; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + [Fact] public void GenerateKey_UsesVaryByUser_WhenUserIsNotAuthenticated() { @@ -345,6 +371,27 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Equal(expected, key); } + [Fact] + [ReplaceCulture("fr-FR", "es-ES")] + public void GenerateKey_UsesCultureAndUICultureName_IfVaryByCulture_IsSet() + { + // Arrange + var expected = "CacheTagHelper||testid||VaryByCulture||fr-FR||es-ES"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of()), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByCulture = true + }; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + [Fact] public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey() { @@ -371,15 +418,137 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Equal(expected, key); } + [Fact] + [ReplaceCulture("zh", "zh-Hans")] + public void GenerateKey_WithVaryByCulture_ComposesWithOtherOptions() + { + // Arrange + var expected = "CacheTagHelper||testid||VaryBy||custom-value||" + + "VaryByHeader(content-type||text/html)||VaryByCulture||zh||zh-Hans"; + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of()), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByCulture = true, + VaryByHeader = "content-type", + VaryBy = "custom-value" + }; + cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html"; + + // Act + var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext); + var key = cacheTagKey.GenerateKey(); + + // Assert + Assert.Equal(expected, key); + } + + [Fact] + public void Equality_ReturnsFalse_WhenVaryByCultureIsTrue_AndCultureIsDifferent() + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of()), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByCulture = true, + }; + + // Act + CacheTagKey key1; + using (new CultureReplacer("fr-FR")) + { + key1 = new CacheTagKey(cacheTagHelper, tagHelperContext); + } + + CacheTagKey key2; + using (new CultureReplacer("es-ES")) + { + key2 = new CacheTagKey(cacheTagHelper, tagHelperContext); + } + var equals = key1.Equals(key2); + var hashCode1 = key1.GetHashCode(); + var hashCode2 = key2.GetHashCode(); + + // Assert + Assert.False(equals, "CacheTagKeys must not be equal"); + Assert.NotEqual(hashCode1, hashCode2); + } + + [Fact] + public void Equality_ReturnsFalse_WhenVaryByCultureIsTrue_AndUICultureIsDifferent() + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of()), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByCulture = true, + }; + + // Act + CacheTagKey key1; + using (new CultureReplacer("fr", "fr-FR")) + { + key1 = new CacheTagKey(cacheTagHelper, tagHelperContext); + } + + CacheTagKey key2; + using (new CultureReplacer("fr", "fr-CA")) + { + key2 = new CacheTagKey(cacheTagHelper, tagHelperContext); + } + var equals = key1.Equals(key2); + var hashCode1 = key1.GetHashCode(); + var hashCode2 = key2.GetHashCode(); + + // Assert + Assert.False(equals, "CacheTagKeys must not be equal"); + Assert.NotEqual(hashCode1, hashCode2); + } + + [Fact] + public void Equality_ReturnsTrue_WhenVaryByCultureIsTrue_AndCultureIsSame() + { + // Arrange + var tagHelperContext = GetTagHelperContext(); + var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of()), new HtmlTestEncoder()) + { + ViewContext = GetViewContext(), + VaryByCulture = true, + }; + + // Act + CacheTagKey key1; + CacheTagKey key2; + using (new CultureReplacer("fr-FR", "fr-FR")) + { + key1 = new CacheTagKey(cacheTagHelper, tagHelperContext); + } + + using (new CultureReplacer("fr-fr", "fr-fr")) + { + key2 = new CacheTagKey(cacheTagHelper, tagHelperContext); + } + + var equals = key1.Equals(key2); + var hashCode1 = key1.GetHashCode(); + var hashCode2 = key2.GetHashCode(); + + // Assert + Assert.True(equals, "CacheTagKeys must be equal"); + Assert.Equal(hashCode1, hashCode2); + } + private static ViewContext GetViewContext() { var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); return new ViewContext(actionContext, - Mock.Of(), - new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()), - Mock.Of(), - TextWriter.Null, - new HtmlHelperOptions()); + Mock.Of(), + new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()), + Mock.Of(), + TextWriter.Null, + new HtmlHelperOptions()); } private static TagHelperContext GetTagHelperContext(string id = "testid") diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/FileVersionProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DefaultFileVersionProviderTest.cs similarity index 71% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/FileVersionProviderTest.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DefaultFileVersionProviderTest.cs index 533a38faf3..9446703636 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/FileVersionProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DefaultFileVersionProviderTest.cs @@ -4,17 +4,18 @@ using System.Collections.Generic; using System.IO; using System.Text; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal +namespace Microsoft.AspNetCore.Mvc.TagHelpers { - public class FileVersionProviderTest + public class DefaultFileVersionProviderTest { [Theory] [InlineData("/hello/world", "/hello/world?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk")] @@ -26,13 +27,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal { // Arrange var fileProvider = GetMockFileProvider(filePath); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase()); + var fileVersionProvider = GetFileVersionProvider(fileProvider); + var requestPath = GetRequestPathBase(); // Act - var result = fileVersionProvider.AddFileVersionToPath(filePath); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, filePath); // Assert Assert.Equal(expected, result); @@ -47,14 +46,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal path, pathStartsWithAppName: false, fileDoesNotExist: true); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase()); + var fileVersionProvider = GetFileVersionProvider(fileProvider); var mockFileProvider = Mock.Get(fileProvider); + var requestPath = GetRequestPathBase(); // Act 1 - var result = fileVersionProvider.AddFileVersionToPath(path); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, path); // Assert 1 Assert.Equal(path, result); @@ -62,7 +59,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal mockFileProvider.Verify(f => f.Watch(It.IsAny()), Times.Once()); // Act 2 - result = fileVersionProvider.AddFileVersionToPath(path); + result = fileVersionProvider.AddFileVersionToPath(requestPath, path); // Assert 2 Assert.Equal(path, result); @@ -79,14 +76,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal var fileProvider = GetMockFileProvider( "file.txt", pathStartsWithAppName); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase()); + var fileVersionProvider = GetFileVersionProvider(fileProvider); var mockFileProvider = Mock.Get(fileProvider); + var requestPath = GetRequestPathBase(); // Act 1 - var result = fileVersionProvider.AddFileVersionToPath(path); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, path); // Assert 1 Assert.Equal($"{path}?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result); @@ -94,7 +89,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal mockFileProvider.Verify(f => f.Watch(It.IsAny()), Times.Once()); // Act 2 - result = fileVersionProvider.AddFileVersionToPath(path); + result = fileVersionProvider.AddFileVersionToPath(requestPath, path); // Assert 2 Assert.Equal($"{path}?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result); @@ -107,13 +102,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal { // Arrange var fileProvider = new TestFileProvider(); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase()); + var fileVersionProvider = GetFileVersionProvider(fileProvider); + var requestPath = GetRequestPathBase(); // Act 1 - File does not exist - var result = fileVersionProvider.AddFileVersionToPath("file.txt"); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, "file.txt"); // Assert 1 Assert.Equal("file.txt", result); @@ -121,7 +114,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal // Act 2 - File gets added fileProvider.AddFile("file.txt", "Hello World!"); fileProvider.GetChangeToken("file.txt").HasChanged = true; - result = fileVersionProvider.AddFileVersionToPath("file.txt"); + result = fileVersionProvider.AddFileVersionToPath(requestPath, "file.txt"); // Assert 2 Assert.Equal("file.txt?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result); @@ -132,14 +125,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal { // Arrange var fileProvider = new TestFileProvider(); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase()); + var requestPath = GetRequestPathBase(); + var fileVersionProvider = GetFileVersionProvider(fileProvider); fileProvider.AddFile("file.txt", "Hello World!"); // Act 1 - File exists - var result = fileVersionProvider.AddFileVersionToPath("file.txt"); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, "file.txt"); // Assert 1 Assert.Equal("file.txt?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result); @@ -147,7 +138,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal // Act 2 fileProvider.DeleteFile("file.txt"); fileProvider.GetChangeToken("file.txt").HasChanged = true; - result = fileVersionProvider.AddFileVersionToPath("file.txt"); + result = fileVersionProvider.AddFileVersionToPath(requestPath, "file.txt"); // Assert 2 Assert.Equal("file.txt", result); @@ -158,14 +149,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal { // Arrange var fileProvider = new TestFileProvider(); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase("/wwwroot/")); + var requestPath = GetRequestPathBase("/wwwroot/"); + var fileVersionProvider = GetFileVersionProvider(fileProvider); fileProvider.AddFile("file.txt", "Hello World!"); // Act 1 - File exists - var result = fileVersionProvider.AddFileVersionToPath("/wwwroot/file.txt"); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, "/wwwroot/file.txt"); // Assert 1 Assert.Equal("/wwwroot/file.txt?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result); @@ -173,7 +162,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal // Act 2 fileProvider.DeleteFile("file.txt"); fileProvider.GetChangeToken("file.txt").HasChanged = true; - result = fileVersionProvider.AddFileVersionToPath("/wwwroot/file.txt"); + result = fileVersionProvider.AddFileVersionToPath(requestPath, "/wwwroot/file.txt"); // Assert 2 Assert.Equal("/wwwroot/file.txt", result); @@ -193,14 +182,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal var fileProvider = new TestFileProvider(); fileProvider.AddFile("/hello/world", mockFile.Object); - - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase()); + var requestPath = GetRequestPathBase(); + var fileVersionProvider = GetFileVersionProvider(fileProvider); // Act - var result = fileVersionProvider.AddFileVersionToPath("/hello/world"); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, "/hello/world"); // Assert Assert.True(stream.Disposed); @@ -217,13 +203,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal { // Arrange var fileProvider = GetMockFileProvider(filePath, pathStartsWithAppBase); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase(requestPathBase)); + var requestPath = GetRequestPathBase(requestPathBase); + var fileVersionProvider = GetFileVersionProvider(fileProvider); // Act - var result = fileVersionProvider.AddFileVersionToPath(filePath); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, filePath); // Assert Assert.Equal(filePath + "?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result); @@ -235,13 +219,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal // Arrange var filePath = "http://contoso.com/hello/world"; var fileProvider = GetMockFileProvider(filePath, false, true); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - new MemoryCache(new MemoryCacheOptions()), - GetRequestPathBase()); + var requestPath = GetRequestPathBase(); + var fileVersionProvider = GetFileVersionProvider(fileProvider); // Act - var result = fileVersionProvider.AddFileVersionToPath(filePath); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, filePath); // Assert Assert.Equal("http://contoso.com/hello/world", result); @@ -253,56 +235,71 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal // Arrange var filePath = "/hello/world"; var fileProvider = GetMockFileProvider(filePath); - var memoryCache = new MemoryCache(new MemoryCacheOptions()); - memoryCache.Set(filePath, "FromCache"); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - memoryCache, - GetRequestPathBase()); + var fileVersionProvider = GetFileVersionProvider(fileProvider); + var cacheEntryOptions = new MemoryCacheEntryOptions(); + cacheEntryOptions.SetSize(1); + fileVersionProvider.Cache.Set(filePath, "FromCache", cacheEntryOptions); + var requestPath = GetRequestPathBase(); // Act - var result = fileVersionProvider.AddFileVersionToPath(filePath); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, filePath); // Assert Assert.Equal("FromCache", result); } - [Theory] - [InlineData("/hello/world", "/hello/world", null)] - [InlineData("/testApp/hello/world", "/hello/world", "/testApp")] - public void SetsValueInCache(string filePath, string watchPath, string requestPathBase) + [Fact] + public void AddFileVersionToPath_CachesEntry() => AddFileVersionToPath("/hello/world", "/hello/world", null); + + [Fact] + public void AddFileVersionToPath_WithRequestPathBase_CachesEntry() => AddFileVersionToPath("/testApp/hello/world", "/hello/world", "/testApp"); + + private static void AddFileVersionToPath(string filePath, string watchPath, string requestPathBase) { // Arrange - var changeToken = new Mock(); + var expected = filePath + "?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk"; + var expectedSize = expected.Length * sizeof(char); + var changeToken = Mock.Of(); + var fileProvider = GetMockFileProvider(filePath, requestPathBase != null); Mock.Get(fileProvider) - .Setup(f => f.Watch(watchPath)).Returns(changeToken.Object); + .Setup(f => f.Watch(watchPath)).Returns(changeToken); - object cacheValue = null; - var value = new Mock(); - value.Setup(c => c.Value).Returns(cacheValue); - value.Setup(c => c.ExpirationTokens).Returns(new List()); + var cacheEntry = Mock.Of(c => c.ExpirationTokens == new List()); var cache = new Mock(); - cache.CallBase = true; - cache.Setup(c => c.TryGetValue(It.IsAny(), out cacheValue)) - .Returns(cacheValue != null); - cache.Setup(c => c.CreateEntry( - /*key*/ filePath)) - .Returns((object key) => value.Object) + + cache.Setup(c => c.CreateEntry(filePath)) + .Returns(cacheEntry) .Verifiable(); - var fileVersionProvider = new FileVersionProvider( - fileProvider, - cache.Object, - GetRequestPathBase(requestPathBase)); + + var requestPath = GetRequestPathBase(requestPathBase); + + var fileVersionProvider = GetFileVersionProvider(fileProvider, cache.Object); // Act - var result = fileVersionProvider.AddFileVersionToPath(filePath); + var result = fileVersionProvider.AddFileVersionToPath(requestPath, filePath); // Assert - Assert.Equal(filePath + "?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result); + Assert.Equal(expected, result); + Assert.Equal(expected, cacheEntry.Value); + Assert.Equal(expectedSize, cacheEntry.Size); cache.VerifyAll(); } + private static DefaultFileVersionProvider GetFileVersionProvider( + IFileProvider fileProvider, + IMemoryCache memoryCache = null) + { + var hostingEnv = Mock.Of(e => e.WebRootFileProvider == fileProvider); + var cacheProvider = new TagHelperMemoryCacheProvider(); + if (memoryCache != null) + { + cacheProvider.Cache = memoryCache; + } + + return new DefaultFileVersionProvider(hostingEnv, cacheProvider); + } + private static IFileProvider GetMockFileProvider( string filePath, bool pathStartsWithAppName = false, diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs index 11fbbe5d0a..903a335451 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs @@ -13,7 +13,6 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ImageTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ImageTagHelperTest.cs index a2159f251f..1c8a307c4a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ImageTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ImageTagHelperTest.cs @@ -12,13 +12,13 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.WebEncoders.Testing; @@ -57,8 +57,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers outputAttributes, getChildContentAsync: (useCachedResult, encoder) => Task.FromResult( new DefaultTagHelperContent())); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var urlHelper = new Mock(); // Ensure expanded path does not look like an absolute path on Linux, avoiding @@ -71,16 +69,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers .Setup(f => f.GetUrlHelper(It.IsAny())) .Returns(urlHelper.Object); - var helper = new ImageTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - urlHelperFactory.Object) - { - ViewContext = viewContext, - AppendVersion = true, - Src = src, - }; + var helper = GetHelper(urlHelperFactory: urlHelperFactory.Object); + helper.AppendVersion = true; + helper.Src = src; // Act helper.Process(context, output); @@ -122,19 +113,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { "src", "testimage.png?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk" } }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - - var helper = new ImageTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - Src = "testimage.png", - AppendVersion = true, - }; + var helper = GetHelper(); + helper.Src = "testimage.png"; + helper.AppendVersion = true; // Act helper.Process(context, output); @@ -145,10 +126,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers for (var i = 0; i < expectedOutput.Attributes.Count; i++) { - var expectedAtribute = expectedOutput.Attributes[i]; + var expectedAttribute = expectedOutput.Attributes[i]; var actualAttribute = output.Attributes[i]; - Assert.Equal(expectedAtribute.Name, actualAttribute.Name); - Assert.Equal(expectedAtribute.Value.ToString(), actualAttribute.Value.ToString()); + Assert.Equal(expectedAttribute.Name, actualAttribute.Name); + Assert.Equal(expectedAttribute.Value.ToString(), actualAttribute.Value.ToString()); } } @@ -170,12 +151,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var hostingEnvironment = MakeHostingEnvironment(); var viewContext = MakeViewContext(); - var helper = new ImageTagHelper(hostingEnvironment, MakeCache(), new HtmlTestEncoder(), MakeUrlHelperFactory()) - { - ViewContext = viewContext, - Src = "/images/test-image.png", - AppendVersion = true - }; + var helper = GetHelper(); + helper.Src = "/images/test-image.png"; + helper.AppendVersion = true; // Act helper.Process(context, output); @@ -206,12 +184,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var hostingEnvironment = MakeHostingEnvironment(); var viewContext = MakeViewContext(); - var helper = new ImageTagHelper(hostingEnvironment, MakeCache(), new HtmlTestEncoder(), MakeUrlHelperFactory()) - { - ViewContext = viewContext, - Src = "/images/test-image.png", - AppendVersion = false - }; + var helper = GetHelper(); + helper.Src = "/images/test-image.png"; // Act helper.Process(context, output); @@ -242,12 +216,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var hostingEnvironment = MakeHostingEnvironment(); var viewContext = MakeViewContext("/bar"); - var helper = new ImageTagHelper(hostingEnvironment, MakeCache(), new HtmlTestEncoder(), MakeUrlHelperFactory()) - { - ViewContext = viewContext, - Src = "/bar/images/image.jpg", - AppendVersion = true - }; + var helper = GetHelper(); + helper.Src = "/bar/images/image.jpg"; + helper.AppendVersion = true; // Act helper.Process(context, output); @@ -280,6 +251,29 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return viewContext; } + private static ImageTagHelper GetHelper( + IHostingEnvironment hostingEnvironment = null, + IUrlHelperFactory urlHelperFactory = null, + ViewContext viewContext = null) + { + hostingEnvironment = hostingEnvironment ?? MakeHostingEnvironment(); + urlHelperFactory = urlHelperFactory ?? MakeUrlHelperFactory(); + viewContext = viewContext ?? MakeViewContext(); + + var cacheProvider = new TagHelperMemoryCacheProvider(); + var fileVersionProvider = new DefaultFileVersionProvider(hostingEnvironment, cacheProvider); + + return new ImageTagHelper( + hostingEnvironment, + new TagHelperMemoryCacheProvider(), + fileVersionProvider, + new HtmlTestEncoder(), + urlHelperFactory) + { + ViewContext = viewContext, + }; + } + private static TagHelperContext MakeTagHelperContext( TagHelperAttributeList attributes) { @@ -328,8 +322,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return hostingEnvironment.Object; } - private static IMemoryCache MakeCache() => new MemoryCache(new MemoryCacheOptions()); - private static IUrlHelperFactory MakeUrlHelperFactory() { var urlHelper = new Mock(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/InputTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/InputTagHelperTest.cs index 79e44651b3..ce787ccf14 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/InputTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/InputTagHelperTest.cs @@ -9,7 +9,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; using Microsoft.AspNetCore.Razor.TagHelpers; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/AttributeMatcherTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/AttributeMatcherTest.cs index a4aff7f9ab..2fc93f6c91 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/AttributeMatcherTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/AttributeMatcherTest.cs @@ -65,7 +65,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal } [Fact] - public void DetermineMode_SetsModeWithHigestValue() + public void DetermineMode_SetsModeWithHighestValue() { // Arrange var modeInfos = new[] diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/DefaultTagHelperActivatorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/DefaultTagHelperActivatorTest.cs new file mode 100644 index 0000000000..ad85133eee --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/DefaultTagHelperActivatorTest.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.TagHelpers; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Razor.Internal +{ + // Tests to verify that script, link and image tag helper use the size limited instance of MemoryCache. + public class DefaultTagHelperActivatorTest + { + private readonly TagHelperMemoryCacheProvider CacheProvider = new TagHelperMemoryCacheProvider(); + private readonly IMemoryCache MemoryCache = new MemoryCache(new MemoryCacheOptions()); + private readonly IHostingEnvironment HostingEnvironment = Mock.Of(); + private readonly IFileVersionProvider FileVersionProvider = Mock.Of(); + + [Fact] + public void ScriptTagHelper_DoesNotUseMemoryCacheInstanceFromDI() + { + // Arrange + var activator = new DefaultTagHelperActivator(new TypeActivatorCache()); + var viewContext = CreateViewContext(); + + var scriptTagHelper = activator.Create(viewContext); + + Assert.Same(CacheProvider.Cache, scriptTagHelper.Cache); + Assert.Same(HostingEnvironment, scriptTagHelper.HostingEnvironment); + Assert.Same(FileVersionProvider, scriptTagHelper.FileVersionProvider); + } + + [Fact] + public void LinkTagHelper_DoesNotUseMemoryCacheInstanceFromDI() + { + // Arrange + var activator = new DefaultTagHelperActivator(new TypeActivatorCache()); + var viewContext = CreateViewContext(); + + var linkTagHelper = activator.Create(viewContext); + + Assert.Same(CacheProvider.Cache, linkTagHelper.Cache); + Assert.Same(HostingEnvironment, linkTagHelper.HostingEnvironment); + Assert.Same(FileVersionProvider, linkTagHelper.FileVersionProvider); + } + + [Fact] + public void ImageTagHelper_DoesNotUseMemoryCacheInstanceFromDI() + { + // Arrange + var activator = new DefaultTagHelperActivator(new TypeActivatorCache()); + var viewContext = CreateViewContext(); + + var imageTagHelper = activator.Create(viewContext); + + Assert.Same(CacheProvider.Cache, imageTagHelper.Cache); + Assert.Same(HostingEnvironment, imageTagHelper.HostingEnvironment); + Assert.Same(FileVersionProvider, imageTagHelper.FileVersionProvider); + } + + private ViewContext CreateViewContext() + { + var services = new ServiceCollection() + .AddSingleton(HostingEnvironment) + .AddSingleton(MemoryCache) + .AddSingleton(CacheProvider) + .AddSingleton(HtmlEncoder.Default) + .AddSingleton(JavaScriptEncoder.Default) + .AddSingleton(Mock.Of()) + .AddSingleton(FileVersionProvider) + .BuildServiceProvider(); + + var viewContext = new ViewContext + { + HttpContext = new DefaultHttpContext + { + RequestServices = services, + } + }; + + return viewContext; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs index 8493f1daa5..4bce947f96 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs @@ -411,6 +411,24 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal Assert.Collection(excludePatterns, pattern => Assert.Equal($"{prefix}**/*.min.css", pattern)); } + [Fact] + public void BuildUrlList_AddsToMemoryCache_WithSizeLimit() + { + // Arrange + var cacheEntry = Mock.Of(m => m.ExpirationTokens == new List()); + var cache = Mock.Of(m => m.CreateEntry(It.IsAny()) == cacheEntry); + + var fileProvider = MakeFileProvider(MakeDirectoryContents("site.css", "blank.css")); + var requestPathBase = PathString.Empty; + var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase); + + // Act + var urlList = globbingUrlBuilder.BuildUrlList("/site.css", "**/*.css", excludePattern: null); + + // Assert + Assert.Equal(38, cacheEntry.Size); + } + public class FileNode { public FileNode(string name) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/LabelTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/LabelTagHelperTest.cs index 3d4e171a6f..106af2e50c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/LabelTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/LabelTagHelperTest.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/LinkTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/LinkTagHelperTest.cs index 1c18cf70cf..09ebce26e6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/LinkTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/LinkTagHelperTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -12,15 +13,15 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; @@ -56,8 +57,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { "href", hrefOutput }, }; var output = MakeTagHelperOutput("link", outputAttributes); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var urlHelper = new Mock(); // Ensure expanded path does not look like an absolute path on Linux, avoiding @@ -70,17 +69,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers .Setup(f => f.GetUrlHelper(It.IsAny())) .Returns(urlHelper.Object); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - urlHelperFactory.Object) - { - ViewContext = viewContext, - AppendVersion = true, - Href = href, - }; + var helper = GetHelper(urlHelperFactory: urlHelperFactory.Object); + helper.AppendVersion = true; + helper.Href = href; // Act helper.Process(context, output); @@ -164,25 +155,18 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("rel", new HtmlString("stylesheet")) })); var output = MakeTagHelperOutput("link", combinedOutputAttributes); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) + var helper = GetHelper(); + helper.FallbackHref = "test.css"; + helper.FallbackTestClass = "hidden"; + helper.FallbackTestProperty = "visibility"; + helper.FallbackTestValue = "hidden"; + helper.Href = "test.css"; + + var expectedAttributes = new TagHelperAttributeList(output.Attributes) { - ViewContext = viewContext, - FallbackHref = "test.css", - FallbackTestClass = "hidden", - FallbackTestProperty = "visibility", - FallbackTestValue = "hidden", - Href = "test.css", + new TagHelperAttribute("href", "test.css") }; - var expectedAttributes = new TagHelperAttributeList(output.Attributes); - expectedAttributes.Add(new TagHelperAttribute("href", "test.css")); // Act helper.Process(context, output); @@ -265,7 +249,49 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers tagHelper.FallbackTestValue = "hidden"; tagHelper.AppendVersion = true; } - } + }, + // asp-suppress-fallback-integrity Attribute true + { + new TagHelperAttributeList + { + new TagHelperAttribute("asp-fallback-href", "test.css"), + new TagHelperAttribute("asp-fallback-test-class", "hidden"), + new TagHelperAttribute("asp-fallback-test-property", "visibility"), + new TagHelperAttribute("asp-fallback-test-value", "hidden"), + new TagHelperAttribute("asp-append-version", "true"), + new TagHelperAttribute("asp-suppress-fallback-integrity", "true") + }, + tagHelper => + { + tagHelper.FallbackHref = "test.css"; + tagHelper.FallbackTestClass = "hidden"; + tagHelper.FallbackTestProperty = "visibility"; + tagHelper.FallbackTestValue = "hidden"; + tagHelper.AppendVersion = true; + tagHelper.SuppressFallbackIntegrity = true; + } + }, + // asp-suppress-fallback-integrity Attribute false + { + new TagHelperAttributeList + { + new TagHelperAttribute("asp-fallback-href", "test.css"), + new TagHelperAttribute("asp-fallback-test-class", "hidden"), + new TagHelperAttribute("asp-fallback-test-property", "visibility"), + new TagHelperAttribute("asp-fallback-test-value", "hidden"), + new TagHelperAttribute("asp-append-version", "true"), + new TagHelperAttribute("asp-suppress-fallback-integrity", "false") + }, + tagHelper => + { + tagHelper.FallbackHref = "test.css"; + tagHelper.FallbackTestClass = "hidden"; + tagHelper.FallbackTestProperty = "visibility"; + tagHelper.FallbackTestValue = "hidden"; + tagHelper.AppendVersion = true; + tagHelper.SuppressFallbackIntegrity = false; + } + }, }; } } @@ -279,8 +305,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Arrange var context = MakeTagHelperContext(attributes); var output = MakeTagHelperOutput("link"); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -288,16 +312,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new[] { "/common.css" }); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - GlobbingUrlBuilder = globbingUrlBuilder.Object - }; + var helper = GetHelper(); + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + setProperties(helper); // Act @@ -376,8 +393,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Arrange var context = MakeTagHelperContext(attributes); var output = MakeTagHelperOutput("link"); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -385,16 +400,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new[] { "/common.css" }); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - GlobbingUrlBuilder = globbingUrlBuilder.Object - }; + var helper = GetHelper(); + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; setProperties(helper); // Act @@ -428,23 +435,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { "rel", new HtmlString("stylesheet") }, { "data-extra", new HtmlString("something") }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - FallbackHref = "test.css", - FallbackTestClass = "hidden", - FallbackTestProperty = "visibility", - FallbackTestValue = "hidden", - Href = "test.css", - }; + var helper = GetHelper(); + helper.FallbackHref = "test.css"; + helper.FallbackTestClass = "hidden"; + helper.FallbackTestProperty = "visibility"; + helper.FallbackTestValue = "hidden"; + helper.Href = "test.css"; // Act helper.Process(context, output); @@ -539,18 +536,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Arrange var context = MakeTagHelperContext(attributes); var output = MakeTagHelperOutput("link"); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - }; + var helper = GetHelper(); setProperties(helper); // Act @@ -569,18 +556,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Arrange var context = MakeTagHelperContext(); var output = MakeTagHelperOutput("link"); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - }; + var helper = GetHelper(); // Act helper.Process(context, output); @@ -609,8 +586,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { { "rel", new HtmlString("stylesheet") }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -618,18 +593,52 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "**/*.css", null)) .Returns(new[] { "/base.css" }); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) + var helper = GetHelper(); + + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + helper.Href = "/css/site.css"; + helper.HrefInclude = "**/*.css"; + + // Act + helper.Process(context, output); + + // Assert + Assert.Equal("link", output.TagName); + Assert.Equal("/css/site.css", output.Attributes["href"].Value); + var content = HtmlContentUtilities.HtmlContentToString(output, new HtmlTestEncoder()); + Assert.Equal(expectedContent, content); + } + + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void RendersLinkTagsForGlobbedHrefResults_UsesInvariantCulture() + { + // Arrange + var expectedContent = "" + + ""; + var context = MakeTagHelperContext( + attributes: new TagHelperAttributeList + { + { "rel", new ConvertToStyleSheet() }, + { "href", "/css/site.css" }, + { "asp-href-include", "**/*.css" }, + }); + var output = MakeTagHelperOutput("link", attributes: new TagHelperAttributeList { - GlobbingUrlBuilder = globbingUrlBuilder.Object, - ViewContext = viewContext, - Href = "/css/site.css", - HrefInclude = "**/*.css", - }; + { "rel", new HtmlString("stylesheet") }, + }); + var globbingUrlBuilder = new Mock( + new TestFileProvider(), + Mock.Of(), + PathString.Empty); + globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "**/*.css", null)) + .Returns(new[] { "/base.css" }); + + var helper = GetHelper(); + + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + helper.Href = "/css/site.css"; + helper.HrefInclude = "**/*.css"; // Act helper.Process(context, output); @@ -672,8 +681,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { "literal", "all HTML encoded" }, { new TagHelperAttribute("mixed", mixed, HtmlAttributeValueStyle.SingleQuotes) }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -681,18 +688,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "**/*.css", null)) .Returns(new[] { "/base.css" }); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - GlobbingUrlBuilder = globbingUrlBuilder.Object, - Href = "/css/site.css", - HrefInclude = "**/*.css", - ViewContext = viewContext, - }; + var helper = GetHelper(); + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + helper.Href = "/css/site.css"; + helper.HrefInclude = "**/*.css"; // Act helper.Process(context, output); @@ -719,20 +718,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { { "rel", new HtmlString("stylesheet") }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - Href = "/css/site.css", - AppendVersion = true - }; + var helper = GetHelper(); + + helper.Href = "/css/site.css"; + helper.AppendVersion = true; // Act helper.Process(context, output); @@ -757,20 +747,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { { "rel", new HtmlString("stylesheet") }, }); - var hostingEnvironment = MakeHostingEnvironment(); var viewContext = MakeViewContext("/bar"); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - Href = "/bar/css/site.css", - AppendVersion = true - }; + var helper = GetHelper(); + helper.ViewContext = viewContext; + helper.Href = "/bar/css/site.css"; + helper.AppendVersion = true; // Act helper.Process(context, output); @@ -809,8 +791,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { { "rel", new HtmlString("stylesheet") }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -818,22 +798,14 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "**/fallback.css", null)) .Returns(new[] { "/fallback.css" }); - var helper = new LinkTagHelper( - MakeHostingEnvironment(), - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - AppendVersion = true, - Href = "/css/site.css", - FallbackHrefInclude = "**/fallback.css", - FallbackTestClass = "hidden", - FallbackTestProperty = "visibility", - FallbackTestValue = "hidden", - GlobbingUrlBuilder = globbingUrlBuilder.Object, - ViewContext = viewContext, - }; + var helper = GetHelper(); + helper.AppendVersion = true; + helper.Href = "/css/site.css"; + helper.FallbackHrefInclude = "**/fallback.css"; + helper.FallbackTestClass = "hidden"; + helper.FallbackTestProperty = "visibility"; + helper.FallbackTestValue = "hidden"; + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; // Act helper.Process(context, output); @@ -888,8 +860,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { "mixed", mixed }, { "rel", new HtmlString("stylesheet") }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -897,22 +867,15 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "**/fallback.css", null)) .Returns(new[] { "/fallback.css" }); - var helper = new LinkTagHelper( - MakeHostingEnvironment(), - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - AppendVersion = true, - FallbackHrefInclude = "**/fallback.css", - FallbackTestClass = "hidden", - FallbackTestProperty = "visibility", - FallbackTestValue = "hidden", - GlobbingUrlBuilder = globbingUrlBuilder.Object, - Href = "/css/site.css", - ViewContext = viewContext, - }; + var helper = GetHelper(); + + helper.AppendVersion = true; + helper.FallbackHrefInclude = "**/fallback.css"; + helper.FallbackTestClass = "hidden"; + helper.FallbackTestProperty = "visibility"; + helper.FallbackTestValue = "hidden"; + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + helper.Href = "/css/site.css"; // Act helper.Process(context, output); @@ -940,8 +903,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { { "rel", new HtmlString("stylesheet") }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -949,19 +910,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "**/*.css", null)) .Returns(new[] { "/base.css" }); - var helper = new LinkTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - GlobbingUrlBuilder = globbingUrlBuilder.Object, - ViewContext = viewContext, - Href = "/css/site.css", - HrefInclude = "**/*.css", - AppendVersion = true - }; + var helper = GetHelper(); + + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + helper.Href = "/css/site.css"; + helper.HrefInclude = "**/*.css"; + helper.AppendVersion = true; // Act helper.Process(context, output); @@ -975,12 +929,36 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers content); } + private static LinkTagHelper GetHelper( + IHostingEnvironment hostingEnvironment = null, + IUrlHelperFactory urlHelperFactory = null, + ViewContext viewContext = null) + { + hostingEnvironment = hostingEnvironment ?? MakeHostingEnvironment(); + urlHelperFactory = urlHelperFactory ?? MakeUrlHelperFactory(); + viewContext = viewContext ?? MakeViewContext(); + + var memoryCacheProvider = new TagHelperMemoryCacheProvider(); + var fileVersionProvider = new DefaultFileVersionProvider(hostingEnvironment, memoryCacheProvider); + + return new LinkTagHelper( + hostingEnvironment, + memoryCacheProvider, + fileVersionProvider, + new HtmlTestEncoder(), + new JavaScriptTestEncoder(), + urlHelperFactory) + { + ViewContext = viewContext, + }; + } + private static ViewContext MakeViewContext(string requestPathBase = null) { var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); if (requestPathBase != null) { - actionContext.HttpContext.Request.PathBase = new Http.PathString(requestPathBase); + actionContext.HttpContext.Request.PathBase = new PathString(requestPathBase); } var metadataProvider = new EmptyModelMetadataProvider(); @@ -1044,8 +1022,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return hostingEnvironment.Object; } - private static IMemoryCache MakeCache() => new MemoryCache(new MemoryCacheOptions()); - private static IUrlHelperFactory MakeUrlHelperFactory() { var urlHelper = new Mock(); @@ -1060,5 +1036,99 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return urlHelperFactory.Object; } + + private class ConvertToStyleSheet : IConvertible + { + public TypeCode GetTypeCode() + { + throw new NotImplementedException(); + } + + public bool ToBoolean(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public byte ToByte(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public char ToChar(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public DateTime ToDateTime(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public decimal ToDecimal(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public double ToDouble(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public short ToInt16(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public int ToInt32(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public long ToInt64(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public sbyte ToSByte(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public float ToSingle(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public string ToString(IFormatProvider provider) + { + Assert.Equal(CultureInfo.InvariantCulture, provider); + return "stylesheet"; + } + + public object ToType(Type conversionType, IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public ushort ToUInt16(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public uint ToUInt32(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public ulong ToUInt64(IFormatProvider provider) + { + throw new NotImplementedException(); + } + + public override string ToString() + { + return "something else"; + } + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Microsoft.AspNetCore.Mvc.TagHelpers.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Microsoft.AspNetCore.Mvc.TagHelpers.Test.csproj index d9c1634c3c..fd04152330 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Microsoft.AspNetCore.Mvc.TagHelpers.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/Microsoft.AspNetCore.Mvc.TagHelpers.Test.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) @@ -6,12 +6,9 @@ - + - - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs index d1f0e16ee1..d6789c5376 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; @@ -362,7 +361,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } [Fact] - public async Task ProcessAsync_DoesNotUseModelFromViewdata_IfModelExpressionEvalulatesToNull() + public async Task ProcessAsync_DoesNotUseModelFromViewdata_IfModelExpressionEvaluatesToNull() { // Arrange var bufferScope = new TestViewBufferScope(); @@ -456,7 +455,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } [Fact] - public async Task ProcessAsync_UsesModelOnViewContextViewData_WhenModelExpresionIsNull() + public async Task ProcessAsync_UsesModelOnViewContextViewData_WhenModelExpressionIsNull() { // Arrange var bufferScope = new TestViewBufferScope(); @@ -570,7 +569,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } [Fact] - public async Task ProcessAsync_Throws_IfGetViewAndFindReturnNotFoundResults() + public async Task ProcessAsync_Throws_If_NotOptional_And_GetViewAndFindReturnNotFoundResults() { // Arrange var bufferScope = new TestViewBufferScope(); @@ -597,6 +596,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Name = partialName, ViewContext = viewContext, ViewData = viewData, + Optional = false }; var tagHelperContext = GetTagHelperContext(); var output = GetTagHelperOutput(); @@ -606,6 +606,239 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers () => tagHelper.ProcessAsync(tagHelperContext, output)); Assert.Equal(expected, exception.Message); } + + [Fact] + public async Task ProcessAsync_IfOptional_And_ViewIsNotFound_WillNotRenderAnything() + { + // Arrange + var expected = string.Empty; + var bufferScope = new TestViewBufferScope(); + var partialName = "_ThisViewDoesNotExists"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, searchedLocations: Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, searchedLocations: Array.Empty())); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + Optional = true + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Empty(content); + } + + [Fact] + public async Task ProcessAsync_RendersMainPartial_If_FallbackIsSet_AndMainPartialIsFound() + { + // Arrange + var expected = "Hello from partial!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var fallbackView = new Mock(); + fallbackView.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write("Hello from fallback partial!"); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, fallbackView.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_IfHasFallback_Throws_When_MainPartialAndFallback_AreNotFound() + { + // Arrange + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var expected = string.Join( + Environment.NewLine, + $"The partial view '{partialName}' was not found. The following locations were searched:", + "PartialNotFound1", + "PartialNotFound2", + "PartialNotFound3", + "PartialNotFound4", + $"The fallback partial view '{fallbackName}' was not found. The following locations were searched:", + "FallbackNotFound1", + "FallbackNotFound2", + "FallbackNotFound3", + "FallbackNotFound4"); + var viewData = new ViewDataDictionary(new TestModelMetadataProvider(), new ModelStateDictionary()); + var viewContext = GetViewContext(); + + var view = Mock.Of(); + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { "PartialNotFound1", "PartialNotFound2" })); + + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { $"PartialNotFound3", $"PartialNotFound4" })); + + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { "FallbackNotFound1", "FallbackNotFound2" })); + + viewEngine.Setup(v => v.FindView(viewContext, fallbackName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { $"FallbackNotFound3", $"FallbackNotFound4" })); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + ViewData = viewData, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => tagHelper.ProcessAsync(tagHelperContext, output)); + Assert.Equal(expected, exception.Message); + } + + [Fact] + public async Task ProcessAsync_RendersFallbackView_If_MainIsNotFound_AndGetViewReturnsView() + { + // Arrange + var expected = "Hello from fallback!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_RendersFallbackView_If_MainIsNotFound_AndFindViewReturnsView() + { + // Arrange + var expected = "Hello from fallback!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.NotFound(fallbackName, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } private static ViewContext GetViewContext() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ScriptTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ScriptTagHelperTest.cs index 7abb469556..75f7da33a7 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ScriptTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ScriptTagHelperTest.cs @@ -12,11 +12,10 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; @@ -57,8 +56,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { "src", srcOutput }, }; var output = MakeTagHelperOutput("script", outputAttributes); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var urlHelper = new Mock(); // Ensure expanded path does not look like an absolute path on Linux, avoiding @@ -71,17 +68,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers .Setup(f => f.GetUrlHelper(It.IsAny())) .Returns(urlHelper.Object); - var helper = new ScriptTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - urlHelperFactory.Object) - { - ViewContext = viewContext, - AppendVersion = true, - Src = src, - }; + var helper = GetHelper(urlHelperFactory: urlHelperFactory.Object); + helper.AppendVersion = true; + helper.Src = src; // Act helper.Process(context, output); @@ -108,7 +97,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("asp-fallback-test", "isavailable()"), })); var tagHelperContext = MakeTagHelperContext(allAttributes); - var viewContext = MakeViewContext(); var combinedOutputAttributes = new TagHelperAttributeList( outputAttributes.Concat( new[] @@ -116,20 +104,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("data-extra", new HtmlString("something")) })); var output = MakeTagHelperOutput("script", combinedOutputAttributes); - var hostingEnvironment = MakeHostingEnvironment(); - var helper = new ScriptTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - FallbackSrc = "~/blank.js", - FallbackTestExpression = "http://www.example.com/blank.js", - Src = "/blank.js", - }; + var helper = GetHelper(); + helper.FallbackSrc = "~/blank.js"; + helper.FallbackTestExpression = "http://www.example.com/blank.js"; + helper.Src = "/blank.js"; + var expectedAttributes = new TagHelperAttributeList(output.Attributes); expectedAttributes.Add(new TagHelperAttribute("src", "/blank.js")); @@ -158,6 +138,20 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers tagHelper.FallbackTestExpression = "isavailable()"; } }, + { + new TagHelperAttributeList + { + new TagHelperAttribute("asp-fallback-src", "test.js"), + new TagHelperAttribute("asp-fallback-test", "isavailable()"), + new TagHelperAttribute("asp-suppress-fallback-integrity", "false") + }, + tagHelper => + { + tagHelper.FallbackSrc = "test.js"; + tagHelper.FallbackTestExpression = "isavailable()"; + tagHelper.SuppressFallbackIntegrity = false; + } + }, { new TagHelperAttributeList { @@ -184,6 +178,22 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers tagHelper.FallbackTestExpression = "isavailable()"; } }, + { + new TagHelperAttributeList + { + new TagHelperAttribute("asp-fallback-src", "test.js"), + new TagHelperAttribute("asp-fallback-src-include", "*.js"), + new TagHelperAttribute("asp-fallback-test", "isavailable()"), + new TagHelperAttribute("asp-suppress-fallback-integrity", "false") + }, + tagHelper => + { + tagHelper.FallbackSrc = "test.js"; + tagHelper.FallbackSrcInclude = "*.css"; + tagHelper.FallbackTestExpression = "isavailable()"; + tagHelper.SuppressFallbackIntegrity = false; + } + }, { new TagHelperAttributeList { @@ -272,8 +282,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Arrange var context = MakeTagHelperContext(attributes); var output = MakeTagHelperOutput("script"); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -281,16 +289,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new[] { "/common.js" }); - var helper = new ScriptTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - GlobbingUrlBuilder = globbingUrlBuilder.Object - }; + var helper = GetHelper(); + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; setProperties(helper); // Act @@ -369,8 +369,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Arrange var context = MakeTagHelperContext(attributes); var output = MakeTagHelperOutput("script"); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -378,16 +376,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new[] { "/common.js" }); - var helper = new ScriptTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - GlobbingUrlBuilder = globbingUrlBuilder.Object - }; + var helper = GetHelper(); + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; setProperties(helper); // Act @@ -474,18 +464,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var tagHelperContext = MakeTagHelperContext(attributes); var output = MakeTagHelperOutput("script"); var logger = new Mock>(); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - var helper = new ScriptTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - }; + var helper = GetHelper(); setProperties(helper); // Act @@ -506,15 +486,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var viewContext = MakeViewContext(); var output = MakeTagHelperOutput("script"); - var helper = new ScriptTagHelper( - MakeHostingEnvironment(), - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - }; + var helper = GetHelper(); // Act helper.Process(tagHelperContext, output); @@ -540,8 +512,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("asp-fallback-test", "isavailable()"), }); - var viewContext = MakeViewContext(); - var output = MakeTagHelperOutput("src", attributes: new TagHelperAttributeList { @@ -549,20 +519,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("data-more", "else"), }); - var hostingEnvironment = MakeHostingEnvironment(); - - var helper = new ScriptTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - FallbackSrc = "~/blank.js", - FallbackTestExpression = "http://www.example.com/blank.js", - Src = "/blank.js", - }; + var helper = GetHelper(); + helper.FallbackSrc = "~/blank.js"; + helper.FallbackTestExpression = "http://www.example.com/blank.js"; + helper.Src = "/blank.js"; // Act helper.Process(tagHelperContext, output); @@ -586,8 +546,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("asp-src-include", "**/*.js") }); var output = MakeTagHelperOutput("script", attributes: new TagHelperAttributeList()); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -595,18 +553,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "**/*.js", null)) .Returns(new[] { "/common.js" }); - var helper = new ScriptTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - GlobbingUrlBuilder = globbingUrlBuilder.Object, - ViewContext = viewContext, - Src = "/js/site.js", - SrcInclude = "**/*.js", - }; + var helper = GetHelper(); + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + helper.Src = "/js/site.js"; + helper.SrcInclude = "**/*.js"; // Act helper.Process(context, output); @@ -649,8 +599,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { "literal", "all HTML encoded"}, { new TagHelperAttribute("mixed", mixed, HtmlAttributeValueStyle.SingleQuotes) }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -658,18 +606,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "**/*.js", null)) .Returns(new[] { "/common.js" }); - var helper = new ScriptTagHelper( - hostingEnvironment, - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - GlobbingUrlBuilder = globbingUrlBuilder.Object, - Src = "/js/site.js", - SrcInclude = "**/*.js", - ViewContext = viewContext, - }; + var helper = GetHelper(); + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + helper.Src = "/js/site.js"; + helper.SrcInclude = "**/*.js"; // Act helper.Process(context, output); @@ -693,20 +633,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers }); var output = MakeTagHelperOutput("script", attributes: new TagHelperAttributeList()); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - - var helper = new ScriptTagHelper( - MakeHostingEnvironment(), - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - AppendVersion = true, - Src = "/js/site.js", - }; + var helper = GetHelper(); + helper.Src = "/js/site.js"; + helper.AppendVersion = true; // Act helper.Process(context, output); @@ -727,20 +656,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("asp-append-version", "true") }); var output = MakeTagHelperOutput("script", attributes: new TagHelperAttributeList()); - var hostingEnvironment = MakeHostingEnvironment(); var viewContext = MakeViewContext("/bar"); - var helper = new ScriptTagHelper( - MakeHostingEnvironment(), - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - AppendVersion = true, - Src = "/bar/js/site.js", - }; + var helper = GetHelper(viewContext: viewContext); + helper.Src = "/bar/js/site.js"; + helper.AppendVersion = true; // Act helper.Process(context, output); @@ -763,22 +683,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("asp-append-version", "true") }); var output = MakeTagHelperOutput("script", attributes: new TagHelperAttributeList()); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - var helper = new ScriptTagHelper( - MakeHostingEnvironment(), - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - ViewContext = viewContext, - FallbackSrc = "fallback.js", - FallbackTestExpression = "isavailable()", - AppendVersion = true, - Src = "/js/site.js", - }; + var helper = GetHelper(); + helper.FallbackSrc = "fallback.js"; + helper.FallbackTestExpression = "isavailable()"; + helper.AppendVersion = true; + helper.Src = "/js/site.js"; // Act helper.Process(context, output); @@ -826,22 +736,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { "literal", "all HTML encoded" }, { new TagHelperAttribute("mixed", mixed, HtmlAttributeValueStyle.SingleQuotes) }, }); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); - var helper = new ScriptTagHelper( - MakeHostingEnvironment(), - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - AppendVersion = true, - FallbackSrc = "fallback.js", - FallbackTestExpression = "isavailable()", - Src = "/js/site.js", - ViewContext = viewContext, - }; + var helper = GetHelper(); + helper.AppendVersion = true; + helper.FallbackSrc = "fallback.js"; + helper.FallbackTestExpression = "isavailable()"; + helper.Src = "/js/site.js"; // Act helper.Process(context, output); @@ -868,8 +768,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers new TagHelperAttribute("asp-append-version", "true") }); var output = MakeTagHelperOutput("script", attributes: new TagHelperAttributeList()); - var hostingEnvironment = MakeHostingEnvironment(); - var viewContext = MakeViewContext(); var globbingUrlBuilder = new Mock( new TestFileProvider(), Mock.Of(), @@ -877,19 +775,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers globbingUrlBuilder.Setup(g => g.BuildUrlList(null, "*.js", null)) .Returns(new[] { "/common.js" }); - var helper = new ScriptTagHelper( - MakeHostingEnvironment(), - MakeCache(), - new HtmlTestEncoder(), - new JavaScriptTestEncoder(), - MakeUrlHelperFactory()) - { - GlobbingUrlBuilder = globbingUrlBuilder.Object, - ViewContext = viewContext, - SrcInclude = "*.js", - AppendVersion = true, - Src = "/js/site.js", - }; + var helper = GetHelper(); + helper.GlobbingUrlBuilder = globbingUrlBuilder.Object; + helper.SrcInclude = "*.js"; + helper.AppendVersion = true; + helper.Src = "/js/site.js"; // Act helper.Process(context, output); @@ -901,6 +791,30 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Equal(expectedContent, content); } + private static ScriptTagHelper GetHelper( + IHostingEnvironment hostingEnvironment = null, + IUrlHelperFactory urlHelperFactory = null, + ViewContext viewContext = null) + { + hostingEnvironment = hostingEnvironment ?? MakeHostingEnvironment(); + urlHelperFactory = urlHelperFactory ?? MakeUrlHelperFactory(); + viewContext = viewContext ?? MakeViewContext(); + + var memoryCacheProvider = new TagHelperMemoryCacheProvider(); + var fileVersionProvider = new DefaultFileVersionProvider(hostingEnvironment, memoryCacheProvider); + + return new ScriptTagHelper( + hostingEnvironment, + memoryCacheProvider, + fileVersionProvider, + new HtmlTestEncoder(), + new JavaScriptTestEncoder(), + urlHelperFactory) + { + ViewContext = viewContext, + }; + } + private TagHelperContext MakeTagHelperContext( TagHelperAttributeList attributes = null, string content = null) @@ -969,8 +883,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return hostingEnvironment.Object; } - private static IMemoryCache MakeCache() => new MemoryCache(new MemoryCacheOptions()); - private static IUrlHelperFactory MakeUrlHelperFactory() { var urlHelper = new Mock(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/SelectTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/SelectTagHelperTest.cs index dd41bb306b..8b8f842669 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/SelectTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/SelectTagHelperTest.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Testing; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs index 2c1cc87c07..8491d2c142 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs @@ -5,13 +5,12 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Razor.TagHelpers.Testing; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.WebEncoders.Testing; -using Microsoft.AspNetCore.Mvc.TestCommon; using Xunit; namespace Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TextAreaTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TextAreaTagHelperTest.cs index 614ceb985a..98f32a8050 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TextAreaTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TextAreaTagHelperTest.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Testing; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ValidationMessageTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ValidationMessageTagHelperTest.cs index 69522655cb..1c919635be 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ValidationMessageTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ValidationMessageTagHelperTest.cs @@ -357,10 +357,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } [Theory] - [InlineData("Content of validation message", "Content of validation message")] - [InlineData("\r\n \r\n", "New HTML")] + [InlineData("Content of validation message", "Content of validation message", "New HTML")] + [InlineData("\r\n \r\n", null, "New HTML")] public async Task ProcessAsync_MergesTagBuilderFromGenerateValidationMessage( string childContent, + string expectedMessage, string expectedOutputContent) { // Arrange @@ -375,7 +376,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), + expectedMessage, It.IsAny(), It.IsAny())) .Returns(tagBuilder); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ValidationSummaryTagHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ValidationSummaryTagHelperTest.cs index 1b4ec3cc80..57730851cc 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ValidationSummaryTagHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/ValidationSummaryTagHelperTest.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; @@ -99,7 +98,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [Theory] [MemberData(nameof(ProcessAsync_GeneratesExpectedOutput_WithNoErrorsData))] - public async Task ProcessAsync_SuppressesOutput_IfClientSideValiationDisabled_WithNoErrorsData( + public async Task ProcessAsync_SuppressesOutput_IfClientSideValidationDisabled_WithNoErrorsData( ModelStateDictionary modelStateDictionary) { // Arrange diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/ApplicationParts/ApplicationAssembliesProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/ApplicationParts/ApplicationAssembliesProviderTest.cs new file mode 100644 index 0000000000..25f18d8cda --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/ApplicationParts/ApplicationAssembliesProviderTest.cs @@ -0,0 +1,57 @@ +// 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 System.Reflection; +using Microsoft.Extensions.DependencyModel; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApplicationParts +{ + public class ApplicationAssembliesProviderTest + { + private static readonly Assembly ThisAssembly = typeof(ApplicationAssembliesProviderTest).Assembly; + + // This test verifies ApplicationAssembliesProviderTest.ReferenceAssemblies reflects the actual loadable assemblies + // of the libraries that Microsoft.AspNetCore.Mvc depends on. + // If we add or remove dependencies, this test should be changed together. + [Fact] + public void ReferenceAssemblies_ReturnsLoadableReferenceAssemblies() + { + // Arrange + var excludeAssemblies = new string[] + { + "Microsoft.AspNetCore.Mvc.Analyzers", + "Microsoft.AspNetCore.Mvc.Test", + "Microsoft.AspNetCore.Mvc.Core.TestCommon", + }; + + var additionalAssemblies = new[] + { + // The following assemblies are not reachable from Microsoft.AspNetCore.Mvc + "Microsoft.AspNetCore.All", + "Microsoft.AspNetCore.Mvc.Formatters.Xml", + }; + + var dependencyContextLibraries = DependencyContext.Load(ThisAssembly) + .CompileLibraries + .Where(r => r.Name.StartsWith("Microsoft.AspNetCore.Mvc", StringComparison.OrdinalIgnoreCase) && + !excludeAssemblies.Contains(r.Name, StringComparer.OrdinalIgnoreCase)) + .Select(r => r.Name); + + var expected = dependencyContextLibraries + .Concat(additionalAssemblies) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase); + + // Act + var referenceAssemblies = ApplicationAssembliesProvider + .ReferenceAssemblies + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase); + + // Assert + Assert.Equal(expected, referenceAssemblies, StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs index 998343d7bb..d16e2b85d9 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/IntegrationTest/CompatibilitySwitchIntegrationTest.cs @@ -1,11 +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 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; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; +using Moq; using Xunit; namespace Microsoft.AspNetCore.Mvc.IntegrationTest @@ -23,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(); @@ -31,6 +38,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var mvcOptions = services.GetRequiredService>().Value; var jsonOptions = services.GetRequiredService>().Value; 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); @@ -39,6 +49,15 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.Equal(InputFormatterExceptionPolicy.AllExceptions, mvcOptions.InputFormatterExceptionPolicy); Assert.False(jsonOptions.AllowInputFormatterExceptionMessages); Assert.False(razorPagesOptions.AllowAreas); + Assert.False(mvcOptions.EnableEndpointRouting); + Assert.Null(mvcOptions.MaxValidationDepth); + Assert.True(apiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses); + Assert.True(apiBehaviorOptions.SuppressMapClientErrors); + Assert.True(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); + Assert.False(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); + Assert.False(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); + Assert.False(mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent); + Assert.True(apiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery); } [Fact] @@ -47,7 +66,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(); @@ -55,6 +77,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var mvcOptions = services.GetRequiredService>().Value; var jsonOptions = services.GetRequiredService>().Value; 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); @@ -63,6 +88,54 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.Equal(InputFormatterExceptionPolicy.MalformedInputExceptions, mvcOptions.InputFormatterExceptionPolicy); Assert.True(jsonOptions.AllowInputFormatterExceptionMessages); Assert.True(razorPagesOptions.AllowAreas); + Assert.False(mvcOptions.EnableEndpointRouting); + Assert.Null(mvcOptions.MaxValidationDepth); + Assert.True(apiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses); + Assert.True(apiBehaviorOptions.SuppressMapClientErrors); + Assert.True(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); + Assert.False(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); + Assert.False(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); + Assert.False(mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent); + Assert.True(apiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery); + } + + [Fact] + public void CompatibilitySwitches_Version_2_2() + { + // Arrange + var serviceCollection = new ServiceCollection(); + AddHostingServices(serviceCollection); + serviceCollection + .AddMvc() + .AddXmlDataContractSerializerFormatters() + .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + + var services = serviceCollection.BuildServiceProvider(); + + // Act + var mvcOptions = services.GetRequiredService>().Value; + var jsonOptions = services.GetRequiredService>().Value; + 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); + Assert.True(mvcOptions.AllowBindingHeaderValuesToNonStringModelTypes); + Assert.True(mvcOptions.SuppressBindingUndefinedValueToEnumType); + Assert.Equal(InputFormatterExceptionPolicy.MalformedInputExceptions, mvcOptions.InputFormatterExceptionPolicy); + Assert.True(jsonOptions.AllowInputFormatterExceptionMessages); + Assert.True(razorPagesOptions.AllowAreas); + Assert.True(mvcOptions.EnableEndpointRouting); + Assert.Equal(32, mvcOptions.MaxValidationDepth); + Assert.False(apiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses); + Assert.False(apiBehaviorOptions.SuppressMapClientErrors); + Assert.False(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); + Assert.True(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); + Assert.True(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); + Assert.True(mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent); + Assert.False(apiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery); } [Fact] @@ -71,7 +144,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(); @@ -79,6 +155,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest var mvcOptions = services.GetRequiredService>().Value; var jsonOptions = services.GetRequiredService>().Value; 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); @@ -87,11 +166,21 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest Assert.Equal(InputFormatterExceptionPolicy.MalformedInputExceptions, mvcOptions.InputFormatterExceptionPolicy); Assert.True(jsonOptions.AllowInputFormatterExceptionMessages); Assert.True(razorPagesOptions.AllowAreas); + Assert.True(mvcOptions.EnableEndpointRouting); + Assert.Equal(32, mvcOptions.MaxValidationDepth); + Assert.False(apiBehaviorOptions.SuppressUseValidationProblemDetailsForInvalidModelStateResponses); + Assert.False(apiBehaviorOptions.SuppressMapClientErrors); + Assert.False(razorViewEngineOptions.AllowRecompilingViewsOnFileChange); + Assert.True(razorPagesOptions.AllowDefaultHandlingForOptionsRequests); + Assert.True(xmlOptions.AllowRfc7807CompliantProblemDetailsFormat); + Assert.True(mvcOptions.AllowShortCircuitingValidationWhenNoValidatorsArePresent); + Assert.False(apiBehaviorOptions.AllowInferringBindingSourceForCollectionTypesAsFromQuery); } // This just does the minimum needed to be able to resolve these options. private static void AddHostingServices(IServiceCollection serviceCollection) { + serviceCollection.AddSingleton(Mock.Of()); serviceCollection.AddLogging(); serviceCollection.AddSingleton(); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/Microsoft.AspNetCore.Mvc.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/Microsoft.AspNetCore.Mvc.Test.csproj index 90beaff56b..d0fbd363f4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/Microsoft.AspNetCore.Mvc.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/Microsoft.AspNetCore.Mvc.Test.csproj @@ -1,15 +1,18 @@ - + $(StandardTestTfms) + true + + + + - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs index eb9f5434a3..53ba4f52fb 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -13,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; @@ -189,6 +191,12 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(BindingSource.FormFile, formFileParameter.BindingSource); }, provider => + { + var formFileParameter = Assert.IsType(provider); + Assert.Equal(typeof(IEnumerable), formFileParameter.Type); + Assert.Equal(BindingSource.FormFile, formFileParameter.BindingSource); + }, + provider => { var excludeFilter = Assert.IsType(provider); Assert.Equal(typeof(Type), excludeFilter.Type); @@ -244,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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs index a9fa6f7d9c..70fea5804a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs +++ b/src/Mvc/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; @@ -58,7 +59,7 @@ namespace Microsoft.AspNetCore.Mvc services.AddSingleton(GetHostingEnvironment()); // Register a mock implementation of each service, AddMvcServices should add another implementation. - foreach (var serviceType in MutliRegistrationServiceTypes) + foreach (var serviceType in MultiRegistrationServiceTypes) { var mockType = typeof(Mock<>).MakeGenericType(serviceType.Key); services.Add(ServiceDescriptor.Transient(serviceType.Key, mockType)); @@ -68,7 +69,7 @@ namespace Microsoft.AspNetCore.Mvc services.AddMvc(); // Assert - foreach (var serviceType in MutliRegistrationServiceTypes) + foreach (var serviceType in MultiRegistrationServiceTypes) { AssertServiceCountEquals(services, serviceType.Key, serviceType.Value.Length + 1); @@ -163,7 +164,7 @@ namespace Microsoft.AspNetCore.Mvc } [Fact] - public void AddMvcTwice_DoesNotAddDuplicateFramewokrParts() + public void AddMvcTwice_DoesNotAddDuplicateFrameworkParts() { // Arrange var mvcRazorAssembly = typeof(UrlResolutionTagHelper).GetTypeInfo().Assembly; @@ -214,7 +215,9 @@ namespace Microsoft.AspNetCore.Mvc Assert.Collection(manager.FeatureProviders, feature => Assert.IsType(feature), feature => Assert.IsType(feature), +#pragma warning disable CS0618 // Type or member is obsolete feature => Assert.IsType(feature), +#pragma warning restore CS0618 // Type or member is obsolete feature => Assert.IsType(feature), feature => Assert.IsType(feature), #pragma warning disable CS0618 // Type or member is obsolete @@ -321,7 +324,7 @@ namespace Microsoft.AspNetCore.Mvc services.AddSingleton(GetHostingEnvironment()); services.AddMvc(); - var multiRegistrationServiceTypes = MutliRegistrationServiceTypes; + var multiRegistrationServiceTypes = MultiRegistrationServiceTypes; return services .Where(sd => !multiRegistrationServiceTypes.Keys.Contains(sd.ServiceType)) .Where(sd => sd.ServiceType.GetTypeInfo().Assembly.FullName.Contains("Mvc")) @@ -329,7 +332,7 @@ namespace Microsoft.AspNetCore.Mvc } } - private Dictionary MutliRegistrationServiceTypes + private Dictionary MultiRegistrationServiceTypes { get { @@ -379,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/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/Routing/ActionConstraintMatcherPolicyTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/Routing/ActionConstraintMatcherPolicyTest.cs new file mode 100644 index 0000000000..b21072b853 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/Routing/ActionConstraintMatcherPolicyTest.cs @@ -0,0 +1,490 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.Cors.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Routing +{ + // These tests are intentionally in Mvc.Test so we can also test the CORS action constraint. + public class ActionConstraintMatcherPolicyTest + { + [Fact] + public async Task Apply_CanBeAmbiguous() + { + // Arrange + var actions = new ActionDescriptor[] + { + new ActionDescriptor() { DisplayName = "A1" }, + new ActionDescriptor() { DisplayName = "A2" }, + }; + + var candidateSet = CreateCandidateSet(actions); + + var selector = CreateSelector(actions); + + // Act + await selector.ApplyAsync(new DefaultHttpContext(), new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.True(candidateSet.IsValidCandidate(0)); + Assert.True(candidateSet.IsValidCandidate(1)); + } + + [Fact] + public async Task Apply_PrefersActionWithConstraints() + { + // Arrange + var actionWithConstraints = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, }, + }, + Parameters = new List(), + }; + + var actionWithoutConstraints = new ActionDescriptor() + { + Parameters = new List(), + }; + + var actions = new ActionDescriptor[] { actionWithConstraints, actionWithoutConstraints }; + var candidateSet = CreateCandidateSet(actions); + + var selector = CreateSelector(actions); + + var httpContext = CreateHttpContext("POST"); + + // Act + await selector.ApplyAsync(httpContext, new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.True(candidateSet.IsValidCandidate(0)); + Assert.False(candidateSet.IsValidCandidate(1)); + } + + [Fact] + public async Task Apply_ConstraintsRejectAll() + { + // Arrange + var action1 = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = false, }, + }, + }; + + var action2 = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = false, }, + }, + }; + + var actions = new ActionDescriptor[] { action1, action2 }; + var candidateSet = CreateCandidateSet(actions); + + var selector = CreateSelector(actions); + + var httpContext = CreateHttpContext("POST"); + + // Act + await selector.ApplyAsync(httpContext, new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.False(candidateSet.IsValidCandidate(0)); + Assert.False(candidateSet.IsValidCandidate(1)); + } + + [Fact] + public async Task Apply_ConstraintsRejectAll_DifferentStages() + { + // Arrange + var action1 = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = false, Order = 0 }, + new BooleanConstraint() { Pass = true, Order = 1 }, + }, + }; + + var action2 = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 0 }, + new BooleanConstraint() { Pass = false, Order = 1 }, + }, + }; + + var actions = new ActionDescriptor[] { action1, action2 }; + var candidateSet = CreateCandidateSet(actions); + + var selector = CreateSelector(actions); + var httpContext = CreateHttpContext("POST"); + + // Act + await selector.ApplyAsync(httpContext, new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.False(candidateSet.IsValidCandidate(0)); + Assert.False(candidateSet.IsValidCandidate(1)); + } + + // Due to ordering of stages, the first action will be better. + [Fact] + public async Task Apply_ConstraintsInOrder() + { + // Arrange + var best = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 0, }, + }, + }; + + var worst = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 1, }, + }, + }; + + var actions = new ActionDescriptor[] { best, worst }; + var candidateSet = CreateCandidateSet(actions); + + var selector = CreateSelector(actions); + var httpContext = CreateHttpContext("POST"); + + // Act + await selector.ApplyAsync(httpContext, new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.True(candidateSet.IsValidCandidate(0)); + Assert.False(candidateSet.IsValidCandidate(1)); + } + + [Fact] + public async Task Apply_SkipsOverInvalidEndpoints() + { + // Arrange + var best = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 0, }, + }, + }; + + var another = new ActionDescriptor(); + + var worst = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 1, }, + }, + }; + + var actions = new ActionDescriptor[] { best, another, worst }; + var candidateSet = CreateCandidateSet(actions); + candidateSet.SetValidity(0, false); + candidateSet.SetValidity(1, false); + + var selector = CreateSelector(actions); + var httpContext = CreateHttpContext("POST"); + + // Act + await selector.ApplyAsync(httpContext, new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.False(candidateSet.IsValidCandidate(0)); + Assert.False(candidateSet.IsValidCandidate(1)); + Assert.True(candidateSet.IsValidCandidate(2)); + } + + [Fact] + public async Task Apply_IncludesNonMvcEndpoints() + { + // Arrange + var action1 = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = false, Order = 0, }, + }, + }; + + var action2 = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = false, Order = 1, }, + }, + }; + + var actions = new ActionDescriptor[] { action1, null, action2 }; + var candidateSet = CreateCandidateSet(actions); + + var selector = CreateSelector(actions); + var httpContext = CreateHttpContext("POST"); + + // Act + await selector.ApplyAsync(httpContext, new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.False(candidateSet.IsValidCandidate(0)); + Assert.True(candidateSet.IsValidCandidate(1)); + Assert.False(candidateSet.IsValidCandidate(2)); + } + + // Due to ordering of stages, the first action will be better. + [Fact] + public async Task Apply_ConstraintsInOrder_MultipleStages() + { + // Arrange + var best = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 0, }, + new BooleanConstraint() { Pass = true, Order = 1, }, + new BooleanConstraint() { Pass = true, Order = 2, }, + }, + }; + + var worst = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 0, }, + new BooleanConstraint() { Pass = true, Order = 1, }, + new BooleanConstraint() { Pass = true, Order = 3, }, + }, + }; + + var actions = new ActionDescriptor[] { best, worst }; + var candidateSet = CreateCandidateSet(actions); + + var selector = CreateSelector(actions); + + var httpContext = CreateHttpContext("POST"); + + // Act + await selector.ApplyAsync(httpContext, new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.True(candidateSet.IsValidCandidate(0)); + Assert.False(candidateSet.IsValidCandidate(1)); + } + + [Fact] + public async Task Apply_Fallback_ToActionWithoutConstraints() + { + // Arrange + var nomatch1 = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 0, }, + new BooleanConstraint() { Pass = true, Order = 1, }, + new BooleanConstraint() { Pass = false, Order = 2, }, + }, + }; + + var nomatch2 = new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint() { Pass = true, Order = 0, }, + new BooleanConstraint() { Pass = true, Order = 1, }, + new BooleanConstraint() { Pass = false, Order = 3, }, + }, + }; + + var best = new ActionDescriptor(); + + var actions = new ActionDescriptor[] { best, nomatch1, nomatch2 }; + var candidateSet = CreateCandidateSet(actions); + + var selector = CreateSelector(actions); + + var httpContext = CreateHttpContext("POST"); + + // Act + await selector.ApplyAsync(httpContext, new EndpointSelectorContext(), candidateSet); + + // Assert + Assert.True(candidateSet.IsValidCandidate(0)); + Assert.False(candidateSet.IsValidCandidate(1)); + Assert.False(candidateSet.IsValidCandidate(2)); + } + + [Fact] + public void AppliesToEndpoints_IgnoresIgnorableConstraints() + { + // Arrange + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + + }, + new ActionDescriptor() + { + ActionConstraints = new List() + { + new HttpMethodActionConstraint(new[]{ "GET", }), + }, + }, + new ActionDescriptor() + { + ActionConstraints = new List() + { + new ConsumesAttribute("text/json"), + }, + }, + new ActionDescriptor() + { + ActionConstraints = new List() + { + new CorsHttpMethodActionConstraint(new HttpMethodActionConstraint(new[]{ "GET", })), + }, + }, + }; + var endpoints = actions.Select(CreateEndpoint).ToArray(); + + var selector = CreateSelector(actions); + + // Act + var result = selector.AppliesToEndpoints(endpoints); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldRunActionConstraints_RunsForArbitraryActionConstraint() + { + // Arrange + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + + }, + new ActionDescriptor() + { + ActionConstraints = new List() + { + new BooleanConstraint(), + }, + }, + }; + var endpoints = actions.Select(CreateEndpoint).ToArray(); + + var selector = CreateSelector(actions); + + // Act + var result = selector.AppliesToEndpoints(endpoints); + + // Assert + Assert.True(result); + } + + private ActionConstraintMatcherPolicy CreateSelector(ActionDescriptor[] actions) + { + // We need to actually provide some actions with some action constraints metadata + // or else the policy will No-op. + var actionDescriptorProvider = new Mock(); + actionDescriptorProvider + .Setup(a => a.OnProvidersExecuted(It.IsAny())) + .Callback(c => + { + for (var i = 0; i < actions.Length; i++) + { + c.Results.Add(actions[i]); + } + }); + + var actionDescriptorCollectionProvider = new DefaultActionDescriptorCollectionProvider( + new IActionDescriptorProvider[] { actionDescriptorProvider.Object, }, + Enumerable.Empty()); + + var cache = new ActionConstraintCache(actionDescriptorCollectionProvider, new[] + { + new DefaultActionConstraintProvider(), + }); + + return new ActionConstraintMatcherPolicy(cache); + } + + private static HttpContext CreateHttpContext(string httpMethod) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = httpMethod; + return httpContext; + } + + private static Endpoint CreateEndpoint(ActionDescriptor action) + { + var metadata = new List() { action, }; + return new Endpoint( + (context) => Task.CompletedTask, + new EndpointMetadataCollection(metadata), + $"test: {action?.DisplayName}"); + } + + private static CandidateSet CreateCandidateSet(ActionDescriptor[] actions) + { + var values = new RouteValueDictionary[actions.Length]; + for (var i = 0; i < actions.Length; i++) + { + values[i] = new RouteValueDictionary(); + } + + var candidateSet = new CandidateSet( + actions.Select(CreateEndpoint).ToArray(), + values, + new int[actions.Length]); + return candidateSet; + } + + private static ActionConstraintCache GetActionConstraintCache(IActionConstraintProvider[] actionConstraintProviders = null) + { + var descriptorProvider = new DefaultActionDescriptorCollectionProvider( + Enumerable.Empty(), + Enumerable.Empty()); + return new ActionConstraintCache(descriptorProvider, actionConstraintProviders.AsEnumerable() ?? new List()); + } + + private class BooleanConstraint : IActionConstraint + { + public bool Pass { get; set; } + + public int Order { get; set; } + + public bool Accept(ActionConstraintContext context) + { + return Pass; + } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/xunit.runner.json b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/xunit.runner.json similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Experimental.Test/xunit.runner.json rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Test/xunit.runner.json diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/Microsoft.AspNetCore.Mvc.TestCommon.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/Microsoft.AspNetCore.Mvc.TestCommon.csproj deleted file mode 100644 index 203c301faa..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/Microsoft.AspNetCore.Mvc.TestCommon.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - $(StandardTestTfms) - - - - - - - - - - - - - - - - - - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/PlatformNormalizer.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/PlatformNormalizer.cs deleted file mode 100644 index 3c8737c9b9..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/PlatformNormalizer.cs +++ /dev/null @@ -1,44 +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.Collections.Generic; -using System.Text.RegularExpressions; -using Microsoft.AspNetCore.Testing; - -namespace Microsoft.AspNetCore.Mvc -{ - public static class PlatformNormalizer - { - // Mono issue - https://github.com/aspnet/External/issues/19 - public static string NormalizeContent(string input) - { - if (TestPlatformHelper.IsMono) - { - var equivalents = new Dictionary { - { - "The [0-9a-zA-Z ]+ field is required.", "RequiredAttribute_ValidationError" - }, - { - "'[0-9a-zA-Z ]+' and '[0-9a-zA-Z ]+' do not match.", "CompareAttribute_MustMatch" - }, - { - "The field [0-9a-zA-Z ]+ must be a string with a minimum length of [0-9]+ and a " + - "maximum length of [0-9]+.", - "StringLengthAttribute_ValidationErrorIncludingMinimum" - }, - }; - - var result = input; - - foreach (var kvp in equivalents) - { - result = Regex.Replace(result, kvp.Key, kvp.Value); - } - - return result; - } - - return input; - } - } -} \ No newline at end of file diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/SolutionPathUtility.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/SolutionPathUtility.cs deleted file mode 100644 index a3d372fbf2..0000000000 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/SolutionPathUtility.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.IO; -using System.Reflection; - -namespace Microsoft.AspNetCore.Mvc -{ - public static class SolutionPathUtility - { - private const string SolutionName = "Mvc.sln"; - - /// - /// Gets the full path to the project. - /// - /// - /// The parent directory of the project. - /// e.g. samples, test, or test/Websites - /// - /// The project's assembly. - /// The full path to the project. - public static string GetProjectPath(string solutionRelativePath, Assembly assembly) - { - var projectName = assembly.GetName().Name; - var applicationBasePath = AppContext.BaseDirectory; - - var directoryInfo = new DirectoryInfo(applicationBasePath); - do - { - var solutionFileInfo = new FileInfo(Path.Combine(directoryInfo.FullName, SolutionName)); - if (solutionFileInfo.Exists) - { - return Path.GetFullPath(Path.Combine(directoryInfo.FullName, solutionRelativePath, projectName)); - } - - directoryInfo = directoryInfo.Parent; - } - while (directoryInfo.Parent != null); - - throw new Exception($"Solution root could not be located using application root {applicationBasePath}."); - } - } -} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestDiagnosticListener/Microsoft.AspNetCore.Mvc.TestDiagnosticListener.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestDiagnosticListener/Microsoft.AspNetCore.Mvc.TestDiagnosticListener.csproj index 23a77e17a1..9ce101241e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestDiagnosticListener/Microsoft.AspNetCore.Mvc.TestDiagnosticListener.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestDiagnosticListener/Microsoft.AspNetCore.Mvc.TestDiagnosticListener.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ControllerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ControllerTest.cs index d88b338f7c..07567c78b9 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ControllerTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ControllerTest.cs @@ -439,7 +439,7 @@ namespace Microsoft.AspNetCore.Mvc.Test var viewData = new ViewDataDictionary(metadataProvider, new ModelStateDictionary()); var tempData = new TempDataDictionary(httpContext, Mock.Of()); - var valiatorProviders = new[] + var validatorProviders = new[] { new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), @@ -458,7 +458,7 @@ namespace Microsoft.AspNetCore.Mvc.Test { ControllerContext = controllerContext, MetadataProvider = metadataProvider, - ObjectValidator = new DefaultObjectValidator(metadataProvider, valiatorProviders), + ObjectValidator = new DefaultObjectValidator(metadataProvider, validatorProviders, new MvcOptions()), TempData = tempData, ViewData = viewData, }; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/DependencyInjection/MvcViewFeaturesMvcBuilderExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/DependencyInjection/MvcViewFeaturesMvcBuilderExtensionsTest.cs index e8c04b1370..862d5e4ffc 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/DependencyInjection/MvcViewFeaturesMvcBuilderExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/DependencyInjection/MvcViewFeaturesMvcBuilderExtensionsTest.cs @@ -20,20 +20,20 @@ namespace Microsoft.Extensions.DependencyInjection public void AddViewComponentsAsServices_ReplacesViewComponentActivator() { // Arrange - var services = new ServiceCollection(); - var builder = services - .AddMvc() - .ConfigureApplicationPartManager(manager => - { - manager.ApplicationParts.Add(new TestApplicationPart()); - manager.FeatureProviders.Add(new ViewComponentFeatureProvider()); - }); + var builder = CreateBuilder(); + + MvcViewFeaturesMvcCoreBuilderExtensions.AddViewServices(builder.Services); + builder.ConfigureApplicationPartManager(manager => + { + manager.ApplicationParts.Add(new TestApplicationPart()); + manager.FeatureProviders.Add(new ViewComponentFeatureProvider()); + }); // Act builder.AddViewComponentsAsServices(); // Assert - var descriptor = Assert.Single(services.ToList(), d => d.ServiceType == typeof(IViewComponentActivator)); + var descriptor = Assert.Single(builder.Services.ToList(), d => d.ServiceType == typeof(IViewComponentActivator)); Assert.Equal(typeof(ServiceBasedViewComponentActivator), descriptor.ImplementationType); } @@ -41,14 +41,13 @@ namespace Microsoft.Extensions.DependencyInjection public void AddCookieTempDataProvider_RegistersExpectedTempDataProvider() { // Arrange - var services = new ServiceCollection(); - var builder = services.AddMvc(); + var builder = CreateBuilder(); // Act builder.AddCookieTempDataProvider(); // Assert - var descriptor = Assert.Single(services, item => item.ServiceType == typeof(ITempDataProvider)); + var descriptor = Assert.Single(builder.Services, item => item.ServiceType == typeof(ITempDataProvider)); Assert.Equal(typeof(CookieTempDataProvider), descriptor.ImplementationType); } @@ -56,15 +55,14 @@ namespace Microsoft.Extensions.DependencyInjection public void AddCookieTempDataProvider_DoesNotRegisterOptionsConfiguration() { // Arrange - var services = new ServiceCollection(); - var builder = services.AddMvc(); + var builder = CreateBuilder(); // Act builder.AddCookieTempDataProvider(); // Assert Assert.DoesNotContain( - services, + builder.Services, item => item.ServiceType == typeof(IConfigureOptions)); } @@ -72,14 +70,13 @@ namespace Microsoft.Extensions.DependencyInjection public void AddCookieTempDataProviderWithSetupAction_RegistersExpectedTempDataProvider() { // Arrange - var services = new ServiceCollection(); - var builder = services.AddMvc(); + var builder = CreateBuilder(); // Act builder.AddCookieTempDataProvider(options => { }); // Assert - var descriptor = Assert.Single(services, item => item.ServiceType == typeof(ITempDataProvider)); + var descriptor = Assert.Single(builder.Services, item => item.ServiceType == typeof(ITempDataProvider)); Assert.Equal(typeof(CookieTempDataProvider), descriptor.ImplementationType); } @@ -87,15 +84,14 @@ namespace Microsoft.Extensions.DependencyInjection public void AddCookieTempDataProviderWithSetupAction_RegistersOptionsConfiguration() { // Arrange - var services = new ServiceCollection(); - var builder = services.AddMvc(); + var builder = CreateBuilder(); // Act builder.AddCookieTempDataProvider(options => { }); // Assert Assert.Single( - services, + builder.Services, item => item.ServiceType == typeof(IConfigureOptions)); } @@ -103,15 +99,14 @@ namespace Microsoft.Extensions.DependencyInjection public void AddCookieTempDataProvider_RegistersExpectedTempDataProvider_IfCalledTwice() { // Arrange - var services = new ServiceCollection(); - var builder = services.AddMvc(); + var builder = CreateBuilder(); // Act builder.AddCookieTempDataProvider(); builder.AddCookieTempDataProvider(); // Assert - var descriptor = Assert.Single(services, item => item.ServiceType == typeof(ITempDataProvider)); + var descriptor = Assert.Single(builder.Services, item => item.ServiceType == typeof(ITempDataProvider)); Assert.Equal(typeof(CookieTempDataProvider), descriptor.ImplementationType); } @@ -119,15 +114,14 @@ namespace Microsoft.Extensions.DependencyInjection public void AddCookieTempDataProviderWithSetupAction_RegistersExpectedTempDataProvider_IfCalledTwice() { // Arrange - var services = new ServiceCollection(); - var builder = services.AddMvc(); + var builder = CreateBuilder(); // Act builder.AddCookieTempDataProvider(options => { }); builder.AddCookieTempDataProvider(options => { }); // Assert - var descriptor = Assert.Single(services, item => item.ServiceType == typeof(ITempDataProvider)); + var descriptor = Assert.Single(builder.Services, item => item.ServiceType == typeof(ITempDataProvider)); Assert.Equal(typeof(CookieTempDataProvider), descriptor.ImplementationType); } @@ -166,6 +160,14 @@ namespace Microsoft.Extensions.DependencyInjection Assert.Equal(ServiceLifetime.Singleton, collection[2].Lifetime); } + private static MvcBuilder CreateBuilder() + { + var services = new ServiceCollection(); + var manager = new ApplicationPartManager(); + var builder = new MvcBuilder(services, manager); + return builder; + } + public class ConventionsViewComponent { public string Invoke() => "Hello world"; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/DefaultDisplayTemplatesTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/DefaultDisplayTemplatesTest.cs index bf9c3446ca..e9526d1d62 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/DefaultDisplayTemplatesTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/DefaultDisplayTemplatesTest.cs @@ -10,7 +10,6 @@ using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Moq; using Xunit; @@ -144,7 +143,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal } [Fact] - public void ObjectTemplate_HonoursHideSurroundingHtml() + public void ObjectTemplate_HonorsHideSurroundingHtml() { // Arrange var expected = @@ -232,7 +231,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal } [Fact] - public void HiddenInputTemplate_HonoursHideSurroundingHtml() + public void HiddenInputTemplate_HonorsHideSurroundingHtml() { // Arrange var model = "Model string"; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/DefaultEditorTemplatesTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/DefaultEditorTemplatesTest.cs index 793f7a176e..ad94d3bf9e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/DefaultEditorTemplatesTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/DefaultEditorTemplatesTest.cs @@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Testing; using Moq; @@ -375,7 +374,7 @@ Environment.NewLine; } [Fact] - public void ObjectTemplate_HonoursHideSurroundingHtml() + public void ObjectTemplate_HonorsHideSurroundingHtml() { // Arrange var expected = @@ -471,7 +470,7 @@ Environment.NewLine; } [Fact] - public void HiddenInputTemplate_HonoursHideSurroundingHtml() + public void HiddenInputTemplate_HonorsHideSurroundingHtml() { // Arrange var expected = ""; @@ -660,6 +659,7 @@ Environment.NewLine; Mock.Of(), viewEngine.Object, provider, + localizerFactory: null, innerHelper => new StubbyHtmlHelper(innerHelper)); helper.ViewData["Property1"] = "True"; @@ -702,6 +702,7 @@ Environment.NewLine; Mock.Of(), viewEngine.Object, provider, + localizerFactory: null, innerHelper => new StubbyHtmlHelper(innerHelper)); // TemplateBuilder sets FormattedModelValue before calling TemplateRenderer and it's used in most templates. @@ -743,6 +744,7 @@ Environment.NewLine; Mock.Of(), viewEngine.Object, provider, + localizerFactory: null, innerHelper => new StubbyHtmlHelper(innerHelper)); helper.ViewData["Property1"] = "True"; @@ -785,6 +787,7 @@ Environment.NewLine; Mock.Of(), viewEngine.Object, provider, + localizerFactory: null, innerHelper => new StubbyHtmlHelper(innerHelper)); // TemplateBuilder sets FormattedModelValue before calling TemplateRenderer and it's used in most templates. diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ExpressionHelperTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ExpressionHelperTest.cs index eee3b4fb66..f316eea6be 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ExpressionHelperTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ExpressionHelperTest.cs @@ -393,7 +393,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal [Theory] [MemberData(nameof(IndexerExpressions))] [MemberData(nameof(UnsupportedExpressions))] - public void GetExpressionText_DoesNotCacheIndexerOrUnspportedExpression(LambdaExpression expression) + public void GetExpressionText_DoesNotCacheIndexerOrUnsupportedExpression(LambdaExpression expression) { // Act - 1 var text1 = ExpressionHelper.GetExpressionText(expression, _expressionTextCache); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ExpressionMetadataProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ExpressionMetadataProviderTest.cs index 44d421c376..c47802cfd0 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ExpressionMetadataProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ExpressionMetadataProviderTest.cs @@ -93,7 +93,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal } [Fact] - public void FromLambaExpression_SetsContainerAsExpected() + public void FromLambdaExpression_SetsContainerAsExpected() { // Arrange var myModel = new TestModel { SelectedCategory = new Category() }; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/MemberExpressionCacheKeyComparerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/MemberExpressionCacheKeyComparerTest.cs new file mode 100644 index 0000000000..eca38d7dc2 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/MemberExpressionCacheKeyComparerTest.cs @@ -0,0 +1,216 @@ +// 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.Expressions; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + public class MemberExpressionCacheKeyComparerTest + { + private readonly MemberExpressionCacheKeyComparer Comparer = MemberExpressionCacheKeyComparer.Instance; + + [Fact] + public void Equals_ReturnsTrue_ForTheSameExpression() + { + // Arrange + var key = GetKey(m => m.Value); + + // Act & Assert + VerifyEquals(key, key); + } + + [Fact] + public void Equals_ReturnsTrue_ForDifferentInstances_OfSameExpression() + { + // Arrange + var key1 = GetKey(m => m.Value); + var key2 = GetKey(m => m.Value); + + // Act & Assert + VerifyEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsTrue_ForChainedMemberAccessExpressionsWithReferenceTypes() + { + // Arrange + var key1 = GetKey(m => m.TestModel2.Name); + var key2 = GetKey(m => m.TestModel2.Name); + + // Act & Assert + VerifyEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsTrue_ForChainedMemberAccessExpressionsWithNullableValueTypes() + { + // Arrange + var key1 = GetKey(m => m.NullableDateTime.Value.TimeOfDay); + var key2 = GetKey(m => m.NullableDateTime.Value.TimeOfDay); + + // Act & Assert + VerifyEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsTrue_ForChainedMemberAccessExpressionsWithValueTypes() + { + // Arrange + var key1 = GetKey(m => m.DateTime.Year); + var key2 = GetKey(m => m.DateTime.Year); + + // Act & Assert + VerifyEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsFalse_ForDifferentExpression() + { + // Arrange + var key1 = GetKey(m => m.Value); + var key2 = GetKey(m => m.TestModel2.Name); + + // Act & Assert + VerifyNotEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsFalse_ForChainedExpressions() + { + // Arrange + var key1 = GetKey(m => m.TestModel2.Id); + var key2 = GetKey(m => m.TestModel2.Name); + + // Act & Assert + VerifyNotEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsFalse_ForChainedExpressions_WithValueTypes() + { + // Arrange + var key1 = GetKey(m => m.DateTime.Ticks); + var key2 = GetKey(m => m.DateTime.Year); + + // Act & Assert + VerifyNotEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsFalse_ForChainedExpressions_DifferingByNullable() + { + // Arrange + var key1 = GetKey(m => m.DateTime.Ticks); + var key2 = GetKey(m => m.NullableDateTime.Value.Ticks); + + // Act & Assert + VerifyNotEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsFalse_WhenOneExpressionIsSubsetOfOther() + { + // Arrange + var key1 = GetKey(m => m.TestModel2); + var key2 = GetKey(m => m.TestModel2.Name); + + // Act & Assert + VerifyNotEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsFalse_WhenMemberIsAccessedThroughNullableProperty() + { + // Arrange + var key1 = GetKey(m => m.NullableDateTime.Value.Year); + var key2 = GetKey(m => m.DateTime.Year); + + // Act + VerifyNotEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsFalse_WhenMemberIsAccessedThroughDifferentModels() + { + // Arrange + var key1 = GetKey(m => m.Id); + var key2 = GetKey(m => m.TestModel2.Id); + + // Act + VerifyNotEquals(key1, key2); + } + + [Fact] + public void Equals_ReturnsFalse_WhenMemberIsAccessedThroughConstantExpression() + { + // Arrange + var testModel = new TestModel2 { Id = 1 }; + var key1 = GetKey(m => testModel.Id); + var key2 = GetKey(m => m.Id); + + // Act + VerifyNotEquals(key1, key2); + } + + private void VerifyEquals(MemberExpressionCacheKey key1, MemberExpressionCacheKey key2) + { + Assert.Equal(key1, key2, Comparer); + + var hashCode1 = Comparer.GetHashCode(key1); + var hashCode2 = Comparer.GetHashCode(key2); + Assert.Equal(hashCode1, hashCode2); + + var cachedKey1 = key1.MakeCacheable(); + + Assert.Equal(key1, cachedKey1, Comparer); + Assert.Equal(cachedKey1, key1, Comparer); + + var cachedKeyHashCode1 = Comparer.GetHashCode(cachedKey1); + Assert.Equal(hashCode1, cachedKeyHashCode1); + } + + private void VerifyNotEquals(MemberExpressionCacheKey key1, MemberExpressionCacheKey key2) + { + var hashCode1 = Comparer.GetHashCode(key1); + var hashCode2 = Comparer.GetHashCode(key2); + + Assert.NotEqual(hashCode1, hashCode2); + Assert.NotEqual(key1, key2, Comparer); + + var cachedKey1 = key1.MakeCacheable(); + Assert.NotEqual(key2, cachedKey1, Comparer); + + var cachedKeyHashCode1 = Comparer.GetHashCode(cachedKey1); + Assert.NotEqual(cachedKeyHashCode1, hashCode2); + } + + private static MemberExpressionCacheKey GetKey(Expression> expression) + => GetKey(expression); + + private static MemberExpressionCacheKey GetKey(Expression> expression) + { + var memberExpression = Assert.IsAssignableFrom(expression.Body); + return new MemberExpressionCacheKey(typeof(TModel), memberExpression); + } + + public class TestModel + { + public string Value { get; set; } + + public TestModel2 TestModel2 { get; set; } + + public DateTime DateTime { get; set; } + + public DateTime? NullableDateTime { get; set; } + } + + public class TestModel2 + { + public string Name { get; set; } + + public int Id { get; set; } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/MemberExpressionCacheKeyTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/MemberExpressionCacheKeyTest.cs new file mode 100644 index 0000000000..eb4cebb948 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/MemberExpressionCacheKeyTest.cs @@ -0,0 +1,111 @@ +// 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.Expressions; +using System.Reflection; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + public class MemberExpressionCacheKeyTest + { + [Fact] + public void GetEnumerator_ReturnsMembers() + { + // Arrange + var expected = new[] + { + typeof(TestModel3).GetProperty(nameof(TestModel3.Value)), + typeof(TestModel2).GetProperty(nameof(TestModel2.TestModel3)), + typeof(TestModel).GetProperty(nameof(TestModel.TestModel2)), + }; + + var key = GetKey(m => m.TestModel2.TestModel3.Value); + + // Act + var actual = GetMembers(key); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void GetEnumerator_WithNullableType_ReturnsMembers() + { + // Arrange + var expected = new[] + { + typeof(DateTime).GetProperty(nameof(DateTime.Ticks)), + typeof(DateTime?).GetProperty(nameof(Nullable.Value)), + typeof(TestModel).GetProperty(nameof(TestModel.NullableDateTime)), + }; + + var key = GetKey(m => m.NullableDateTime.Value.Ticks); + + // Act + var actual = GetMembers(key); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void GetEnumerator_WithValueType_ReturnsMembers() + { + // Arrange + var expected = new[] + { + typeof(DateTime).GetProperty(nameof(DateTime.Ticks)), + typeof(TestModel).GetProperty(nameof(TestModel.DateTime)), + }; + + var key = GetKey(m => m.DateTime.Ticks); + + // Act + var actual = GetMembers(key); + + // Assert + Assert.Equal(expected, actual); + } + + private static MemberExpressionCacheKey GetKey(Expression> expression) + { + var memberExpression = Assert.IsAssignableFrom(expression.Body); + return new MemberExpressionCacheKey(typeof(TestModel), memberExpression); + } + + private static IList GetMembers(MemberExpressionCacheKey key) + { + var members = new List(); + foreach (var member in key) + { + members.Add(member); + } + + return members; + } + + public class TestModel + { + public TestModel2 TestModel2 { get; set; } + + public DateTime DateTime { get; set; } + + public DateTime? NullableDateTime { get; set; } + } + + public class TestModel2 + { + public string Name { get; set; } + + public TestModel3 TestModel3 { get; set; } + } + + public class TestModel3 + { + public string Value { get; set; } + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/PagedCharBufferTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/PagedCharBufferTest.cs index 28944150ff..8c05f896f4 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/PagedCharBufferTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/PagedCharBufferTest.cs @@ -469,14 +469,14 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal // Assert - 1 Assert.Equal(0, buffer.Length); - Assert.Equal(1, buffer.Pages.Count); + Assert.Single(buffer.Pages); // Act - 2 buffer.Append("efgh"); // Assert - 2 Assert.Equal(4, buffer.Length); - Assert.Equal(1, buffer.Pages.Count); + Assert.Single(buffer.Pages); Assert.Equal(new[] { 'e', 'f', 'g', 'h' }, buffer.Pages[0].Take(buffer.Length)); } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/TempDataApplicationModelProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/TempDataApplicationModelProviderTest.cs index fb6ec71dc1..ab6df27ee5 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/TempDataApplicationModelProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/TempDataApplicationModelProviderTest.cs @@ -21,12 +21,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal var type = typeof(TestController_NoTempDataProperties); var options = Options.Create(new MvcViewOptions()); var provider = new TempDataApplicationModelProvider(options); - var defaultProvider = new DefaultApplicationModelProvider( - Options.Create(new MvcOptions()), - TestModelMetadataProvider.CreateDefaultProvider()); - var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() }); - defaultProvider.OnProvidersExecuting(context); + var context = GetContext(type); // Act provider.OnProvidersExecuting(context); @@ -44,12 +40,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal var options = Options.Create(new MvcViewOptions()); var provider = new TempDataApplicationModelProvider(options); var expected = $"The '{type.FullName}.Test' property with TempDataAttribute is invalid. A property using TempDataAttribute must have a public getter and setter."; - var defaultProvider = new DefaultApplicationModelProvider( - Options.Create(new MvcOptions()), - TestModelMetadataProvider.CreateDefaultProvider()); - var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() }); - defaultProvider.OnProvidersExecuting(context); + var context = GetContext(type); // Act & Assert var ex = Assert.Throws(() => provider.OnProvidersExecuting(context)); @@ -63,12 +55,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal var type = typeof(TestController_NullableNonPrimitiveTempDataProperty); var options = Options.Create(new MvcViewOptions()); var provider = new TempDataApplicationModelProvider(options); - var defaultProvider = new DefaultApplicationModelProvider( - Options.Create(new MvcOptions()), - TestModelMetadataProvider.CreateDefaultProvider()); - var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() }); - defaultProvider.OnProvidersExecuting(context); + var context = GetContext(type); // Act provider.OnProvidersExecuting(context); @@ -82,15 +70,12 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal public void InitializeFilterFactory_WithExpectedPropertyHelpers_ForTempDataAttributeProperties() { // Arrange - var expected = typeof(TestController_OneTempDataProperty).GetProperty(nameof(TestController_OneTempDataProperty.Test2)); + var type = typeof(TestController_OneTempDataProperty); + var expected = type.GetProperty(nameof(TestController_OneTempDataProperty.Test2)); var options = Options.Create(new MvcViewOptions()); var provider = new TempDataApplicationModelProvider(options); - var defaultProvider = new DefaultApplicationModelProvider( - Options.Create(new MvcOptions()), - TestModelMetadataProvider.CreateDefaultProvider()); - var context = new ApplicationModelProviderContext(new[] { typeof(TestController_OneTempDataProperty).GetTypeInfo() }); - defaultProvider.OnProvidersExecuting(context); + var context = GetContext(type); // Act provider.OnProvidersExecuting(context); @@ -110,13 +95,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal // Arrange var expected = typeof(TestController_OneTempDataProperty).GetProperty(nameof(TestController_OneTempDataProperty.Test2)); var options = Options.Create(new MvcViewOptions { SuppressTempDataAttributePrefix = true }); + var type = typeof(TestController_OneTempDataProperty); var provider = new TempDataApplicationModelProvider(options); - var defaultProvider = new DefaultApplicationModelProvider( - Options.Create(new MvcOptions()), - TestModelMetadataProvider.CreateDefaultProvider()); - - var context = new ApplicationModelProviderContext(new[] { typeof(TestController_OneTempDataProperty).GetTypeInfo() }); - defaultProvider.OnProvidersExecuting(context); + var context = GetContext(type); // Act provider.OnProvidersExecuting(context); @@ -130,6 +111,17 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal Assert.Equal("Test2", property.Key); } + private static ApplicationModelProviderContext GetContext(Type type) + { + var defaultProvider = new DefaultApplicationModelProvider( + Options.Create(new MvcOptions()), + new EmptyModelMetadataProvider()); + + var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() }); + defaultProvider.OnProvidersExecuting(context); + return context; + } + public class TestController_NoTempDataProperties { public DateTime? DateTime { get; set; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ValidateAntiforgeryTokenAuthorizationFilterTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ValidateAntiforgeryTokenAuthorizationFilterTest.cs index 2748716667..d8e62d278e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ValidateAntiforgeryTokenAuthorizationFilterTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ValidateAntiforgeryTokenAuthorizationFilterTest.cs @@ -73,5 +73,29 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal // Assert antiforgery.Verify(a => a.ValidateRequestAsync(It.IsAny()), Times.Never()); } + + [Fact] + public async Task Filter_SetsFailureResult() + { + // Arrange + var antiforgery = new Mock(MockBehavior.Strict); + antiforgery + .Setup(a => a.ValidateRequestAsync(It.IsAny())) + .Throws(new AntiforgeryValidationException("Failed")) + .Verifiable(); + + var filter = new ValidateAntiforgeryTokenAuthorizationFilter(antiforgery.Object, NullLoggerFactory.Instance); + + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + actionContext.HttpContext.Request.Method = "POST"; + + var context = new AuthorizationFilterContext(actionContext, new[] { filter }); + + // Act + await filter.OnAuthorizationAsync(context); + + // Assert + Assert.IsType(context.Result); + } } } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ViewDataAttributePropertyProviderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ViewDataAttributePropertyProviderTest.cs index 48f0726884..1ab02127a9 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ViewDataAttributePropertyProviderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ViewDataAttributePropertyProviderTest.cs @@ -54,7 +54,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal Assert.Collection( result.OrderBy(p => p.Key), property => Assert.Equal(nameof(BaseController.BaseProperty), property.PropertyInfo.Name), - property => Assert.Equal(nameof(DerivedController.DeriviedProperty), property.PropertyInfo.Name)); + property => Assert.Equal(nameof(DerivedController.DerivedProperty), property.PropertyInfo.Name)); } [Fact] @@ -90,7 +90,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal public class DerivedController : BaseController { [ViewData] - public string DeriviedProperty { get; set; } + public string DerivedProperty { get; set; } } public class PropertyWithKeyController diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test.csproj index f3dea58d26..1c7b9e362e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test.csproj @@ -1,15 +1,11 @@ - + $(StandardTestTfms) - - + - - - diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/RemoteAttributeTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/RemoteAttributeTest.cs index 5c8da2b478..5742da183b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/RemoteAttributeTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/RemoteAttributeTest.cs @@ -1045,7 +1045,7 @@ namespace Microsoft.AspNetCore.Mvc serviceCollection.AddRouting(); serviceCollection.AddSingleton( - provider => new DefaultInlineConstraintResolver(provider.GetRequiredService>())); + provider => new DefaultInlineConstraintResolver(provider.GetRequiredService>(), provider)); if (localizerFactory != null) { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/DefaultTemplatesUtilities.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/DefaultTemplatesUtilities.cs index 75649f3223..24377c95eb 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/DefaultTemplatesUtilities.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/DefaultTemplatesUtilities.cs @@ -80,6 +80,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering CreateUrlHelper(), CreateViewEngine(), metadataProvider, + localizerFactory: null, innerHelperWrapper: null, htmlGenerator: htmlGenerator, idAttributeDotReplacement: null); @@ -92,6 +93,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering CreateUrlHelper(), CreateViewEngine(), TestModelMetadataProvider.CreateDefaultProvider(), + localizerFactory: null, innerHelperWrapper: null, htmlGenerator: null, idAttributeDotReplacement: null); @@ -106,6 +108,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering CreateUrlHelper(), CreateViewEngine(), TestModelMetadataProvider.CreateDefaultProvider(), + localizerFactory: null, innerHelperWrapper: null, htmlGenerator: null, idAttributeDotReplacement: idAttributeDotReplacement); @@ -127,6 +130,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering CreateUrlHelper(), CreateViewEngine(), provider, + localizerFactory: null, innerHelperWrapper: null, htmlGenerator: null, idAttributeDotReplacement: idAttributeDotReplacement); @@ -167,7 +171,8 @@ namespace Microsoft.AspNetCore.Mvc.Rendering model, CreateUrlHelper(), viewEngine, - TestModelMetadataProvider.CreateDefaultProvider(stringLocalizerFactory)); + TestModelMetadataProvider.CreateDefaultProvider(stringLocalizerFactory), + stringLocalizerFactory); } public static HtmlHelper GetHtmlHelper( @@ -180,6 +185,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering CreateUrlHelper(), viewEngine, TestModelMetadataProvider.CreateDefaultProvider(), + localizerFactory: null, innerHelperWrapper); } @@ -187,9 +193,10 @@ namespace Microsoft.AspNetCore.Mvc.Rendering TModel model, IUrlHelper urlHelper, ICompositeViewEngine viewEngine, - IModelMetadataProvider provider) + IModelMetadataProvider provider, + IStringLocalizerFactory localizerFactory = null) { - return GetHtmlHelper(model, urlHelper, viewEngine, provider, innerHelperWrapper: null); + return GetHtmlHelper(model, urlHelper, viewEngine, provider, localizerFactory, innerHelperWrapper: null); } public static HtmlHelper GetHtmlHelper( @@ -197,6 +204,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering IUrlHelper urlHelper, ICompositeViewEngine viewEngine, IModelMetadataProvider provider, + IStringLocalizerFactory localizerFactory, Func innerHelperWrapper) { var viewData = new ViewDataDictionary(provider); @@ -207,6 +215,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering urlHelper, viewEngine, provider, + localizerFactory, innerHelperWrapper, htmlGenerator: null, idAttributeDotReplacement: null); @@ -217,6 +226,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering IUrlHelper urlHelper, ICompositeViewEngine viewEngine, IModelMetadataProvider provider, + IStringLocalizerFactory localizerFactory, Func innerHelperWrapper, IHtmlGenerator htmlGenerator, string idAttributeDotReplacement) @@ -229,14 +239,14 @@ namespace Microsoft.AspNetCore.Mvc.Rendering { options.HtmlHelperOptions.IdAttributeDotReplacement = idAttributeDotReplacement; } - var localizationOptionsAccesor = new Mock>(); - localizationOptionsAccesor.SetupGet(o => o.Value).Returns(new MvcDataAnnotationsLocalizationOptions()); + var localizationOptions = new MvcDataAnnotationsLocalizationOptions(); + var localizationOptionsAccesor = Options.Create(localizationOptions); options.ClientModelValidatorProviders.Add(new DataAnnotationsClientModelValidatorProvider( new ValidationAttributeAdapterProvider(), - localizationOptionsAccesor.Object, - stringLocalizerFactory: null)); + localizationOptionsAccesor, + localizerFactory)); var urlHelperFactory = new Mock(); urlHelperFactory diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperCheckboxTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperCheckboxTest.cs index 6f56ab6dde..67cfb7f64e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperCheckboxTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperCheckboxTest.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Testing; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperDisplayExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperDisplayExtensionsTest.cs index c32e06c3bf..c53a3eaf56 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperDisplayExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperDisplayExtensionsTest.cs @@ -5,7 +5,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.Extensions.Localization; using Moq; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperDropDownListExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperDropDownListExtensionsTest.cs index b7d1846b0b..a465269d9b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperDropDownListExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperDropDownListExtensionsTest.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperEditorExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperEditorExtensionsTest.cs index a73fc44a2f..9a8365805f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperEditorExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperEditorExtensionsTest.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Moq; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperFormExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperFormExtensionsTest.cs index 13beb13337..0624f2aa95 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperFormExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperFormExtensionsTest.cs @@ -708,7 +708,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering [Theory] [MemberData(nameof(ActionNameControllerNameRouteValuesAndMethodDataSet))] - public void BeginFormWithActionNameContollerNameRouteValuesAndMethodParameters_CallsHtmlGeneratorWithExpectedValues( + public void BeginFormWithActionNameControllerNameRouteValuesAndMethodParameters_CallsHtmlGeneratorWithExpectedValues( string actionName, string controllerName, object routeValues, @@ -754,7 +754,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering [Theory] [MemberData(nameof(ActionNameControllerNameMethodAndHtmlAttributesDataSet))] - public void BeginFormWithActionNameContollerNameMethodAndHtmlAttributesParameters_CallsHtmlGeneratorWithExpectedValues( + public void BeginFormWithActionNameControllerNameMethodAndHtmlAttributesParameters_CallsHtmlGeneratorWithExpectedValues( string actionName, string controllerName, FormMethod method, @@ -800,7 +800,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering [Theory] [MemberData(nameof(ActionNameControllerNameMethodAndHtmlAttributesDataSet))] - public void BeginFormWithActionNameContollerNameMethodAndHtmlAttributesParameters_WithAntiforgery_CallsHtmlGeneratorWithExpectedValues( + public void BeginFormWithActionNameControllerNameMethodAndHtmlAttributesParameters_WithAntiforgery_CallsHtmlGeneratorWithExpectedValues( string actionName, string controllerName, FormMethod method, @@ -848,7 +848,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering [Theory] [MemberData(nameof(ActionNameControllerNameMethodAndHtmlAttributesDataSet))] - public void BeginFormWithActionNameContollerNameMethodAndHtmlAttributesParameters_SuppressAntiforgery_CallsHtmlGeneratorWithExpectedValues( + public void BeginFormWithActionNameControllerNameMethodAndHtmlAttributesParameters_SuppressAntiforgery_CallsHtmlGeneratorWithExpectedValues( string actionName, string controllerName, FormMethod method, diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperHiddenTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperHiddenTest.cs index 2829b1746d..738a0fe051 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperHiddenTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperHiddenTest.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperLabelExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperLabelExtensionsTest.cs index 95c8d3a38a..1c1a1861e6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperLabelExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperLabelExtensionsTest.cs @@ -4,7 +4,6 @@ using System; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperLinkGenerationTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperLinkGenerationTest.cs index 3339241361..be91f77692 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperLinkGenerationTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperLinkGenerationTest.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.Extensions.Internal; using Moq; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperListBoxExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperListBoxExtensionsTest.cs index d5277ae2bd..1a2c4d6d4b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperListBoxExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperListBoxExtensionsTest.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPartialExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPartialExtensionsTest.cs index 23944cc16e..f795adcf2b 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPartialExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPartialExtensionsTest.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Moq; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPasswordTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPasswordTest.cs index f951e13c9f..61465fe48e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPasswordTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPasswordTest.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperRadioButtonExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperRadioButtonExtensionsTest.cs index ce3b3ca7fe..cbfb61c7bf 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperRadioButtonExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperRadioButtonExtensionsTest.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperSelectTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperSelectTest.cs index 9a0d279ffe..41050775f2 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperSelectTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperSelectTest.cs @@ -8,7 +8,6 @@ using System.Globalization; using System.Linq; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; @@ -1038,7 +1037,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering } [Fact] - public void ListBoxFor_WithUnreleatedExpression_GeneratesExpectedValue() + public void ListBoxFor_WithUnrelatedExpression_GeneratesExpectedValue() { // Arrange var unrelated = new[] { "2" }; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextAreaExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextAreaExtensionsTest.cs index 2f30e0f551..b5bd004a48 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextAreaExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextAreaExtensionsTest.cs @@ -4,7 +4,6 @@ using System; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextAreaTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextAreaTest.cs index 1e5d51fc1c..a125cc250d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextAreaTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextAreaTest.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; -using Microsoft.AspNetCore.Mvc.TestCommon; using Xunit; namespace Microsoft.AspNetCore.Mvc.Rendering diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxExtensionsTest.cs index 700f9fbf4c..64444999a6 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxExtensionsTest.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Testing; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxTest.cs index 82b5710fde..16d0b0e2f0 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxTest.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; -using Microsoft.AspNetCore.Mvc.TestCommon; using Xunit; namespace Microsoft.AspNetCore.Mvc.Rendering diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperValidationMessageExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperValidationMessageExtensionsTest.cs index b9e1f5fc4f..6bf8c50b65 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperValidationMessageExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperValidationMessageExtensionsTest.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Xunit; namespace Microsoft.AspNetCore.Mvc.Core diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperValidationSummaryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperValidationSummaryTest.cs index 5531f4da05..28b6ad4c86 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperValidationSummaryTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperValidationSummaryTest.cs @@ -7,7 +7,6 @@ using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Xunit; namespace Microsoft.AspNetCore.Mvc.Rendering diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/TagBuilderTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/TagBuilderTest.cs index ce76a6c786..0132887614 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/TagBuilderTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/TagBuilderTest.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.IO; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.Extensions.WebEncoders.Testing; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/TestResources.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/TestResources.cs index 5c5b711090..5f34f8406c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/TestResources.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/TestResources.cs @@ -1,7 +1,7 @@ // 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.ViewFeatures.Test; +using Resources = Microsoft.AspNetCore.Mvc.ViewFeatures.Test.Resources; namespace Microsoft.AspNetCore.Mvc { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewComponentResultTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewComponentResultTest.cs index 1744a3380b..fcb0c7c92c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewComponentResultTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewComponentResultTest.cs @@ -12,10 +12,9 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewComponents; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/AntiforgeryExtensionsTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/AntiforgeryExtensionsTest.cs index d3b57ffb7d..9123aeed3e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/AntiforgeryExtensionsTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/AntiforgeryExtensionsTest.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.TestCommon; using Moq; using Xunit; diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/CachedExpressionCompilerTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/CachedExpressionCompilerTest.cs new file mode 100644 index 0000000000..a798d5594e --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/CachedExpressionCompilerTest.cs @@ -0,0 +1,1012 @@ +// 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.Expressions; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + public class CachedExpressionCompilerTest + { + [Fact] + public void Process_IdentityExpression() + { + // Arrange + var model = new TestModel(); + var expression = GetTestModelExpression(m => m); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Same(model, result); + } + + [Fact] + public void Process_CachesIdentityExpression() + { + // Arrange + var expression1 = GetTestModelExpression(m => m); + var expression2 = GetTestModelExpression(m => m); + + // Act + var func1 = CachedExpressionCompiler.Process(expression1); + var func2 = CachedExpressionCompiler.Process(expression2); + + // Assert + Assert.NotNull(func1); + Assert.Same(func1, func2); + } + + [Fact] + public void Process_ConstLookup() + { + // Arrange + var model = new TestModel(); + var differentModel = new DifferentModel(); + var expression = GetTestModelExpression(m => differentModel); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Same(differentModel, result); + } + + [Fact] + public void Process_ConstLookup_ReturningNull() + { + // Arrange + var model = new TestModel(); + var expression = GetTestModelExpression(m => (DifferentModel)null); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_ConstLookup_WithNullModel() + { + // Arrange + var differentModel = new DifferentModel(); + var expression = GetTestModelExpression(m => differentModel); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(null); + Assert.Same(differentModel, result); + } + + [Fact] + public void Process_ConstLookup_UsingCachedValue() + { + // Arrange + var model = new TestModel(); + var differentModel = new DifferentModel(); + var expression1 = GetTestModelExpression(m => differentModel); + var expression2 = GetTestModelExpression(m => differentModel); + + // Act - 1 + var func1 = CachedExpressionCompiler.Process(expression1); + + // Assert - 1 + var result1 = func1(null); + Assert.Same(differentModel, result1); + + // Act - 2 + var func2 = CachedExpressionCompiler.Process(expression2); + + // Assert - 2 + var result2 = func1(null); + Assert.Same(differentModel, result2); + } + + [Fact] + public void Process_ConstLookup_WhenCapturedLocalChanges() + { + // Arrange + var model = new TestModel(); + var differentModel = new DifferentModel(); + var expression = GetTestModelExpression(m => differentModel); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert - 1 + var result1 = func(null); + Assert.Same(differentModel, result1); + + // Act - 2 + differentModel = new DifferentModel(); + + // Assert - 2 + var result2 = func(null); + Assert.NotSame(differentModel, result1); + Assert.Same(differentModel, result2); + } + + [Fact] + public void Process_ConstLookup_WithPrimitiveConstant() + { + // Arrange + var model = new TestModel(); + var expression = GetTestModelExpression(m => 10); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(10, result); + } + + [Fact] + public void Process_StaticFieldAccess() + { + // Arrange + var model = new TestModel(); + var expression = GetTestModelExpression(m => TestModel.StaticField); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal("StaticValue", result); + } + + [Fact] + public void Process_CachesStaticFieldAccess() + { + // Arrange + var expression1 = GetTestModelExpression(m => TestModel.StaticField); + var expression2 = GetTestModelExpression(m => TestModel.StaticField); + + // Act + var func1 = CachedExpressionCompiler.Process(expression1); + var func2 = CachedExpressionCompiler.Process(expression2); + + // Assert + Assert.NotNull(func1); + Assert.Same(func1, func2); + } + + [Fact] + public void Process_StaticPropertyAccess() + { + // Arrange + var expected = "TestValue"; + TestModel.StaticProperty = expected; + var model = new TestModel(); + var expression = GetTestModelExpression(m => TestModel.StaticProperty); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(expected, result); + } + + [Fact] + public void Process_CachesStaticPropertyAccess() + { + // Arrange + var expression1 = GetTestModelExpression(m => TestModel.StaticProperty); + var expression2 = GetTestModelExpression(m => TestModel.StaticProperty); + + // Act + var func1 = CachedExpressionCompiler.Process(expression1); + var func2 = CachedExpressionCompiler.Process(expression2); + + // Assert + Assert.NotNull(func1); + Assert.Same(func1, func2); + } + + [Fact] + public void Process_StaticPropertyAccess_WithNullModel() + { + // Arrange + var expected = "TestValue"; + TestModel.StaticProperty = expected; + var expression = GetTestModelExpression(m => TestModel.StaticProperty); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(null); + Assert.Equal(expected, result); + } + + [Fact] + public void Process_ConstFieldLookup() + { + // Arrange + var model = new TestModel(); + var expression = GetTestModelExpression(m => DifferentModel.Constant); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(10, result); + } + + [Fact] + public void Process_ConstFieldLookup_WthNullModel() + { + // Arrange + var expression = GetTestModelExpression(m => DifferentModel.Constant); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(null); + Assert.Equal(10, result); + } + + [Fact] + public void Process_SimpleMemberAccess() + { + // Arrange + var model = new TestModel { Name = "Test" }; + var expression = GetTestModelExpression(m => m.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal("Test", result); + } + + [Fact] + public void Process_CachesSimpleMemberAccess() + { + // Arrange + var expression1 = GetTestModelExpression(m => m.Name); + var expression2 = GetTestModelExpression(m => m.Name); + + // Act + var func1 = CachedExpressionCompiler.Process(expression1); + var func2 = CachedExpressionCompiler.Process(expression2); + + // Assert + Assert.NotNull(func1); + Assert.Same(func1, func2); + } + + [Fact] + public void Process_SimpleMemberAccess_ToPrimitive() + { + // Arrange + var model = new TestModel { Age = 12 }; + var expression = GetTestModelExpression(m => m.Age); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(12, result); + } + + [Fact] + public void Process_SimpleMemberAccess_WithNullModel() + { + // Arrange + var model = (TestModel)null; + var expression = GetTestModelExpression(m => m.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_SimpleMemberAccess_ToPrimitive_WithNullModel() + { + // Arrange + var model = (TestModel)null; + var expression = GetTestModelExpression(m => m.Age); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_SimpleMemberAccess_OnTypeWithBadEqualityComparer() + { + // Arrange + var model = new BadEqualityModel { Id = 7 }; + var expression = GetExpression(m => m.Id); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(7, result); + } + + [Fact] + public void Process_SimpleMemberAccess_OnTypeWithBadEqualityComparer_WithNullModel() + { + // Arrange + var model = (BadEqualityModel)null; + var expression = GetExpression(m => m.Id); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_SimpleMemberAccess_OnValueTypeWithBadEqualityComparer() + { + // Arrange + var model = new BadEqualityValueTypeModel { Id = 7 }; + var expression = GetExpression(m => m.Id); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(7, result); + } + + [Fact] + public void Process_SimpleMemberAccess_OnTypeWithBadEqualityComparer_WithDefaultValue() + { + // Arrange + var model = (BadEqualityValueTypeModel)default; + var expression = GetExpression(m => m.Id); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(model.Id, result); + } + + [Fact] + public void Process_SimpleMemberAccess_OnValueType() + { + // Arrange + var model = new DateTime(2000, 1, 1); + var expression = GetExpression(m => m.Year); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(2000, result); + } + + [Fact] + public void Process_SimpleMemberAccess_OnValueType_WithDefaultValue() + { + // Arrange + var model = default(DateTime); + var expression = GetExpression(m => m.Year); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(1, result); + } + + [Fact] + public void Process_SimpleMemberAccess_OnNullableValueType() + { + // Arrange + var model = new DateTime(2000, 1, 1); + var nullableModel = (DateTime?)model; + var expression = GetExpression(m => m.Value); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(nullableModel); + Assert.Equal(model, result); + } + + [Fact] + public void Process_SimpleMemberAccess_OnNullableValueType_WithNullValue() + { + // Arrange + var nullableModel = (DateTime?)null; + var expression = GetExpression(m => m.Value); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(nullableModel); + Assert.Null(result); + } + + [Fact] + public void Process_ChainedMemberAccess_ToValueType() + { + // Arrange + var dateTime = new DateTime(2000, 1, 1); + var model = new TestModel { Date = dateTime }; + var expression = GetTestModelExpression(m => m.Date.Year); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(2000, result); + } + + [Fact] + public void Process_ChainedMemberAccess_ToValueType_WithNullModel() + { + // Arrange + var model = (TestModel)null; + var expression = GetTestModelExpression(m => m.Date.Year); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_ChainedMemberAccess_ToReferenceType() + { + // Arrange + var expected = "Test1"; + var model = new TestModel { DifferentModel = new DifferentModel { Name = expected } }; + var expression = GetTestModelExpression(m => m.DifferentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(expected, result); + } + + [Fact] + public void Process_CachesChainedMemberAccess() + { + // Arrange + var expression1 = GetTestModelExpression(m => m.DifferentModel.Name); + var expression2 = GetTestModelExpression(m => m.DifferentModel.Name); + + // Act + var func1 = CachedExpressionCompiler.Process(expression1); + var func2 = CachedExpressionCompiler.Process(expression2); + + // Assert + Assert.NotNull(func1); + Assert.Same(func1, func2); + } + + [Fact] + public void Process_CachesChainedMemberAccess_ToValueType() + { + // Arrange + var expression1 = GetTestModelExpression(m => m.Date.Year); + var expression2 = GetTestModelExpression(m => m.Date.Year); + + // Act + var func1 = CachedExpressionCompiler.Process(expression1); + var func2 = CachedExpressionCompiler.Process(expression2); + + // Assert + Assert.NotNull(func1); + Assert.Same(func1, func2); + } + + [Fact] + public void Process_ChainedMemberAccess_LongChain_WithReferenceType() + { + // Arrange + var expected = "TestVal"; + var model = new Chain0Model + { + Chain1 = new Chain1Model + { + ValueTypeModel = new ValueType1 + { + TestModel = new TestModel { DifferentModel = new DifferentModel { Name = expected } } + } + } + }; + + var expression = GetExpression(m => m.Chain1.ValueTypeModel.TestModel.DifferentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(expected, result); + } + + [Fact] + public void Process_ChainedMemberAccess_LongChain_WithNullIntermediary() + { + // Arrange + var model = new Chain0Model + { + Chain1 = new Chain1Model + { + ValueTypeModel = new ValueType1 { TestModel = null }, + } + }; + + var expression = GetExpression(m => m.Chain1.ValueTypeModel.TestModel.DifferentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_ChainedMemberAccess_LongChain_WithNullValueTypeAccessor() + { + // Arrange + // Chain2 is a value type + var model = new Chain0Model + { + Chain1 = null + }; + + var expression = GetExpression(m => m.Chain1.ValueTypeModel.TestModel.DifferentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_ChainedMemberAccess_LongChain_WithNullableValueType() + { + // Arrange + var expected = "TestVal"; + var model = new Chain0Model + { + Chain1 = new Chain1Model + { + NullableValueTypeModel = new ValueType1 + { + TestModel = new TestModel { DifferentModel = new DifferentModel { Name = expected } } + } + } + }; + + var expression = GetExpression(m => m.Chain1.NullableValueTypeModel.Value.TestModel.DifferentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(expected, result); + } + + [Fact] + public void Process_ChainedMemberAccess_LongChain_WithNullValuedNullableValueType() + { + // Arrange + var model = new Chain0Model + { + Chain1 = new Chain1Model + { + NullableValueTypeModel = null + } + }; + + var expression = GetExpression(m => m.Chain1.NullableValueTypeModel.Value.TestModel.DifferentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_ChainedMemberAccess_ToReferenceType_WithNullIntermediary() + { + // Arrange + var model = new TestModel { DifferentModel = null }; + var expression = GetTestModelExpression(m => m.DifferentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_ChainedMemberAccess_ToReferenceType_WithNullModel() + { + // Arrange + var model = (TestModel)null; + var expression = GetTestModelExpression(m => m.DifferentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_ChainedMemberAccess_OfValueTypes_ReturningReferenceTypeMember() + { + // Arrange + var expected = "TestName"; + var model = new ValueType1 + { + ValueType2 = new ValueType2 { Name = expected }, + }; + var expression = GetExpression(m => m.ValueType2.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(expected, result); + } + + [Fact] + public void Process_ChainedMemberAccess_OfValueTypes_ReturningValueType() + { + // Arrange + var expected = new DateTime(2001, 1, 1); + var model = new ValueType1 + { + ValueType2 = new ValueType2 { Date = expected }, + }; + var expression = GetExpression(m => m.ValueType2.Date); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(expected, result); + } + + [Fact] + public void Process_ChainedMemberAccess_OfValueTypes_IncludingNullableType() + { + // Arrange + var expected = "TestName"; + var model = new ValueType1 + { + NullableValueType2 = new ValueType2 { Name = expected }, + }; + var expression = GetExpression(m => m.NullableValueType2.Value.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Equal(expected, result); + } + + [Fact] + public void Process_ChainedMemberAccess_OfValueTypes_WithNullValuedNullable() + { + // Arrange + var model = new ValueType1 { NullableValueType2 = null }; + var expression = GetExpression(m => m.NullableValueType2.Value.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_ChainedMemberAccess_OfValueTypes_WithNullValuedNullable_ReturningValueType() + { + // Arrange + var model = new ValueType1 { NullableValueType2 = null }; + var expression = GetExpression(m => m.NullableValueType2.Value.Date); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Null(result); + } + + [Fact] + public void Process_MemberAccessOnCapturedVariable_ReturnsNull() + { + // Arrange + var differentModel = new DifferentModel { Name = "Test" }; + var expression = GetTestModelExpression(m => differentModel.Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.Null(func); + } + + [Fact] + public void Process_CapturedVariable() + { + // Arrange + var differentModel = new DifferentModel(); + var model = new TestModel(); + var expression = GetTestModelExpression(m => differentModel); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Same(differentModel, result); + } + + [Fact] + public void Process_CapturedVariable_WithNullModel() + { + // Arrange + var differentModel = new DifferentModel(); + var model = (TestModel)null; + var expression = GetTestModelExpression(m => differentModel); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.NotNull(func); + var result = func(model); + Assert.Same(differentModel, result); + } + + + [Fact] + public void Process_MemberAccess_OnCapturedVariable_ReturnsNull() + { + // Arrange + var differentModel = "Hello world"; + var expression = GetTestModelExpression(m => differentModel.Length); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.Null(func); + } + + [Fact] + public void Process_ComplexChainedMemberAccess_ReturnsNull() + { + // Arrange + var expected = "SomeName"; + var model = new TestModel { DifferentModels = new[] { new DifferentModel { Name = expected } } }; + var expression = GetTestModelExpression(m => m.DifferentModels[0].Name); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.Null(func); + } + + [Fact] + public void Process_ArrayMemberAccess_ReturnsNull() + { + // Arrange + var expression = GetTestModelExpression(m => m.Sizes[1]); + + // Act + var func = CachedExpressionCompiler.Process(expression); + + // Assert + Assert.Null(func); + } + + private static Expression> GetExpression(Expression> expression) + => expression; + + private static Expression> GetTestModelExpression(Expression> expression) + => GetExpression(expression); + + public class TestModel + { + public static readonly string StaticField = "StaticValue"; + + public static string StaticProperty { get; set; } + + public int Age { get; set; } + + public string Name { get; set; } + + public DateTime Date { get; set; } + + public DifferentModel DifferentModel { get; set; } + + public int[] Sizes { get; set; } + + public DifferentModel[] DifferentModels { get; set; } + } + + public class DifferentModel + { + public const int Constant = 10; + + public string Name { get; set; } + } + + public class Chain0Model + { + public Chain1Model Chain1 { get; set; } + } + + public class Chain1Model + { + public ValueType1 ValueTypeModel { get; set; } + + public ValueType1? NullableValueTypeModel { get; set; } + } + + public struct ValueType1 + { + public TestModel TestModel { get; set; } + + public ValueType2 ValueType2 { get; set; } + + public ValueType2? NullableValueType2 { get; set; } + } + + public struct ValueType2 + { + public string Name { get; set; } + + public DateTime Date { get; set; } + } + + public class BadEqualityModel + { + public int Id { get; set; } + + public override bool Equals(object obj) + { + return this == obj; + } + + public static bool operator ==(BadEqualityModel a, object b) + { + if (a is null || b is null) + { + throw new TimeZoneNotFoundException(); + } + + return true; + } + + public static bool operator !=(BadEqualityModel a, object b) + { + return !(a == b); + } + + public override int GetHashCode() => 0; + } + + public struct BadEqualityValueTypeModel + { + public int Id { get; set; } + + public override bool Equals(object obj) + { + return this == obj; + } + + public static bool operator ==(BadEqualityValueTypeModel a, object b) + { + if (b is null) + { + throw new TimeZoneNotFoundException(); + } + + return true; + } + + public static bool operator !=(BadEqualityValueTypeModel a, object b) + { + return !(a == b); + } + + public override int GetHashCode() => 0; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs index c8c7198ad9..0d38ca3674 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Text.Encodings.Web; @@ -13,7 +14,6 @@ using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Testing; @@ -191,6 +191,169 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures Assert.Equal(expected, attribute.Value); } + [Theory] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength), ModelWithMaxLengthMetadata.MaxLengthAttributeValue)] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength), ModelWithMaxLengthMetadata.StringLengthAttributeValue)] + public void GenerateTextArea_RendersMaxLength(string expression, int expectedValue) + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlGenerator = GetGenerator(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + }; + + // Act + var tagBuilder = htmlGenerator.GenerateTextArea(viewContext, modelExplorer, expression, rows: 1, columns: 1, htmlAttributes); + + // Assert + var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength"); + Assert.Equal(expectedValue, Int32.Parse(attribute.Value)); + } + + [Theory] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength), ModelWithMaxLengthMetadata.MaxLengthAttributeValue)] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength), ModelWithMaxLengthMetadata.StringLengthAttributeValue)] + public void GeneratePassword_RendersMaxLength(string expression, int expectedValue) + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlGenerator = GetGenerator(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + }; + + // Act + var tagBuilder = htmlGenerator.GeneratePassword(viewContext, modelExplorer, expression, null, htmlAttributes); + + // Assert + var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength"); + Assert.Equal(expectedValue, Int32.Parse(attribute.Value)); + } + + [Theory] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength), ModelWithMaxLengthMetadata.MaxLengthAttributeValue)] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength), ModelWithMaxLengthMetadata.StringLengthAttributeValue)] + public void GenerateTextBox_RendersMaxLength(string expression, int expectedValue) + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlGenerator = GetGenerator(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + }; + + // Act + var tagBuilder = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, expression, null, null, htmlAttributes); + + // Assert + var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength"); + Assert.Equal(expectedValue, Int32.Parse(attribute.Value)); + } + + [Fact] + public void GenerateTextBox_RendersMaxLength_WithMinimumValueFromBothAttributes() + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlGenerator = GetGenerator(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), nameof(ModelWithMaxLengthMetadata.FieldWithBothAttributes)); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + }; + + // Act + var tagBuilder = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, nameof(ModelWithMaxLengthMetadata.FieldWithBothAttributes), null, null, htmlAttributes); + + // Assert + var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength"); + Assert.Equal(Math.Min(ModelWithMaxLengthMetadata.MaxLengthAttributeValue, ModelWithMaxLengthMetadata.StringLengthAttributeValue), Int32.Parse(attribute.Value)); + } + + [Fact] + public void GenerateTextBox_DoesNotRenderMaxLength_WhenNoAttributesPresent() + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlGenerator = GetGenerator(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), nameof(ModelWithMaxLengthMetadata.FieldWithoutAttributes)); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + }; + + // Act + var tagBuilder = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, nameof(ModelWithMaxLengthMetadata.FieldWithoutAttributes), null, null, htmlAttributes); + + // Assert + Assert.DoesNotContain(tagBuilder.Attributes, a => a.Key == "maxlength"); + } + + [Theory] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength), ModelWithMaxLengthMetadata.MaxLengthAttributeValue)] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength), ModelWithMaxLengthMetadata.StringLengthAttributeValue)] + public void GenerateTextBox_SearchType_RendersMaxLength(string expression, int expectedValue) + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlGenerator = GetGenerator(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + { "type", "search"} + }; + + // Act + var tagBuilder = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, expression, null, null, htmlAttributes); + + // Assert + var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength"); + Assert.Equal(expectedValue, Int32.Parse(attribute.Value)); + } + + [Theory] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength))] + [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength))] + public void GenerateHidden_DoesNotRenderMaxLength(string expression) + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlGenerator = GetGenerator(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + }; + + // Act + var tagBuilder = htmlGenerator.GenerateHidden(viewContext, modelExplorer, expression, null, false, htmlAttributes); + + // Assert + Assert.DoesNotContain(tagBuilder.Attributes, a => a.Key == "maxlength"); + } + [Fact] public void GenerateValidationMessage_WithNullExpression_Throws() { @@ -760,7 +923,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvider) { var mvcViewOptionsAccessor = new Mock>(); - mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions()); + mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions() { AllowRenderingMaxLengthAttribute = true }); var htmlEncoder = Mock.Of(); var antiforgery = new Mock(); @@ -821,6 +984,24 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures All = -1, } + private class ModelWithMaxLengthMetadata + { + internal const int MaxLengthAttributeValue = 77; + internal const int StringLengthAttributeValue = 7; + + [MaxLength(MaxLengthAttributeValue)] + public string FieldWithMaxLength { get; set; } + + [StringLength(StringLengthAttributeValue)] + public string FieldWithStringLength { get; set; } + + public string FieldWithoutAttributes { get; set; } + + [MaxLength(MaxLengthAttributeValue)] + [StringLength(StringLengthAttributeValue)] + public string FieldWithBothAttributes { get; set; } + } + private class Model { public int Id { get; set; } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/PartialViewResultExecutorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/PartialViewResultExecutorTest.cs index ff2abdd59e..3a34cc25d2 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/PartialViewResultExecutorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/PartialViewResultExecutorTest.cs @@ -1,6 +1,7 @@ // 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.Linq; using System.Threading.Tasks; @@ -10,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -75,6 +77,30 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures Assert.Equal(viewName, viewEngineResult.ViewName); } + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void FindView_UsesActionDescriptorName_IfViewNameIsNull_UsesInvariantCulture() + { + // Arrange + var viewName = "10/31/2018 07:37:38 -07:00"; + var context = GetActionContext(viewName); + context.RouteData.Values["action"] = new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)); + + var executor = GetViewExecutor(); + + var viewResult = new PartialViewResult + { + ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider()), + TempData = Mock.Of(), + }; + + // Act + var viewEngineResult = executor.FindView(context, viewResult); + + // Assert + Assert.Equal(viewName, viewEngineResult.ViewName); + } + [Fact] public void FindView_ReturnsExpectedNotFoundResult_WithGetViewLocations() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewDataDictionaryOfTModelTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewDataDictionaryOfTModelTest.cs index 31f0d036d6..2a2a355257 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewDataDictionaryOfTModelTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewDataDictionaryOfTModelTest.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures public class ViewDataDictionaryOfTModelTest { [Fact] - public void Constructor_InitalizesMembers() + public void Constructor_InitializesMembers() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); @@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures } [Fact] - public void CopyConstructors_InitalizeModelAndModelMetadataBasedOnSource() + public void CopyConstructors_InitializeModelAndModelMetadataBasedOnSource() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); @@ -73,7 +73,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures } [Fact] - public void CopyConstructors_InitalizeModelAndModelMetadataBasedOnSource_NullModel() + public void CopyConstructors_InitializeModelAndModelMetadataBasedOnSource_NullModel() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); @@ -112,7 +112,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures } [Fact] - public void CopyConstructor_InitalizesModelAndModelMetadataBasedOnSource_ModelOfSubclass() + public void CopyConstructor_InitializesModelAndModelMetadataBasedOnSource_ModelOfSubclass() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewDataDictionaryTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewDataDictionaryTest.cs index cfcb4bf426..bd2b2e47ba 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewDataDictionaryTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewDataDictionaryTest.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures public class ViewDataDictionaryTest { [Fact] - public void ConstructorWithOneParameterInitalizesMembers() + public void ConstructorWithOneParameterInitializesMembers() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); @@ -31,7 +31,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures } [Fact] - public void ConstructorInitalizesMembers() + public void ConstructorInitializesMembers() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); @@ -186,7 +186,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures } [Fact] - public void CopyConstructorInitalizesModelAndModelMetadataBasedOnSource() + public void CopyConstructorInitializesModelAndModelMetadataBasedOnSource() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewExecutorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewExecutorTest.cs index 12be821f84..7fc8949fa3 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewExecutorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewExecutorTest.cs @@ -11,10 +11,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.TestCommon; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -142,11 +140,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures // Arrange var tempDataNull = false; var viewDataNull = false; - var deligateHit = false; + var delegateHit = false; var view = CreateView(async (v) => { - deligateHit = true; + delegateHit = true; tempDataNull = v.TempData == null; viewDataNull = v.ViewData == null; @@ -176,7 +174,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures // Assert Assert.Equal(200, context.Response.StatusCode); - Assert.True(deligateHit); + Assert.True(delegateHit); Assert.False(viewDataNull); Assert.False(tempDataNull); } diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewResultExecutorTest.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewResultExecutorTest.cs index 415a45700b..f8a8ae8745 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewResultExecutorTest.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ViewResultExecutorTest.cs @@ -1,6 +1,7 @@ // 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.Linq; using System.Threading.Tasks; @@ -9,6 +10,7 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -74,6 +76,30 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures Assert.Equal(viewName, viewEngineResult.ViewName); } + [Fact] + [ReplaceCulture("de-CH", "de-CH")] + public void FindView_UsesActionDescriptorName_IfViewNameIsNull_UsesInvariantCulture() + { + // Arrange + var viewName = "10/31/2018 07:37:38 -07:00"; + var context = GetActionContext(viewName); + context.RouteData.Values["action"] = new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)); + + var executor = GetViewExecutor(); + + var viewResult = new ViewResult + { + ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider()), + TempData = Mock.Of(), + }; + + // Act + var viewEngineResult = executor.FindView(context, viewResult); + + // Assert + Assert.Equal(viewName, viewEngineResult.ViewName); + } + [Fact] public void FindView_ReturnsExpectedNotFoundResult_WithGetViewLocations() { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlContentUtilities.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/HtmlContentUtilities.cs similarity index 94% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlContentUtilities.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/HtmlContentUtilities.cs index 0f7c7aca6e..b828223c65 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlContentUtilities.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/HtmlContentUtilities.cs @@ -6,7 +6,7 @@ using System.Text.Encodings.Web; using Microsoft.AspNetCore.Html; using Microsoft.Extensions.WebEncoders.Testing; -namespace Microsoft.AspNetCore.Mvc.TestCommon +namespace Microsoft.AspNetCore.Mvc { public class HtmlContentUtilities { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlGeneratorUtilities.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/HtmlGeneratorUtilities.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlGeneratorUtilities.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/HtmlGeneratorUtilities.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj new file mode 100644 index 0000000000..4cf0e46940 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj @@ -0,0 +1,18 @@ + + + + $(StandardTestTfms) + + + + + + + + + + + + + + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestDirectoryContent.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestDirectoryContent.cs similarity index 92% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestDirectoryContent.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestDirectoryContent.cs index 0ddb4f7823..476a59ef2d 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestDirectoryContent.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestDirectoryContent.cs @@ -5,9 +5,8 @@ using System; using System.Collections; using System.Collections.Generic; using System.IO; -using Microsoft.Extensions.FileProviders; -namespace Microsoft.AspNetCore.Mvc.TestCommon +namespace Microsoft.Extensions.FileProviders { public class TestDirectoryContent : IDirectoryContents, IFileInfo { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestDirectoryFileInfo.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestDirectoryFileInfo.cs similarity index 86% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestDirectoryFileInfo.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestDirectoryFileInfo.cs index 4183025503..8d2a05e29f 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestDirectoryFileInfo.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestDirectoryFileInfo.cs @@ -3,10 +3,8 @@ using System; using System.IO; -using System.Text; -using Microsoft.Extensions.FileProviders; -namespace Microsoft.AspNetCore.Mvc.Razor +namespace Microsoft.Extensions.FileProviders { public class TestDirectoryFileInfo : IFileInfo { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileChangeToken.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestFileChangeToken.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileChangeToken.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestFileChangeToken.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileInfo.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestFileInfo.cs similarity index 92% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileInfo.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestFileInfo.cs index 79b2f94c4f..9545c36d5e 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileInfo.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestFileInfo.cs @@ -4,9 +4,8 @@ using System; using System.IO; using System.Text; -using Microsoft.Extensions.FileProviders; -namespace Microsoft.AspNetCore.Mvc.Razor +namespace Microsoft.Extensions.FileProviders { public class TestFileInfo : IFileInfo { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileProvider.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestFileProvider.cs similarity index 97% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileProvider.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestFileProvider.cs index 3e3f62a9e5..e54d2c770c 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileProvider.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestFileProvider.cs @@ -4,11 +4,9 @@ using System; using System.Collections.Generic; using System.IO; -using Microsoft.AspNetCore.Mvc.TestCommon; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; -namespace Microsoft.AspNetCore.Mvc.Razor +namespace Microsoft.Extensions.FileProviders { public class TestFileProvider : IFileProvider { diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestRazorCompiledItem.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestRazorCompiledItem.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestRazorCompiledItem.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestRazorCompiledItem.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestRazorProjectItem.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestRazorProjectItem.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestRazorProjectItem.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestRazorProjectItem.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestViewBufferScope.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestViewBufferScope.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/TestViewBufferScope.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/TestViewBufferScope.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/VirtualRazorProjectFileSystem.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/VirtualRazorProjectFileSystem.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.TestCommon/VirtualRazorProjectFileSystem.cs rename to src/Mvc/test/Microsoft.AspNetCore.Mvc.Views.TestCommon/VirtualRazorProjectFileSystem.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest.csproj b/src/Mvc/test/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest.csproj index b10e11d8e7..42bdfc7899 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest.csproj +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) @@ -7,7 +7,7 @@ - + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest/TestUtils/TestDataSetAttribute.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest/TestUtils/TestDataSetAttribute.cs index 33a5897181..0f891bb34a 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest/TestUtils/TestDataSetAttribute.cs +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.WebApiCompatShimTest/TestUtils/TestDataSetAttribute.cs @@ -185,14 +185,14 @@ namespace Microsoft.TestCommon private static IEnumerable GetDataSetFromTestDataCollection(IEnumerable testDataCollection, TestDataVariations variations) { - foreach (TestData testdataInstance in testDataCollection) + foreach (TestData testDataInstance in testDataCollection) { - foreach (TestDataVariations variation in testdataInstance.GetSupportedTestDataVariations()) + foreach (TestDataVariations variation in testDataInstance.GetSupportedTestDataVariations()) { if ((variation & variations) == variation) { - Type variationType = testdataInstance.GetAsTypeOrNull(variation); - object testData = testdataInstance.GetAsTestDataOrNull(variation); + Type variationType = testDataInstance.GetAsTypeOrNull(variation); + object testData = testDataInstance.GetAsTestDataOrNull(variation); if (AsSingleInstances(variation)) { foreach (object obj in (IEnumerable)testData) diff --git a/src/Mvc/test/Mvc.Analyzers.Test/AttributesShouldNotBeAppliedToPageModelAnalyzerTest.cs b/src/Mvc/test/Mvc.Analyzers.Test/AttributesShouldNotBeAppliedToPageModelAnalyzerTest.cs new file mode 100644 index 0000000000..baac0e3b4f --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/AttributesShouldNotBeAppliedToPageModelAnalyzerTest.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 System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + public class AttributesShouldNotBeAppliedToPageModelAnalyzerTest + { + private MvcDiagnosticAnalyzerRunner Executor { get; } = new MvcDiagnosticAnalyzerRunner(new AttributesShouldNotBeAppliedToPageModelAnalyzer()); + + [Fact] + public async Task NoDiagnosticsAreReturned_FoEmptyScenarios() + { + // Act + var result = await Executor.GetDiagnosticsAsync(source: string.Empty); + + // Assert + Assert.Empty(result); + } + + [Fact] + public Task NoDiagnosticsAreReturned_ForControllerBaseActions() + => VerifyNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForControllerActions() + => VerifyNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForPageHandlersWithNonFilterAttributes() + => VerifyNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_IfFiltersAreAppliedToPageModel() + => VerifyNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageModel() + => VerifyNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageModel() + => VerifyNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForNonHandlerMethodsWithAttributes() + => VerifyNoDiagnosticsAreReturned(); + + [Fact] + public Task DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethod() + => VerifyDefault(DiagnosticDescriptors.MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods); + + [Fact] + public Task DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodDerivingFromCustomModel() + => VerifyDefault(DiagnosticDescriptors.MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods); + + [Fact] + public Task DiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageHandlerMethod() + => VerifyDefault(DiagnosticDescriptors.MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods); + + [Fact] + public Task DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodForTypeWithPageModelAttribute() + => VerifyDefault(DiagnosticDescriptors.MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods); + + [Fact] + public Task DiagnosticsAreReturned_IfAttributeIsAppliedToBaseType() + => VerifyDefault(DiagnosticDescriptors.MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods); + + [Fact] + public Task DiagnosticsAreReturned_IfRouteAttributesAreAppliedToPageHandlerMethod() + => VerifyDefault(DiagnosticDescriptors.MVC1002_RouteAttributesShouldNotBeAppliedToPageHandlerMethods); + + [Fact] + public Task DiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageHandlerMethod() + => VerifyDefault(DiagnosticDescriptors.MVC1001_FiltersShouldNotBeAppliedToPageHandlerMethods); + + [Fact] + public Task DiagnosticsAreReturned_IfRouteAttribute_IsAppliedToPageModel() + => VerifyDefault(DiagnosticDescriptors.MVC1003_RouteAttributesShouldNotBeAppliedToPageModels); + + private async Task VerifyNoDiagnosticsAreReturned([CallerMemberName] string testMethod = "") + { + // Arrange + var source = MvcTestSource.Read(GetType().Name, testMethod); + + // Act + var result = await Executor.GetDiagnosticsAsync(source.Source); + + // Assert + Assert.Empty(result); + } + + private async Task VerifyDefault(DiagnosticDescriptor descriptor, [CallerMemberName] string testMethod = "") + { + // Arrange + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Executor.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Collection( + result, + diagnostic => + { + + Assert.Equal(descriptor.Id, diagnostic.Id); + Assert.Same(descriptor, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(expectedLocation, diagnostic.Location); + }); + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/AvoidHtmlPartialAnalyzerTest.cs b/src/Mvc/test/Mvc.Analyzers.Test/AvoidHtmlPartialAnalyzerTest.cs new file mode 100644 index 0000000000..cac606e97c --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/AvoidHtmlPartialAnalyzerTest.cs @@ -0,0 +1,90 @@ +// 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.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class AvoidHtmlPartialAnalyzerTest + { + private static DiagnosticDescriptor DiagnosticDescriptor = DiagnosticDescriptors.MVC1000_HtmlHelperPartialShouldBeAvoided; + + private MvcDiagnosticAnalyzerRunner Executor { get; } = new MvcDiagnosticAnalyzerRunner(new AvoidHtmlPartialAnalyzer()); + + [Fact] + public Task NoDiagnosticsAreReturned_FoEmptyScenarios() + => VerifyNoDiagnosticsAreReturned(source: string.Empty); + + [Fact] + public Task NoDiagnosticsAreReturned_ForNonUseOfHtmlPartial() + => VerifyNoDiagnosticsAreReturned(ReadTestSource().Source); + + [Fact] + public Task NoDiagnosticsAreReturned_ForUseOfHtmlPartialAsync() + => VerifyNoDiagnosticsAreReturned(ReadTestSource().Source); + + [Fact] + public Task DiagnosticsAreReturned_ForUseOfHtmlPartial() + => VerifyDefault(ReadTestSource()); + + [Fact] + public Task DiagnosticsAreReturned_ForUseOfHtmlPartial_WithAdditionalParameters() + => VerifyDefault(ReadTestSource()); + + [Fact] + public Task DiagnosticsAreReturned_ForUseOfHtmlPartial_InSections() + => VerifyDefault(ReadTestSource()); + + [Fact] + public Task NoDiagnosticsAreReturned_ForUseOfRenderPartialAsync() + => VerifyNoDiagnosticsAreReturned(ReadTestSource().Source); + + [Fact] + public Task DiagnosticsAreReturned_ForUseOfRenderPartial() + => VerifyDefault(ReadTestSource()); + + [Fact] + public Task DiagnosticsAreReturned_ForUseOfRenderPartial_WithAdditionalParameters() + => VerifyDefault(ReadTestSource()); + + [Fact] + public Task DiagnosticsAreReturned_ForUseOfRenderPartial_InSections() + => VerifyDefault(ReadTestSource()); + + private async Task VerifyNoDiagnosticsAreReturned(string source) + { + // Act + var result = await Executor.GetDiagnosticsAsync(source); + + // Assert + Assert.Empty(result); + } + + private async Task VerifyDefault(TestSource testSource) + { + // Arrange + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Executor.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Collection( + result, + diagnostic => + { + + Assert.Equal(DiagnosticDescriptor.Id, diagnostic.Id); + Assert.Same(DiagnosticDescriptor, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(expectedLocation, diagnostic.Location); + }); + } + + private static TestSource ReadTestSource([CallerMemberName] string testMethod = "") => + MvcTestSource.Read(nameof(AvoidHtmlPartialAnalyzerTest), testMethod); + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/CodeAnalysisExtensionsTest.cs b/src/Mvc/test/Mvc.Analyzers.Test/CodeAnalysisExtensionsTest.cs new file mode 100644 index 0000000000..a9630cba1f --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/CodeAnalysisExtensionsTest.cs @@ -0,0 +1,499 @@ +// 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 System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class CodeAnalysisExtensionsTest + { + private static readonly string Namespace = typeof(CodeAnalysisExtensionsTest).Namespace; + + [Fact] + public async Task GetAttributes_OnMethodWithoutAttributes() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(ProducesResponseTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetAttributes_OnMethodWithoutAttributesClass)}"); + var method = (IMethodSymbol)testClass.GetMembers(nameof(GetAttributes_OnMethodWithoutAttributesClass.Method)).First(); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(method, attribute, inherit: true); + + // Assert + Assert.Empty(attributes); + } + + [Fact] + public async Task GetAttributes_OnNonOverriddenMethod_ReturnsAllAttributesOnCurrentAction() + { + // Arrange + var compilation = await GetCompilation("GetAttributes_WithoutMethodOverriding"); + var attribute = compilation.GetTypeByMetadataName(typeof(ProducesResponseTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetAttributes_WithoutMethodOverriding)}"); + var method = (IMethodSymbol)testClass.GetMembers(nameof(GetAttributes_WithoutMethodOverriding.Method)).First(); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(method, attribute, inherit: true); + + // Assert + Assert.Collection( + attributes, + attributeData => Assert.Equal(201, attributeData.ConstructorArguments[0].Value)); + } + + [Fact] + public async Task GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentAction() + { + // Arrange + var compilation = await GetCompilation("GetAttributes_WithMethodOverridding"); + var attribute = compilation.GetTypeByMetadataName(typeof(ProducesResponseTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass)}"); + var method = (IMethodSymbol)testClass.GetMembers(nameof(GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass.Method)).First(); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(method, attribute, inherit: false); + + // Assert + Assert.Collection( + attributes, + attributeData => Assert.Equal(400, attributeData.ConstructorArguments[0].Value)); + } + + [Fact] + public async Task GetAttributesSymbolOverload_OnMethodSymbol() + { + // Arrange + var compilation = await GetCompilation("GetAttributes_WithMethodOverridding"); + var attribute = compilation.GetTypeByMetadataName(typeof(ProducesResponseTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass)}"); + var method = (IMethodSymbol)testClass.GetMembers(nameof(GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass.Method)).First(); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(symbol: method, attribute: attribute); + + // Assert + Assert.Collection( + attributes, + attributeData => Assert.Equal(400, attributeData.ConstructorArguments[0].Value)); + } + + [Fact] + public async Task GetAttributes_WithInheritTrue_ReturnsAllAttributesOnCurrentActionAndOverridingMethod() + { + // Arrange + var compilation = await GetCompilation("GetAttributes_WithMethodOverridding"); + var attribute = compilation.GetTypeByMetadataName(typeof(ProducesResponseTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass)}"); + var method = (IMethodSymbol)testClass.GetMembers(nameof(GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass.Method)).First(); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(method, attribute, inherit: true); + + // Assert + Assert.Collection( + attributes, + attributeData => Assert.Equal(400, attributeData.ConstructorArguments[0].Value), + attributeData => Assert.Equal(200, attributeData.ConstructorArguments[0].Value), + attributeData => Assert.Equal(404, attributeData.ConstructorArguments[0].Value)); + } + + [Fact] + public async Task GetAttributes_OnNewMethodOfVirtualBaseMethod() + { + // Arrange + var compilation = await GetCompilation("GetAttributes_WithNewMethod"); + var attribute = compilation.GetTypeByMetadataName(typeof(ProducesResponseTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetAttributes_WithNewMethodDerived)}"); + var method = (IMethodSymbol)testClass.GetMembers(nameof(GetAttributes_WithNewMethodDerived.VirtualMethod)).First(); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(method, attribute, inherit: true); + + // Assert + Assert.Collection( + attributes, + attributeData => Assert.Equal(400, attributeData.ConstructorArguments[0].Value)); + } + + [Fact] + public async Task GetAttributes_OnNewMethodOfNonVirtualBaseMethod() + { + // Arrange + var compilation = await GetCompilation("GetAttributes_WithNewMethod"); + var attribute = compilation.GetTypeByMetadataName(typeof(ProducesResponseTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetAttributes_WithNewMethodDerived)}"); + var method = (IMethodSymbol)testClass.GetMembers(nameof(GetAttributes_WithNewMethodDerived.NotVirtualMethod)).First(); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(method, attribute, inherit: true); + + // Assert + Assert.Collection( + attributes, + attributeData => Assert.Equal(401, attributeData.ConstructorArguments[0].Value)); + } + + [Fact] + public async Task GetAttributes_OnTypeWithoutAttributes() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_OnTypeWithoutAttributesType).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(testClass, attribute, inherit: true); + + // Assert + Assert.Empty(attributes); + } + + [Fact] + public async Task GetAttributes_OnTypeWithAttributes() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_OnTypeWithAttributes).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(testClass, attribute, inherit: true); + + // Assert + Assert.Collection( + attributes, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Object)); + }, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_String)); + }); + } + + [Fact] + public async Task GetAttributes_BaseTypeWithAttributes() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_BaseTypeWithAttributesDerived).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(testClass, attribute, inherit: true); + + // Assert + Assert.Collection( + attributes, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Int32)); + }, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Object)); + }, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_String)); + }); + } + + [Fact] + public async Task GetAttributes_OnDerivedTypeWithInheritFalse() + { + // Arrange + var compilation = await GetCompilation(nameof(GetAttributes_BaseTypeWithAttributes)); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_BaseTypeWithAttributesDerived).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(testClass, attribute, inherit: false); + + // Assert + Assert.Collection( + attributes, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Int32)); + }); + } + + [Fact] + public async Task GetAttributesSymbolOverload_OnTypeSymbol() + { + // Arrange + var compilation = await GetCompilation(nameof(GetAttributes_BaseTypeWithAttributes)); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_BaseTypeWithAttributesDerived).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(symbol: testClass, attribute: attribute); + + // Assert + Assert.Collection( + attributes, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Int32)); + }); + } + + [Fact] + public async Task HasAttribute_ReturnsFalseIfSymbolDoesNotHaveAttribute() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsFalseIfTypeDoesNotHaveAttribute"); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsFalseIfTypeDoesNotHaveAttributeTest"); + var testMethod = (IMethodSymbol)testClass.GetMembers("SomeMethod").First(); + var testProperty = (IPropertySymbol)testClass.GetMembers("SomeProperty").First(); + + // Act + var classHasAttribute = CodeAnalysisExtensions.HasAttribute(testClass, attribute, inherit: false); + var methodHasAttribute = CodeAnalysisExtensions.HasAttribute(testMethod, attribute, inherit: false); + var propertyHasAttribute = CodeAnalysisExtensions.HasAttribute(testProperty, attribute, inherit: false); + + // AssertControllerAttribute + Assert.False(classHasAttribute); + Assert.False(methodHasAttribute); + Assert.False(propertyHasAttribute); + } + + [Fact] + public async Task HasAttribute_ReturnsTrueIfTypeHasAttribute() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.ControllerAttribute"); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(HasAttribute_ReturnsTrueIfTypeHasAttribute)}"); + + // Act + var hasAttribute = CodeAnalysisExtensions.HasAttribute(testClass, attribute, inherit: false); + + // Assert + Assert.True(hasAttribute); + } + + [Fact] + public async Task HasAttribute_ReturnsTrueIfBaseTypeHasAttribute() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.ControllerAttribute"); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(HasAttribute_ReturnsTrueIfBaseTypeHasAttribute)}"); + + // Act + var hasAttributeWithoutInherit = CodeAnalysisExtensions.HasAttribute(testClass, attribute, inherit: false); + var hasAttributeWithInherit = CodeAnalysisExtensions.HasAttribute(testClass, attribute, inherit: true); + + // Assert + Assert.False(hasAttributeWithoutInherit); + Assert.True(hasAttributeWithInherit); + } + + [Fact] + public async Task HasAttribute_ReturnsTrueForInterfaceContractOnAttribute() + { + // Arrange + var compilation = await GetCompilation(); + var @interface = compilation.GetTypeByMetadataName($"{Namespace}.IHasAttribute_ReturnsTrueForInterfaceContractOnAttribute"); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForInterfaceContractOnAttributeTest"); + var derivedClass = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForInterfaceContractOnAttributeDerived"); + + // Act + var hasAttribute = CodeAnalysisExtensions.HasAttribute(testClass, @interface, inherit: true); + var hasAttributeOnDerived = CodeAnalysisExtensions.HasAttribute(testClass, @interface, inherit: true); + + // Assert + Assert.True(hasAttribute); + Assert.True(hasAttributeOnDerived); + } + + [Fact] + public async Task HasAttribute_ReturnsTrueForAttributesOnMethods() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForAttributesOnMethodsAttribute"); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForAttributesOnMethodsTest"); + var method = (IMethodSymbol)testClass.GetMembers("SomeMethod").First(); + + // Act + var hasAttribute = CodeAnalysisExtensions.HasAttribute(method, attribute, inherit: false); + + // Assert + Assert.True(hasAttribute); + } + + [Fact] + public async Task HasAttribute_ReturnsTrueForAttributesOnOverriddenMethods() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForAttributesOnOverriddenMethodsAttribute"); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForAttributesOnOverriddenMethodsTest"); + var method = (IMethodSymbol)testClass.GetMembers("SomeMethod").First(); + + + // Act + var hasAttributeWithoutInherit = CodeAnalysisExtensions.HasAttribute(method, attribute, inherit: false); + var hasAttributeWithInherit = CodeAnalysisExtensions.HasAttribute(method, attribute, inherit: true); + + // Assert + Assert.False(hasAttributeWithoutInherit); + Assert.True(hasAttributeWithInherit); + } + + [Fact] + public async Task HasAttribute_ReturnsTrueForAttributesOnProperties() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForAttributesOnPropertiesAttribute"); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForAttributesOnProperties"); + var property = (IPropertySymbol)testClass.GetMembers("SomeProperty").First(); + + // Act + var hasAttribute = CodeAnalysisExtensions.HasAttribute(property, attribute, inherit: false); + + // Assert + Assert.True(hasAttribute); + } + + [Fact] + public async Task HasAttribute_ReturnsTrueForAttributesOnOverridenProperties() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForAttributesOnOverriddenPropertiesAttribute"); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.HasAttribute_ReturnsTrueForAttributesOnOverriddenProperties"); + var property = (IPropertySymbol)testClass.GetMembers("SomeProperty").First(); + + // Act + var hasAttributeWithoutInherit = CodeAnalysisExtensions.HasAttribute(property, attribute, inherit: false); + var hasAttributeWithInherit = CodeAnalysisExtensions.HasAttribute(property, attribute, inherit: true); + + // Assert + Assert.False(hasAttributeWithoutInherit); + Assert.True(hasAttributeWithInherit); + } + + [Fact] + public async Task IsAssignable_ReturnsFalseForDifferentTypes() + { + // Arrange + var compilation = await GetCompilation(); + var source = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsFalseForDifferentTypesA"); + var target = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsFalseForDifferentTypesB"); + + // Act + var isAssignableFrom = CodeAnalysisExtensions.IsAssignableFrom(source, target); + + // Assert + Assert.False(isAssignableFrom); + } + + [Fact] + public async Task IsAssignable_ReturnsFalseIfTypeDoesNotImplementInterface() + { + // Arrange + var compilation = await GetCompilation(nameof(IsAssignable_ReturnsFalseForDifferentTypes)); + var source = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsFalseForDifferentTypesA"); + var target = compilation.GetTypeByMetadataName($"System.IDisposable"); + + // Act + var isAssignableFrom = CodeAnalysisExtensions.IsAssignableFrom(source, target); + + // Assert + Assert.False(isAssignableFrom); + } + + [Fact] + public async Task IsAssignable_ReturnsTrueIfTypesAreExact() + { + // Arrange + var compilation = await GetCompilation(); + var source = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsTrueIfTypesAreExact"); + var target = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsTrueIfTypesAreExact"); + + // Act + var isAssignableFrom = CodeAnalysisExtensions.IsAssignableFrom(source, target); + + // Assert + Assert.True(isAssignableFrom); + } + + [Fact] + public async Task IsAssignable_ReturnsTrueIfTypeImplementsInterface() + { + // Arrange + var compilation = await GetCompilation(); + var source = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsTrueIfTypeImplementsInterface"); + var target = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsTrueIfTypeImplementsInterfaceTest"); + + // Act + var isAssignableFrom = CodeAnalysisExtensions.IsAssignableFrom(source, target); + var isAssignableFromDerived = CodeAnalysisExtensions.IsAssignableFrom(target, source); + + // Assert + Assert.True(isAssignableFrom); + Assert.False(isAssignableFromDerived); // Inverse shouldn't be true + } + + [Fact] + public async Task IsAssignable_ReturnsTrue_IfSourceAndDestinationAreTheSameInterface() + { + // Arrange + var compilation = await GetCompilation(nameof(IsAssignable_ReturnsTrueIfTypeImplementsInterface)); + var source = compilation.GetTypeByMetadataName(typeof(IsAssignable_ReturnsTrueIfTypeImplementsInterface).FullName); + var target = compilation.GetTypeByMetadataName(typeof(IsAssignable_ReturnsTrueIfTypeImplementsInterface).FullName); + + // Act + var isAssignableFrom = CodeAnalysisExtensions.IsAssignableFrom(source, target); + + // Assert + Assert.True(isAssignableFrom); + } + + [Fact] + public async Task IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterface() + { + // Arrange + var compilation = await GetCompilation(); + var source = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterface"); + var target = compilation.GetTypeByMetadataName($"{Namespace}.IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterfaceTest"); + + // Act + var isAssignableFrom = CodeAnalysisExtensions.IsAssignableFrom(source, target); + var isAssignableFromDerived = CodeAnalysisExtensions.IsAssignableFrom(target, source); + + // Assert + Assert.True(isAssignableFrom); + Assert.False(isAssignableFromDerived); // Inverse shouldn't be true + } + + private Task GetCompilation([CallerMemberName] string testMethod = "") + { + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + return project.GetCompilationAsync(); + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/Infrastructure/MvcDiagnosticAnalyzerRunner.cs b/src/Mvc/test/Mvc.Analyzers.Test/Infrastructure/MvcDiagnosticAnalyzerRunner.cs new file mode 100644 index 0000000000..f29cff84d1 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/Infrastructure/MvcDiagnosticAnalyzerRunner.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 System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Mvc +{ + public class MvcDiagnosticAnalyzerRunner : DiagnosticAnalyzerRunner + { + public MvcDiagnosticAnalyzerRunner(DiagnosticAnalyzer analyzer) + { + Analyzer = analyzer; + } + + public DiagnosticAnalyzer Analyzer { get; } + + public Task GetDiagnosticsAsync(string source) + { + return GetDiagnosticsAsync(sources: new[] { source }, Analyzer, Array.Empty()); + } + + public Task GetDiagnosticsAsync(Project project) + { + return GetDiagnosticsAsync(new[] { project }, Analyzer, Array.Empty()); + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/Infrastructure/MvcTestSource.cs b/src/Mvc/test/Mvc.Analyzers.Test/Infrastructure/MvcTestSource.cs new file mode 100644 index 0000000000..e0e2df6493 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/Infrastructure/MvcTestSource.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 System.IO; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Testing; + +namespace Microsoft.AspNetCore.Mvc +{ + public static class MvcTestSource + { + private static readonly string ProjectDirectory = GetProjectDirectory(); + + public static TestSource Read(string testClassName, string testMethod) + { + var filePath = Path.Combine(ProjectDirectory, "TestFiles", testClassName, testMethod + ".cs"); + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"TestFile {testMethod} could not be found at {filePath}.", filePath); + } + + var fileContent = File.ReadAllText(filePath); + return TestSource.Read(fileContent); + } + + private static string GetProjectDirectory() + { + var solutionDirectory = TestPathUtilities.GetSolutionRootDirectory("Mvc"); + var assemblyName = typeof(MvcTestSource).Assembly.GetName().Name; + var projectDirectory = Path.Combine(solutionDirectory, "test", assemblyName); + return projectDirectory; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Microsoft.AspNetCore.Mvc.Analyzers.Test.csproj b/src/Mvc/test/Mvc.Analyzers.Test/Mvc.Analyzers.Test.csproj similarity index 76% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Microsoft.AspNetCore.Mvc.Analyzers.Test.csproj rename to src/Mvc/test/Mvc.Analyzers.Test/Mvc.Analyzers.Test.csproj index 53db6da506..48a2919472 100644 --- a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/Microsoft.AspNetCore.Mvc.Analyzers.Test.csproj +++ b/src/Mvc/test/Mvc.Analyzers.Test/Mvc.Analyzers.Test.csproj @@ -1,8 +1,9 @@ - + $(StandardTestTfms) true + Microsoft.AspNetCore.Mvc.Analyzers @@ -13,6 +14,7 @@ + diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageHandlerMethod.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageHandlerMethod.cs new file mode 100644 index 0000000000..c070495950 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageHandlerMethod.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class DiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageHandlerMethod : PageModel + { + [/*MM*/AllowAnonymous] + public void OnGet() + { + + } + + public void OnPost() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAttributeIsAppliedToBaseType.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAttributeIsAppliedToBaseType.cs new file mode 100644 index 0000000000..9ca5e1e251 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAttributeIsAppliedToBaseType.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + [PageModel] + public abstract class DiagnosticsAreReturned_IfAttributeIsAppliedToBaseTypeBase + { + [/*MM*/Authorize] + public void OnGet() { } + } + + public class DiagnosticsAreReturned_IfAttributeIsAppliedToBaseType : DiagnosticsAreReturned_IfAttributeIsAppliedToBaseTypeBase + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageHandlerMethod.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageHandlerMethod.cs new file mode 100644 index 0000000000..7bbfe32c07 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageHandlerMethod.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + public class DiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageHandlerMethod : PageModel + { + [/*MM*/Authorize] + public void OnPost() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethod.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethod.cs new file mode 100644 index 0000000000..d91567c8cc --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethod.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + public class DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethod : PageModel + { + [/*MM*/ServiceFilter(typeof(object))] + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodDerivingFromCustomModel.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodDerivingFromCustomModel.cs new file mode 100644 index 0000000000..946d5e42be --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodDerivingFromCustomModel.cs @@ -0,0 +1,20 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + [PageModel] + public abstract class CustomPageModel + { + + } + + public class DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodDerivingFromCustomModel : CustomPageModel + { + [/*MM*/ServiceFilter(typeof(object))] + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodForTypeWithPageModelAttribute.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodForTypeWithPageModelAttribute.cs new file mode 100644 index 0000000000..1a07dfe84a --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodForTypeWithPageModelAttribute.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + [PageModel] + public class DiagnosticsAreReturned_IfFiltersAreAppliedToPageHandlerMethodForTypeWithPageModelAttribute + { + [/*MM*/ServiceFilter(typeof(object))] + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfRouteAttribute_IsAppliedToPageModel.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfRouteAttribute_IsAppliedToPageModel.cs new file mode 100644 index 0000000000..3d865c3269 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfRouteAttribute_IsAppliedToPageModel.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + [/*MM*/Route("/mypage")] + public class DiagnosticsAreReturned_IfRouteAttribute_IsAppliedToPageModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfRouteAttributesAreAppliedToPageHandlerMethod.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfRouteAttributesAreAppliedToPageHandlerMethod.cs new file mode 100644 index 0000000000..889272bc2c --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/DiagnosticsAreReturned_IfRouteAttributesAreAppliedToPageHandlerMethod.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + public class DiagnosticsAreReturned_IfRouteAttributesAreAppliedToPageHandlerMethod : PageModel + { + [/*MM*/HttpHead] + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForControllerActions.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForControllerActions.cs new file mode 100644 index 0000000000..c187cc2ff3 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForControllerActions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + public class NoDiagnosticsAreReturned_ForControllerActions : Controller + { + [Authorize] + public IActionResult AuthorizeAttribute() => null; + + [ServiceFilter(typeof(object))] + public IActionResult ServiceFilter() => null; + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForControllerBaseActions.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForControllerBaseActions.cs new file mode 100644 index 0000000000..ce077e6b9f --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForControllerBaseActions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + public class NoDiagnosticsAreReturned_ForControllerBaseActions : ControllerBase + { + [Authorize] + public IActionResult AuthorizeAttribute() => null; + + [ServiceFilter(typeof(object))] + public IActionResult ServiceFilter() => null; + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForNonHandlerMethodsWithAttributes.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForNonHandlerMethodsWithAttributes.cs new file mode 100644 index 0000000000..b3aa481c2d --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForNonHandlerMethodsWithAttributes.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + public class NoDiagnosticsAreReturned_ForNonHandlerMethodsWithAttributes : PageModel + { + [Authorize] + private void OnGetPrivate() { } + + [TypeFilter(typeof(object))] + internal IActionResult OnPost() => null; + + [AllowAnonymous] + public void OnGet() { } + + [ServiceFilter(typeof(object))] + public static void OnPostStatic() { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForPageHandlersWithNonFilterAttributes.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForPageHandlersWithNonFilterAttributes.cs new file mode 100644 index 0000000000..3e593db2d4 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_ForPageHandlersWithNonFilterAttributes.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + public class NoDiagnosticsAreReturned_ForPageHandlersWithNonFilterAttributes : PageModel + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageModel.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageModel.cs new file mode 100644 index 0000000000..c298c43ff2 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageModel.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + [AllowAnonymous] + public class NoDiagnosticsAreReturned_IfAllowAnonymousIsAppliedToPageModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageModel.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageModel.cs new file mode 100644 index 0000000000..47c778eb9e --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageModel.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + [Authorize] + public class NoDiagnosticsAreReturned_IfAuthorizeAttributeIsAppliedToPageModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfFiltersAreAppliedToPageModel.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfFiltersAreAppliedToPageModel.cs new file mode 100644 index 0000000000..5d0198174c --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AttributesShouldNotBeAppliedToPageModelAnalyzerTest/NoDiagnosticsAreReturned_IfFiltersAreAppliedToPageModel.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers.Test +{ + [ServiceFilter(typeof(object))] + public class NoDiagnosticsAreReturned_IfFiltersAreAppliedToPageModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfHtmlPartial.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfHtmlPartial.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfHtmlPartial.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfHtmlPartial.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfHtmlPartial_InSections.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfHtmlPartial_InSections.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfHtmlPartial_InSections.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfHtmlPartial_InSections.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfHtmlPartial_WithAdditionalParameters.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfHtmlPartial_WithAdditionalParameters.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfHtmlPartial_WithAdditionalParameters.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfHtmlPartial_WithAdditionalParameters.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfRenderPartial.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfRenderPartial.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfRenderPartial.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfRenderPartial.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfRenderPartial_InSections.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfRenderPartial_InSections.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfRenderPartial_InSections.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfRenderPartial_InSections.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfRenderPartial_WithAdditionalParameters.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfRenderPartial_WithAdditionalParameters.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/DiagnosticsAreReturned_ForUseOfRenderPartial_WithAdditionalParameters.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/DiagnosticsAreReturned_ForUseOfRenderPartial_WithAdditionalParameters.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/NoDiagnosticsAreReturned_ForNonUseOfHtmlPartial.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/NoDiagnosticsAreReturned_ForNonUseOfHtmlPartial.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/NoDiagnosticsAreReturned_ForNonUseOfHtmlPartial.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/NoDiagnosticsAreReturned_ForNonUseOfHtmlPartial.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/NoDiagnosticsAreReturned_ForUseOfHtmlPartialAsync.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/NoDiagnosticsAreReturned_ForUseOfHtmlPartialAsync.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/NoDiagnosticsAreReturned_ForUseOfHtmlPartialAsync.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/NoDiagnosticsAreReturned_ForUseOfHtmlPartialAsync.cs diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/NoDiagnosticsAreReturned_ForUseOfRenderPartialAsync.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/NoDiagnosticsAreReturned_ForUseOfRenderPartialAsync.cs similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/TestFiles/NoDiagnosticsAreReturned_ForUseOfRenderPartialAsync.cs rename to src/Mvc/test/Mvc.Analyzers.Test/TestFiles/AvoidHtmlPartialAnalyzerTest/NoDiagnosticsAreReturned_ForUseOfRenderPartialAsync.cs diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_BaseTypeWithAttributes.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_BaseTypeWithAttributes.cs new file mode 100644 index 0000000000..8e0ee1e5dc --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_BaseTypeWithAttributes.cs @@ -0,0 +1,14 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiConventionType(typeof(object))] + [ApiController] + [ApiConventionType(typeof(string))] + public class GetAttributes_BaseTypeWithAttributesBase + { + } + + [ApiConventionType(typeof(int))] + public class GetAttributes_BaseTypeWithAttributesDerived : GetAttributes_BaseTypeWithAttributesBase + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnMethodWithoutAttributes.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnMethodWithoutAttributes.cs new file mode 100644 index 0000000000..9b28469109 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnMethodWithoutAttributes.cs @@ -0,0 +1,7 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class GetAttributes_OnMethodWithoutAttributesClass + { + public void Method() { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithAttributes.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithAttributes.cs new file mode 100644 index 0000000000..a402f5cea9 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithAttributes.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiConventionType(typeof(object))] + [ApiController] + [ApiConventionType(typeof(string))] + public class GetAttributes_OnTypeWithAttributes + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithoutAttributes.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithoutAttributes.cs new file mode 100644 index 0000000000..e4aea55674 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithoutAttributes.cs @@ -0,0 +1,8 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class GetAttributes_OnTypeWithoutAttributesType + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithMethodOverridding.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithMethodOverridding.cs new file mode 100644 index 0000000000..43b2710d58 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithMethodOverridding.cs @@ -0,0 +1,15 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionBase + { + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public virtual void Method() { } + } + + public class GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass : GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionBase + { + [ProducesResponseType(400)] + public override void Method() { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithNewMethod.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithNewMethod.cs new file mode 100644 index 0000000000..3455cc21dd --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithNewMethod.cs @@ -0,0 +1,22 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class GetAttributes_WithNewMethodBase + { + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public virtual void VirtualMethod() { } + + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public virtual void NotVirtualMethod() { } + } + + public class GetAttributes_WithNewMethodDerived : GetAttributes_WithNewMethodBase + { + [ProducesResponseType(400)] + public new void VirtualMethod() { } + + [ProducesResponseType(401)] + public new void NotVirtualMethod() { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithoutMethodOverriding.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithoutMethodOverriding.cs new file mode 100644 index 0000000000..79c2be699b --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_WithoutMethodOverriding.cs @@ -0,0 +1,8 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class GetAttributes_WithoutMethodOverriding + { + [ProducesResponseType(201)] + public void Method() { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsFalseIfSymbolDoesNotHaveAttribute.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsFalseIfSymbolDoesNotHaveAttribute.cs new file mode 100644 index 0000000000..db5c15882f --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsFalseIfSymbolDoesNotHaveAttribute.cs @@ -0,0 +1,19 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class HasAttribute_ReturnsFalseIfTypeDoesNotHaveAttribute : Attribute { } + + [Controller] + public class HasAttribute_ReturnsFalseIfTypeDoesNotHaveAttributeTest + { + [NonAction] + public void SomeMethod() + { + + } + + [BindProperty] + public string SomeProperty { get; set; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnMethods.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnMethods.cs new file mode 100644 index 0000000000..4dd452410e --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnMethods.cs @@ -0,0 +1,12 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class HasAttribute_ReturnsTrueForAttributesOnMethodsAttribute : Attribute { } + + public class HasAttribute_ReturnsTrueForAttributesOnMethodsTest + { + [HasAttribute_ReturnsTrueForAttributesOnMethodsAttribute] + public void SomeMethod() { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnOverriddenMethods.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnOverriddenMethods.cs new file mode 100644 index 0000000000..64ef7b87b9 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnOverriddenMethods.cs @@ -0,0 +1,17 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class HasAttribute_ReturnsTrueForAttributesOnOverriddenMethodsAttribute : Attribute { } + + public class HasAttribute_ReturnsTrueForAttributesOnOverriddenMethodsBase + { + [HasAttribute_ReturnsTrueForAttributesOnOverriddenMethodsAttribute] + public virtual void SomeMethod() { } + } + + public class HasAttribute_ReturnsTrueForAttributesOnOverriddenMethodsTest : HasAttribute_ReturnsTrueForAttributesOnOverriddenMethodsBase + { + public override void SomeMethod() { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnOverridenProperties.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnOverridenProperties.cs new file mode 100644 index 0000000000..872f35fbfb --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnOverridenProperties.cs @@ -0,0 +1,17 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class HasAttribute_ReturnsTrueForAttributesOnOverriddenPropertiesAttribute : Attribute { } + + public class HasAttribute_ReturnsTrueForAttributesOnOverriddenPropertiesBase + { + [HasAttribute_ReturnsTrueForAttributesOnOverriddenPropertiesAttribute] + public virtual string SomeProperty { get; set; } + } + + public class HasAttribute_ReturnsTrueForAttributesOnOverriddenProperties : HasAttribute_ReturnsTrueForAttributesOnOverriddenPropertiesBase + { + public override string SomeProperty { get; set; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnProperties.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnProperties.cs new file mode 100644 index 0000000000..7b36527ddb --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForAttributesOnProperties.cs @@ -0,0 +1,12 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class HasAttribute_ReturnsTrueForAttributesOnPropertiesAttribute : Attribute { } + + public class HasAttribute_ReturnsTrueForAttributesOnProperties + { + [HasAttribute_ReturnsTrueForAttributesOnPropertiesAttribute] + public string SomeProperty { get; set; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForInterfaceContractOnAttribute.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForInterfaceContractOnAttribute.cs new file mode 100644 index 0000000000..25a966df8e --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueForInterfaceContractOnAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public interface IHasAttribute_ReturnsTrueForInterfaceContractOnAttribute { } + + public class HasAttribute_ReturnsTrueForInterfaceContractOnAttribute : Attribute, IHasAttribute_ReturnsTrueForInterfaceContractOnAttribute { } + + [HasAttribute_ReturnsTrueForInterfaceContractOnAttribute] + public class HasAttribute_ReturnsTrueForInterfaceContractOnAttributeTest + { + } + + public class HasAttribute_ReturnsTrueForInterfaceContractOnAttributeDerived : HasAttribute_ReturnsTrueForInterfaceContractOnAttributeTest + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueIfBaseTypeHasAttribute.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueIfBaseTypeHasAttribute.cs new file mode 100644 index 0000000000..39851099c2 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueIfBaseTypeHasAttribute.cs @@ -0,0 +1,7 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [Controller] + public class HasAttribute_ReturnsTrueIfBaseTypeHasAttributeBase { } + + public class HasAttribute_ReturnsTrueIfBaseTypeHasAttribute : HasAttribute_ReturnsTrueIfBaseTypeHasAttributeBase { } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueIfTypeHasAttribute.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueIfTypeHasAttribute.cs new file mode 100644 index 0000000000..31c6cf70cc --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/HasAttribute_ReturnsTrueIfTypeHasAttribute.cs @@ -0,0 +1,5 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [Controller] + public class HasAttribute_ReturnsTrueIfTypeHasAttribute { } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsFalseForDifferentTypes.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsFalseForDifferentTypes.cs new file mode 100644 index 0000000000..ceef09cde4 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsFalseForDifferentTypes.cs @@ -0,0 +1,10 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class IsAssignable_ReturnsFalseForDifferentTypesA + { + } + + public class IsAssignable_ReturnsFalseForDifferentTypesB + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterface.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterface.cs new file mode 100644 index 0000000000..60d8aecb48 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterface.cs @@ -0,0 +1,18 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public interface IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterface + { + } + + public class IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterfaceA : IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterface + { + } + + public class IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterfaceB : IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterfaceA + { + } + + public class IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterfaceTest : IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterfaceB + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypeImplementsInterface.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypeImplementsInterface.cs new file mode 100644 index 0000000000..a60d9fa56d --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypeImplementsInterface.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public interface IsAssignable_ReturnsTrueIfTypeImplementsInterface + { + } + + public class IsAssignable_ReturnsTrueIfTypeImplementsInterfaceTest : IsAssignable_ReturnsTrueIfTypeImplementsInterface { } + +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypeIsBaseClass.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypeIsBaseClass.cs new file mode 100644 index 0000000000..8ed1087a79 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypeIsBaseClass.cs @@ -0,0 +1,10 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class IsAssignable_ReturnsTrueIfTypeIsBaseClassBase + { + } + + public class IsAssignable_ReturnsTrueIfTypeIsBaseClass : IsAssignable_ReturnsTrueIfTypeIsBaseClassBase + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypesAreExact.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypesAreExact.cs new file mode 100644 index 0000000000..e47fa67dcc --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/IsAssignable_ReturnsTrueIfTypesAreExact.cs @@ -0,0 +1,6 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class IsAssignable_ReturnsTrueIfTypesAreExact + { + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_ForControllerActionsWithParametersThatMatchProperties.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_ForControllerActionsWithParametersThatMatchProperties.cs new file mode 100644 index 0000000000..85642db52a --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_ForControllerActionsWithParametersThatMatchProperties.cs @@ -0,0 +1,13 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class DiagnosticsAreReturned_ForControllerActionsWithParametersThatMatchProperties : Controller + { + [HttpPost] + public IActionResult EditPerson(DiagnosticsAreReturned_ForControllerActionsWithParametersThatMatchPropertiesModel /*MM*/model) => null; + } + + public class DiagnosticsAreReturned_ForControllerActionsWithParametersThatMatchPropertiesModel + { + public string Model { get; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_ForModelBoundParameters.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_ForModelBoundParameters.cs new file mode 100644 index 0000000000..be2bc2d7f0 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_ForModelBoundParameters.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class DiagnosticsAreReturned_ForModelBoundParameters : Controller + { + [HttpPost] + public IActionResult EditPerson( + [FromBody] DiagnosticsAreReturned_ForModelBoundParametersModel model, + [FromQuery] DiagnosticsAreReturned_ForModelBoundParametersModel /*MM*/value) => null; + } + + public class DiagnosticsAreReturned_ForModelBoundParametersModel + { + public string Model { get; } + + public string Value { get; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_IfModelNameProviderIsUsedToModifyParameterName.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_IfModelNameProviderIsUsedToModifyParameterName.cs new file mode 100644 index 0000000000..ed15be578a --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/DiagnosticsAreReturned_IfModelNameProviderIsUsedToModifyParameterName.cs @@ -0,0 +1,15 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class DiagnosticsAreReturned_IfModelNameProviderIsUsedToModifyParameterName : Controller + { + [HttpPost] + public IActionResult Edit([ModelBinder(Name = "model")] DiagnosticsAreReturned_IfModelNameProviderIsUsedToModifyParameterNameModel /*MM*/parameter) => null; + } + + public class DiagnosticsAreReturned_IfModelNameProviderIsUsedToModifyParameterNameModel + { + public string Model { get; } + + public string Value { get; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/GetNameTests.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/GetNameTests.cs new file mode 100644 index 0000000000..2810ab1f49 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/GetNameTests.cs @@ -0,0 +1,13 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class GetNameTests + { + public void NoAttribute(int param) { } + + public void SingleAttribute([ModelBinder(Name = "testModelName")] int param) { } + + public void SingleAttributeWithoutName([ModelBinder] int param) { } + + public void MultipleAttributes([ModelBinder(Name = "name1")][Bind(Prefix = "name2")] int param) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresFields.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresFields.cs new file mode 100644 index 0000000000..e0136bc5f3 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresFields.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_IgnoresFields + { + public string model; + + public void ActionMethod(IsProblematicParameter_IgnoresFields model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresMethods.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresMethods.cs new file mode 100644 index 0000000000..b5f79812d6 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresMethods.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_IgnoresMethods + { + public string Item() => null; + + public void ActionMethod(IsProblematicParameter_IgnoresMethods item) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresNonPublicProperties.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresNonPublicProperties.cs new file mode 100644 index 0000000000..fbfb437f54 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresNonPublicProperties.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_IgnoresNonPublicProperties + { + protected string Model { get; set; } + + public void ActionMethod(IsProblematicParameter_IgnoresNonPublicProperties model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresStaticProperties.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresStaticProperties.cs new file mode 100644 index 0000000000..065fca633f --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_IgnoresStaticProperties.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_IgnoresStaticProperties + { + public static string Model { get; set; } + + public void ActionMethod(IsProblematicParameter_IgnoresStaticProperties model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_ForFromBodyParameter.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_ForFromBodyParameter.cs new file mode 100644 index 0000000000..f1b0011291 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_ForFromBodyParameter.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_ReturnsFalse_ForFromBodyParameter + { + public string Model { get; set; } + + public void ActionMethod([FromBody] IsProblematicParameter_ReturnsFalse_ForFromBodyParameter model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_ForParametersWithCustomModelBinder.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_ForParametersWithCustomModelBinder.cs new file mode 100644 index 0000000000..4e86cb1df5 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_ForParametersWithCustomModelBinder.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_ReturnsFalse_ForParametersWithCustomModelBinder + { + public string Model { get; set; } + + public void ActionMethod([ModelBinder(typeof(object))] IsProblematicParameter_ReturnsFalse_ForParametersWithCustomModelBinder model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameParameter.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameParameter.cs new file mode 100644 index 0000000000..20c2a39c3d --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameParameter.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameParameter + { + public string Model { get; set; } + + public void ActionMethod([FromRoute(Name = "id")] IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameParameter model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameProperty.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameProperty.cs new file mode 100644 index 0000000000..902465213f --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameProperty.cs @@ -0,0 +1,10 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameProperty + { + [FromQuery(Name = "different")] + public string Model { get; set; } + + public void ActionMethod(IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameProperty model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfModelBinderAttributeIsUsedToRenameParameter.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfModelBinderAttributeIsUsedToRenameParameter.cs new file mode 100644 index 0000000000..0c28fb1132 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfModelBinderAttributeIsUsedToRenameParameter.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_ReturnsTrue_IfModelBinderAttributeIsUsedToRenameParameter + { + public string Model { get; set; } + + public void ActionMethod([ModelBinder(Name = "model")] IsProblematicParameter_ReturnsTrue_IfModelBinderAttributeIsUsedToRenameParameter different) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfParameterNameIsTheSameAsModelProperty.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfParameterNameIsTheSameAsModelProperty.cs new file mode 100644 index 0000000000..adfcf507d0 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfParameterNameIsTheSameAsModelProperty.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_ReturnsTrue_IfParameterNameIsTheSameAsModelProperty + { + public string Model { get; set; } + + public void ActionMethod(IsProblematicParameter_ReturnsTrue_IfParameterNameIsTheSameAsModelProperty model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfParameterNameWithBinderAttributeIsTheSameNameAsModelProperty.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfParameterNameWithBinderAttributeIsTheSameNameAsModelProperty.cs new file mode 100644 index 0000000000..2b96ecc579 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfParameterNameWithBinderAttributeIsTheSameNameAsModelProperty.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_ReturnsTrue_IfParameterNameWithBinderAttributeIsTheSameNameAsModelProperty + { + public string Model { get; set; } + + public void ActionMethod([Bind(Prefix = "model")] IsProblematicParameter_ReturnsTrue_IfParameterNameWithBinderAttributeIsTheSameNameAsModelProperty different) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs new file mode 100644 index 0000000000..7f3d9c499d --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs @@ -0,0 +1,10 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter + { + [ModelBinder(typeof(object), Name = "model")] + public string Different { get; set; } + + public void ActionMethod(IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedForApiControllers.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedForApiControllers.cs new file mode 100644 index 0000000000..3be2d2a07a --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedForApiControllers.cs @@ -0,0 +1,14 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + [ApiController] + public class NoDiagnosticsAreReturnedForApiControllers : Controller + { + [HttpPost] + public IActionResult EditPerson(NoDiagnosticsAreReturnedForApiControllersModel model) => null; + } + + public class NoDiagnosticsAreReturnedForApiControllersModel + { + public string Model { get; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedForNonActions.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedForNonActions.cs new file mode 100644 index 0000000000..d6e7edce31 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedForNonActions.cs @@ -0,0 +1,13 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class NoDiagnosticsAreReturnedForNonActions : Controller + { + [NonAction] + public IActionResult EditPerson(NoDiagnosticsAreReturnedForNonActionsModel model) => null; + } + + public class NoDiagnosticsAreReturnedForNonActionsModel + { + public string Model { get; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedIfParameterIsRenamedUsingBindingAttribute.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedIfParameterIsRenamedUsingBindingAttribute.cs new file mode 100644 index 0000000000..e870af3704 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/NoDiagnosticsAreReturnedIfParameterIsRenamedUsingBindingAttribute.cs @@ -0,0 +1,13 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class NoDiagnosticsAreReturnedIfParameterIsRenamedUsingBindingAttribute : Controller + { + [HttpPost] + public IActionResult EditPerson([FromForm(Name = "")] NoDiagnosticsAreReturnedIfParameterIsRenamedUsingBindingAttributeModel model) => null; + } + + public class NoDiagnosticsAreReturnedIfParameterIsRenamedUsingBindingAttributeModel + { + public string Model { get; } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/SpecifiesModelTypeTests.cs b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/SpecifiesModelTypeTests.cs new file mode 100644 index 0000000000..e707001d87 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TestFiles/TopLevelParameterNameAnalyzerTest/SpecifiesModelTypeTests.cs @@ -0,0 +1,11 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles +{ + public class SpecifiesModelTypeTests + { + public void SpecifiesModelType_ReturnsFalse_IfModelBinderDoesNotSpecifyType([ModelBinder(Name = "Name")] object model) { } + + public void SpecifiesModelType_ReturnsTrue_IfModelBinderSpecifiesTypeFromConstructor([ModelBinder(typeof(object))] object model) { } + + public void SpecifiesModelType_ReturnsTrue_IfModelBinderSpecifiesTypeFromProperty([ModelBinder(BinderType = typeof(object))] object model) { } + } +} diff --git a/src/Mvc/test/Mvc.Analyzers.Test/TopLevelParameterNameAnalyzerTest.cs b/src/Mvc/test/Mvc.Analyzers.Test/TopLevelParameterNameAnalyzerTest.cs new file mode 100644 index 0000000000..7329ae9d13 --- /dev/null +++ b/src/Mvc/test/Mvc.Analyzers.Test/TopLevelParameterNameAnalyzerTest.cs @@ -0,0 +1,305 @@ +// 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 System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class TopLevelParameterNameAnalyzerTest + { + private MvcDiagnosticAnalyzerRunner Runner { get; } = new MvcDiagnosticAnalyzerRunner(new TopLevelParameterNameAnalyzer()); + + [Fact] + public Task DiagnosticsAreReturned_ForControllerActionsWithParametersThatMatchProperties() + => RunTest(nameof(DiagnosticsAreReturned_ForControllerActionsWithParametersThatMatchPropertiesModel), "model"); + + [Fact] + public Task DiagnosticsAreReturned_ForModelBoundParameters() + => RunTest(nameof(DiagnosticsAreReturned_ForModelBoundParametersModel), "value"); + + [Fact] + public Task DiagnosticsAreReturned_IfModelNameProviderIsUsedToModifyParameterName() + => RunTest(nameof(DiagnosticsAreReturned_IfModelNameProviderIsUsedToModifyParameterNameModel), "parameter"); + + [Fact] + public Task NoDiagnosticsAreReturnedForApiControllers() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturnedIfParameterIsRenamedUsingBindingAttribute() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturnedForNonActions() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public async Task IsProblematicParameter_ReturnsTrue_IfParameterNameIsTheSameAsModelProperty() + { + var result = await IsProblematicParameterTest(); + Assert.True(result); + } + + [Fact] + public async Task IsProblematicParameter_ReturnsTrue_IfParameterNameWithBinderAttributeIsTheSameNameAsModelProperty() + { + var result = await IsProblematicParameterTest(); + Assert.True(result); + } + + [Fact] + public async Task IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter() + { + var result = await IsProblematicParameterTest(); + Assert.True(result); + } + + [Fact] + public async Task IsProblematicParameter_ReturnsTrue_IfModelBinderAttributeIsUsedToRenameParameter() + { + var result = await IsProblematicParameterTest(); + Assert.True(result); + } + + [Fact] + public async Task IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameProperty() + { + var result = await IsProblematicParameterTest(); + Assert.False(result); + } + + [Fact] + public async Task IsProblematicParameter_ReturnsFalse_IfBindingSourceAttributeIsUsedToRenameParameter() + { + var result = await IsProblematicParameterTest(); + Assert.False(result); + } + + [Fact] + public async Task IsProblematicParameter_ReturnsFalse_ForFromBodyParameter() + { + var result = await IsProblematicParameterTest(); + Assert.False(result); + } + + [Fact] + public async Task IsProblematicParameter_ReturnsFalse_ForParametersWithCustomModelBinder() + { + var result = await IsProblematicParameterTest(); + Assert.False(result); + } + + [Fact] + public async Task IsProblematicParameter_IgnoresStaticProperties() + { + var result = await IsProblematicParameterTest(); + Assert.False(result); + } + + [Fact] + public async Task IsProblematicParameter_IgnoresFields() + { + var result = await IsProblematicParameterTest(); + Assert.False(result); + } + + [Fact] + public async Task IsProblematicParameter_IgnoresMethods() + { + var result = await IsProblematicParameterTest(); + Assert.False(result); + } + + [Fact] + public async Task IsProblematicParameter_IgnoresNonPublicProperties() + { + var result = await IsProblematicParameterTest(); + Assert.False(result); + } + + private async Task IsProblematicParameterTest([CallerMemberName] string testMethod = "") + { + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + var compilation = await project.GetCompilationAsync(); + + var modelType = compilation.GetTypeByMetadataName($"Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFiles.{testMethod}"); + var method = (IMethodSymbol)modelType.GetMembers("ActionMethod").First(); + var parameter = method.Parameters[0]; + + var symbolCache = new TopLevelParameterNameAnalyzer.SymbolCache(compilation); + + var result = TopLevelParameterNameAnalyzer.IsProblematicParameter(symbolCache, parameter); + return result; + } + + [Fact] + public async Task GetName_ReturnsValueFromFirstAttributeWithValue() + { + var methodName = nameof(GetNameTests.SingleAttribute); + var compilation = await GetCompilationForGetName(); + var symbolCache = new TopLevelParameterNameAnalyzer.SymbolCache(compilation); + + var type = compilation.GetTypeByMetadataName(typeof(GetNameTests).FullName); + var method = (IMethodSymbol)type.GetMembers(methodName).First(); + + var parameter = method.Parameters[0]; + var name = TopLevelParameterNameAnalyzer.GetName(symbolCache, parameter); + + Assert.Equal("testModelName", name); + } + + [Fact] + public async Task GetName_ReturnsName_IfNoAttributesAreSpecified() + { + var methodName = nameof(GetNameTests.NoAttribute); + var compilation = await GetCompilationForGetName(); + var symbolCache = new TopLevelParameterNameAnalyzer.SymbolCache(compilation); + + var type = compilation.GetTypeByMetadataName(typeof(GetNameTests).FullName); + var method = (IMethodSymbol)type.GetMembers(methodName).First(); + + var parameter = method.Parameters[0]; + var name = TopLevelParameterNameAnalyzer.GetName(symbolCache, parameter); + + Assert.Equal("param", name); + } + + [Fact] + public async Task GetName_ReturnsName_IfAttributeDoesNotSpecifyName() + { + var methodName = nameof(GetNameTests.SingleAttributeWithoutName); + var compilation = await GetCompilationForGetName(); + var symbolCache = new TopLevelParameterNameAnalyzer.SymbolCache(compilation); + + var type = compilation.GetTypeByMetadataName(typeof(GetNameTests).FullName); + var method = (IMethodSymbol)type.GetMembers(methodName).First(); + + var parameter = method.Parameters[0]; + var name = TopLevelParameterNameAnalyzer.GetName(symbolCache, parameter); + + Assert.Equal("param", name); + } + + [Fact] + public async Task GetName_ReturnsFirstName_IfMultipleAttributesAreSpecified() + { + var methodName = nameof(GetNameTests.MultipleAttributes); + var compilation = await GetCompilationForGetName(); + var symbolCache = new TopLevelParameterNameAnalyzer.SymbolCache(compilation); + + var type = compilation.GetTypeByMetadataName(typeof(GetNameTests).FullName); + var method = (IMethodSymbol)type.GetMembers(methodName).First(); + + var parameter = method.Parameters[0]; + var name = TopLevelParameterNameAnalyzer.GetName(symbolCache, parameter); + + Assert.Equal("name1", name); + } + + private async Task GetCompilationForGetName() + { + var testSource = MvcTestSource.Read(GetType().Name, "GetNameTests"); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + var compilation = await project.GetCompilationAsync(); + return compilation; + } + + [Fact] + public async Task SpecifiesModelType_ReturnsFalse_IfModelBinderDoesNotSpecifyType() + { + var testMethod = nameof(SpecifiesModelType_ReturnsFalse_IfModelBinderDoesNotSpecifyType); + var testSource = MvcTestSource.Read(GetType().Name, "SpecifiesModelTypeTests"); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + var compilation = await project.GetCompilationAsync(); + var symbolCache = new TopLevelParameterNameAnalyzer.SymbolCache(compilation); + + var type = compilation.GetTypeByMetadataName(typeof(SpecifiesModelTypeTests).FullName); + var method = (IMethodSymbol)type.GetMembers(testMethod).First(); + + var parameter = method.Parameters[0]; + var result = TopLevelParameterNameAnalyzer.SpecifiesModelType(symbolCache, parameter); + Assert.False(result); + } + + [Fact] + public async Task SpecifiesModelType_ReturnsTrue_IfModelBinderSpecifiesTypeFromConstructor() + { + var testMethod = nameof(SpecifiesModelType_ReturnsTrue_IfModelBinderSpecifiesTypeFromConstructor); + var testSource = MvcTestSource.Read(GetType().Name, "SpecifiesModelTypeTests"); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + var compilation = await project.GetCompilationAsync(); + var symbolCache = new TopLevelParameterNameAnalyzer.SymbolCache(compilation); + + var type = compilation.GetTypeByMetadataName(typeof(SpecifiesModelTypeTests).FullName); + var method = (IMethodSymbol)type.GetMembers(testMethod).First(); + + var parameter = method.Parameters[0]; + var result = TopLevelParameterNameAnalyzer.SpecifiesModelType(symbolCache, parameter); + Assert.True(result); + } + + [Fact] + public async Task SpecifiesModelType_ReturnsTrue_IfModelBinderSpecifiesTypeFromProperty() + { + var testMethod = nameof(SpecifiesModelType_ReturnsTrue_IfModelBinderSpecifiesTypeFromProperty); + var testSource = MvcTestSource.Read(GetType().Name, "SpecifiesModelTypeTests"); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + var compilation = await project.GetCompilationAsync(); + var symbolCache = new TopLevelParameterNameAnalyzer.SymbolCache(compilation); + + var type = compilation.GetTypeByMetadataName(typeof(SpecifiesModelTypeTests).FullName); + var method = (IMethodSymbol)type.GetMembers(testMethod).First(); + + var parameter = method.Parameters[0]; + var result = TopLevelParameterNameAnalyzer.SpecifiesModelType(symbolCache, parameter); + Assert.True(result); + } + + private async Task RunNoDiagnosticsAreReturned([CallerMemberName] string testMethod = "") + { + // Arrange + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Runner.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Empty(result); + } + + private async Task RunTest(string typeName, string parameterName, [CallerMemberName] string testMethod = "") + { + // Arrange + var descriptor = DiagnosticDescriptors.MVC1004_ParameterNameCollidesWithTopLevelProperty; + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Runner.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Collection( + result, + diagnostic => + { + Assert.Equal(descriptor.Id, diagnostic.Id); + Assert.Same(descriptor, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(expectedLocation, diagnostic.Location); + Assert.Equal(string.Format(descriptor.MessageFormat.ToString(), typeName, parameterName), diagnostic.GetMessage()); + }); + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/xunit.runner.json b/src/Mvc/test/Mvc.Analyzers.Test/xunit.runner.json similarity index 100% rename from src/Mvc/test/Microsoft.AspNetCore.Mvc.Analyzers.Test/xunit.runner.json rename to src/Mvc/test/Mvc.Analyzers.Test/xunit.runner.json diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/ActualApiResponseMetadataFactoryTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/ActualApiResponseMetadataFactoryTest.cs new file mode 100644 index 0000000000..bf4a12fd67 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/ActualApiResponseMetadataFactoryTest.cs @@ -0,0 +1,369 @@ +// 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; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ActualApiResponseMetadataFactoryTest; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class ActualApiResponseMetadataFactoryTest + { + private static readonly string Namespace = typeof(ActualApiResponseMetadataFactoryTest).Namespace; + + [Fact] + public async Task GetDefaultStatusCode_ReturnsValueDefinedUsingStatusCodeConstants() + { + // Arrange + var compilation = await GetCompilation("GetDefaultStatusCodeTest"); + var attribute = compilation.GetTypeByMetadataName(typeof(TestActionResultUsingStatusCodesConstants).FullName).GetAttributes()[0]; + + // Act + var actual = ActualApiResponseMetadataFactory.GetDefaultStatusCode(attribute); + + // Assert + Assert.Equal(412, actual); + } + + [Fact] + public async Task GetDefaultStatusCode_ReturnsValueDefinedUsingHttpStatusCast() + { + // Arrange + var compilation = await GetCompilation("GetDefaultStatusCodeTest"); + var attribute = compilation.GetTypeByMetadataName(typeof(TestActionResultUsingHttpStatusCodeCast).FullName).GetAttributes()[0]; + + // Act + var actual = ActualApiResponseMetadataFactory.GetDefaultStatusCode(attribute); + + // Assert + Assert.Equal(302, actual); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsNull_IfReturnExpressionCannotBeFound() + { + // Arrange & Act + var source = @" + using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class TestController : ControllerBase + { + public IActionResult Get(int id) + { + return new DoesNotExist(id); + } + } +}"; + var project = DiagnosticProject.Create(GetType().Assembly, new[] { source }); + var compilation = await project.GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var returnType = compilation.GetTypeByMetadataName($"{Namespace}.TestController"); + var syntaxTree = returnType.DeclaringSyntaxReferences[0].SyntaxTree; + + var method = (IMethodSymbol)returnType.GetMembers().First(); + var methodSyntax = syntaxTree.GetRoot().FindNode(method.Locations[0].SourceSpan); + var returnStatement = methodSyntax.DescendantNodes().OfType().First(); + + var actualResponseMetadata = ActualApiResponseMetadataFactory.InspectReturnStatementSyntax( + symbolCache, + compilation.GetSemanticModel(syntaxTree), + returnStatement, + CancellationToken.None); + + // Assert + Assert.Null(actualResponseMetadata); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsStatusCodeFromDefaultStatusCodeAttributeOnActionResult() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.Equal(401, actualResponseMetadata.Value.StatusCode); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsDefaultResponseMetadata_IfReturnedTypeIsNotActionResult() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.True(actualResponseMetadata.Value.IsDefaultResponse); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsStatusCodeFromStatusCodePropertyAssignment() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.Equal(201, actualResponseMetadata.Value.StatusCode); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsStatusCodeFromConstructorAssignment() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.Equal(204, actualResponseMetadata.Value.StatusCode); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsStatusCodeFromHelperMethod() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.Equal(302, actualResponseMetadata.Value.StatusCode); + } + + [Fact] + public async Task InspectReturnExpression_UsesExplicitlySpecifiedStatusCode_ForActionResultWithDefaultStatusCode() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.Equal(422, actualResponseMetadata.Value.StatusCode); + } + + [Fact] + public async Task InspectReturnExpression_ReadsStatusCodeConstant() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.Equal(423, actualResponseMetadata.Value.StatusCode); + } + + [Fact] + public async Task InspectReturnExpression_DoesNotReadLocalFieldWithConstantValue() + { + // This is a gap in the analyzer. We're using this to document the current behavior and not an expecation. + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.Null(actualResponseMetadata); + } + + [Fact] + public async Task InspectReturnExpression_FallsBackToDefaultStatusCode_WhenAppliedStatusCodeCannotBeRead() + { + // This is a gap in the analyzer. We're using this to document the current behavior and not an expecation. + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.Equal(400, actualResponseMetadata.Value.StatusCode); + } + + [Fact] + public async Task InspectReturnExpression_SetsReturnType_WhenLiteralTypeIsSpecifiedInConstructor() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata?.ReturnType); + Assert.Equal("TestModel", actualResponseMetadata.Value.ReturnType.Name); + } + + [Fact] + public async Task InspectReturnExpression_SetsReturnType_WhenLocalValueIsSpecifiedInConstructor() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata?.ReturnType); + Assert.Equal("TestModel", actualResponseMetadata.Value.ReturnType.Name); + } + + [Fact] + public async Task InspectReturnExpression_SetsReturnType_WhenValueIsReturned() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata?.ReturnType); + Assert.Equal("TestModel", actualResponseMetadata.Value.ReturnType.Name); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsNullReturnType_IfValueIsNotSpecified() + { + // Arrange & Act + var actualResponseMetadata = await RunInspectReturnStatementSyntax(); + + // Assert + Assert.NotNull(actualResponseMetadata); + Assert.Null(actualResponseMetadata.Value.ReturnType); + } + + [Fact] + public async Task TryGetActualResponseMetadata_ActionWithActionResultOfTReturningOkResult() + { + // Arrange + var typeName = typeof(TryGetActualResponseMetadataController).FullName; + var methodName = nameof(TryGetActualResponseMetadataController.ActionWithActionResultOfTReturningOkResult); + + // Act + var (success, responseMetadatas, _) = await TryGetActualResponseMetadata(typeName, methodName); + + // Assert + Assert.True(success); + Assert.Collection( + responseMetadatas, + metadata => + { + Assert.False(metadata.IsDefaultResponse); + Assert.Equal(200, metadata.StatusCode); + }); + } + + [Fact] + public async Task TryGetActualResponseMetadata_ActionWithActionResultOfTReturningModel() + { + // Arrange + var typeName = typeof(TryGetActualResponseMetadataController).FullName; + var methodName = nameof(TryGetActualResponseMetadataController.ActionWithActionResultOfTReturningModel); + + // Act + var (success, responseMetadatas, _) = await TryGetActualResponseMetadata(typeName, methodName); + + // Assert + Assert.True(success); + Assert.Collection( + responseMetadatas, + metadata => + { + Assert.True(metadata.IsDefaultResponse); + }); + } + + [Fact] + public async Task TryGetActualResponseMetadata_ActionReturningNotFoundAndModel() + { + // Arrange + var typeName = typeof(TryGetActualResponseMetadataController).FullName; + var methodName = nameof(TryGetActualResponseMetadataController.ActionReturningNotFoundAndModel); + + // Act + var (success, responseMetadatas, testSource) = await TryGetActualResponseMetadata(typeName, methodName); + + // Assert + Assert.True(success); + Assert.Collection( + responseMetadatas, + metadata => + { + Assert.False(metadata.IsDefaultResponse); + Assert.Equal(204, metadata.StatusCode); + AnalyzerAssert.DiagnosticLocation(testSource.MarkerLocations["MM1"], metadata.ReturnStatement.GetLocation()); + + }, + metadata => + { + Assert.True(metadata.IsDefaultResponse); + AnalyzerAssert.DiagnosticLocation(testSource.MarkerLocations["MM2"], metadata.ReturnStatement.GetLocation()); + }); + } + + private async Task<(bool result, IList responseMetadatas, TestSource testSource)> TryGetActualResponseMetadata(string typeName, string methodName) + { + var testSource = MvcTestSource.Read(GetType().Name, "TryGetActualResponseMetadataTests"); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + var compilation = await GetCompilation("TryGetActualResponseMetadataTests"); + + var type = compilation.GetTypeByMetadataName(typeName); + var method = (IMethodSymbol)type.GetMembers(methodName).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var syntaxTree = method.DeclaringSyntaxReferences[0].SyntaxTree; + var methodSyntax = (MethodDeclarationSyntax)syntaxTree.GetRoot().FindNode(method.Locations[0].SourceSpan); + var semanticModel = compilation.GetSemanticModel(syntaxTree); + + var result = ActualApiResponseMetadataFactory.TryGetActualResponseMetadata(symbolCache, semanticModel, methodSyntax, CancellationToken.None, out var responseMetadatas); + + return (result, responseMetadatas, testSource); + } + + private async Task RunInspectReturnStatementSyntax([CallerMemberName]string test = null) + { + // Arrange + var compilation = await GetCompilation("InspectReturnExpressionTests"); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var controllerType = compilation.GetTypeByMetadataName(typeof(TestFiles.InspectReturnExpressionTests.TestController).FullName); + var syntaxTree = controllerType.DeclaringSyntaxReferences[0].SyntaxTree; + + var method = (IMethodSymbol)Assert.Single(controllerType.GetMembers(test)); + var methodSyntax = syntaxTree.GetRoot().FindNode(method.Locations[0].SourceSpan); + var returnStatement = methodSyntax.DescendantNodes().OfType().First(); + + return ActualApiResponseMetadataFactory.InspectReturnStatementSyntax( + symbolCache, + compilation.GetSemanticModel(syntaxTree), + returnStatement, + CancellationToken.None); + } + + private async Task RunInspectReturnStatementSyntax(string source, string test) + { + var project = DiagnosticProject.Create(GetType().Assembly, new[] { source }); + var compilation = await project.GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var returnType = compilation.GetTypeByMetadataName($"{Namespace}.{test}"); + var syntaxTree = returnType.DeclaringSyntaxReferences[0].SyntaxTree; + + var method = (IMethodSymbol)returnType.GetMembers().First(); + var methodSyntax = syntaxTree.GetRoot().FindNode(method.Locations[0].SourceSpan); + var returnStatement = methodSyntax.DescendantNodes().OfType().First(); + + return ActualApiResponseMetadataFactory.InspectReturnStatementSyntax( + symbolCache, + compilation.GetSemanticModel(syntaxTree), + returnStatement, + CancellationToken.None); + } + + private Task GetCompilation(string test) + { + var testSource = MvcTestSource.Read(GetType().Name, test); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + return project.GetCompilationAsync(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/AddResponseTypeAttributeCodeFixProviderIntegrationTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/AddResponseTypeAttributeCodeFixProviderIntegrationTest.cs new file mode 100644 index 0000000000..f0cfbff1e5 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/AddResponseTypeAttributeCodeFixProviderIntegrationTest.cs @@ -0,0 +1,86 @@ +// 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.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class AddResponseTypeAttributeCodeFixProviderIntegrationTest + { + private MvcDiagnosticAnalyzerRunner AnalyzerRunner { get; } = new MvcDiagnosticAnalyzerRunner(new ApiConventionAnalyzer()); + + private CodeFixRunner CodeFixRunner => CodeFixRunner.Default; + + [Fact] + public Task CodeFixAddsStatusCodes() => RunTest(); + + [Fact] + public Task CodeFixAddsMissingStatusCodes() => RunTest(); + + [Fact] + public Task CodeFixAddsMissingStatusCodesAndTypes() => RunTest(); + + [Fact] + public Task CodeFixWithConventionAddsMissingStatusCodes() => RunTest(); + + [Fact] + public Task CodeFixWithConventionMethodAddsMissingStatusCodes() => RunTest(); + + [Fact] + public Task CodeFixAddsSuccessStatusCode() => RunTest(); + + [Fact] + public Task CodeFixAddsFullyQualifiedProducesResponseType() => RunTest(); + + [Fact] + public Task CodeFixAddsNumericLiteralForNonExistingStatusCodeConstants() => RunTest(); + + [Fact] + public Task CodeFixAddsResponseTypeWhenDifferentFromErrorType() => RunTest(); + + [Fact] + public Task CodeFixAddsStatusCodesFromMethodParameters() => RunTest(); + + [Fact] + public Task CodeFixAddsStatusCodesFromConstructorParameters() => RunTest(); + + [Fact] + public Task CodeFixAddsStatusCodesFromObjectInitializer() => RunTest(); + + private async Task RunTest([CallerMemberName] string testMethod = "") + { + // Arrange + var project = GetProject(testMethod); + var controllerDocument = project.DocumentIds[0]; + + var expectedOutput = Read(testMethod + ".Output"); + + // Act + var diagnostics = await AnalyzerRunner.GetDiagnosticsAsync(project); + var actualOutput = await CodeFixRunner.ApplyCodeFixAsync( + new AddResponseTypeAttributeCodeFixProvider(), + project.GetDocument(controllerDocument), + diagnostics[0]); + + Assert.Equal(expectedOutput, actualOutput, ignoreLineEndingDifferences: true); + } + + private Project GetProject(string testMethod) + { + var testSource = Read(testMethod + ".Input"); + return DiagnosticProject.Create(GetType().Assembly, new[] { testSource }); + } + + private string Read(string fileName) + { + return MvcTestSource.Read(GetType().Name, fileName) + .Source + .Replace("_INPUT_", "_TEST_") + .Replace("_OUTPUT_", "_TEST_"); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest.cs new file mode 100644 index 0000000000..9b3e546014 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest.cs @@ -0,0 +1,85 @@ +// 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.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest + { + private MvcDiagnosticAnalyzerRunner AnalyzerRunner { get; } = new MvcDiagnosticAnalyzerRunner(new ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer()); + + [Fact] + public Task NoDiagnosticsAreReturned_ForNonApiController() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForRazorPageModels() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForApiActionsWithoutModelStateChecks() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForApiActionsReturning400FromNonModelStateIsValidBlocks() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForApiActionsReturningNot400FromNonModelStateIsValidBlock() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForApiActionsCheckingAdditionalConditions() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task DiagnosticsAreReturned_ForApiActionsWithModelStateChecks() + => RunTest(); + + [Fact] + public Task DiagnosticsAreReturned_ForApiActionsWithModelStateChecksUsingEquality() + => RunTest(); + + [Fact] + public Task DiagnosticsAreReturned_ForApiActionsWithModelStateChecksWithoutBracing() + => RunTest(); + + private async Task RunNoDiagnosticsAreReturned([CallerMemberName] string testMethod = "") + { + // Arrange + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await AnalyzerRunner.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Empty(result); + } + + private async Task RunTest([CallerMemberName] string testMethod = "") + { + // Arrange + var descriptor = ApiDiagnosticDescriptors.API1003_ApiActionsDoNotRequireExplicitModelValidationCheck; + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await AnalyzerRunner.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Collection( + result, + diagnostic => + { + Assert.Equal(descriptor.Id, diagnostic.Id); + Assert.Same(descriptor, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(expectedLocation, diagnostic.Location); + }); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest.cs new file mode 100644 index 0000000000..918a04940a --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest.cs @@ -0,0 +1,62 @@ +// 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.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest + { + private MvcDiagnosticAnalyzerRunner AnalyzerRunner { get; } = new MvcDiagnosticAnalyzerRunner(new ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzer()); + + private CodeFixRunner CodeFixRunner => CodeFixRunner.Default; + + [Fact] + public Task CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck() + => RunTest(); + + [Fact] + public Task CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck() + => RunTest(); + + [Fact] + public Task CodeFixRemovesIfBlockWithoutBraces() + => RunTest(); + + private async Task RunTest([CallerMemberName] string testMethod = "") + { + // Arrange + var project = GetProject(testMethod); + var controllerDocument = project.DocumentIds[0]; + var expectedOutput = Read(testMethod + ".Output"); + + // Act + var diagnostics = await AnalyzerRunner.GetDiagnosticsAsync(project); + Assert.NotEmpty(diagnostics); + var actualOutput = await CodeFixRunner.ApplyCodeFixAsync( + new ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProvider(), + project.GetDocument(controllerDocument), + diagnostics[0]); + + Assert.Equal(expectedOutput, actualOutput, ignoreLineEndingDifferences: true); + } + + private Project GetProject(string testMethod) + { + var testSource = Read(testMethod + ".Input"); + return DiagnosticProject.Create(GetType().Assembly, new[] { testSource }); + } + + private string Read(string fileName) + { + return MvcTestSource.Read(GetType().Name, fileName) + .Source + .Replace("_INPUT_", "_TEST_") + .Replace("_OUTPUT_", "_TEST_"); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiControllerFactsTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiControllerFactsTest.cs new file mode 100644 index 0000000000..e0875d0f93 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiControllerFactsTest.cs @@ -0,0 +1,138 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiControllerFactsTest; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class ApiControllerFactsTest + { + [Fact] + public async Task IsApiControllerAction_ReturnsFalse_IfMethodReturnTypeIsInvalid() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Mvc; + +namespace TestNamespace +{ + [ApiController] + public class TestController : ControllerBase + { + public DoesNotExist Get(int id) + { + if (id == 0) + { + return NotFound(); + } + + return new DoesNotExist(id); + } + } +}"; + var project = DiagnosticProject.Create(GetType().Assembly, new[] { source }); + var compilation = await project.GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var method = (IMethodSymbol)compilation.GetTypeByMetadataName("TestNamespace.TestController").GetMembers("Get").First(); + + // Act + var result = ApiControllerFacts.IsApiControllerAction(symbolCache, method); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsApiControllerAction_ReturnsFalse_IfContainingTypeIsNotController() + { + // Arrange + var compilation = await GetCompilation(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerTest_IndexModel).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(ApiConventionAnalyzerTest_IndexModel.OnGet)).First(); + + // Act + var result = ApiControllerFacts.IsApiControllerAction(symbolCache, method); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsApiControllerAction_ReturnsFalse_IfContainingTypeIsNotApiController() + { + // Arrange + var compilation = await GetCompilation(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerTest_NotApiController).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(ApiConventionAnalyzerTest_NotApiController.Index)).First(); + + // Act + var result = ApiControllerFacts.IsApiControllerAction(symbolCache, method); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsApiControllerAction_ReturnsFalse_IfContainingTypeIsNotAction() + { + // Arrange + var compilation = await GetCompilation(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerTest_NotAction).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(ApiConventionAnalyzerTest_NotAction.Index)).First(); + + // Act + var result = ApiControllerFacts.IsApiControllerAction(symbolCache, method); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsApiControllerAction_ReturnsTrue_ForValidActionMethods() + { + // Arrange + var compilation = await GetCompilation(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerTest_Valid).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(ApiConventionAnalyzerTest_Valid.Index)).First(); + + // Act + var result = ApiControllerFacts.IsApiControllerAction(symbolCache, method); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsApiControllerAction_ReturnsTrue_IfAttributeIsDeclaredOnAssembly() + { + // Arrange + var compilation = await GetCompilation(nameof(IsApiControllerAction_ReturnsTrue_IfAttributeIsDeclaredOnAssembly)); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(IsApiControllerAction_ReturnsTrue_IfAttributeIsDeclaredOnAssemblyController).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(IsApiControllerAction_ReturnsTrue_IfAttributeIsDeclaredOnAssemblyController.Action)).First(); + + // Act + var result = ApiControllerFacts.IsApiControllerAction(symbolCache, method); + + // Assert + Assert.True(result); + } + + private Task GetCompilation(string testFile = "TestFile") + { + var testSource = MvcTestSource.Read(GetType().Name, testFile); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + return project.GetCompilationAsync(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs new file mode 100644 index 0000000000..77c5b0d751 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs @@ -0,0 +1,221 @@ +// 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.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class ApiConventionAnalyzerIntegrationTest + { + private MvcDiagnosticAnalyzerRunner Executor { get; } = new ApiConventionWith1006DiagnosticEnabledRunner(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForNonApiController() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForRazorPageModels() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForOkResultReturningAction() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForReturnStatementsInLambdas() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public async Task DiagnosticsAreReturned_ForIncompleteActionResults() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Mvc; + +namespace Test +{ + [ApiController] + [Route(""[controller]/[action]"") + public class TestController : ControllerBase + { + public IActionResult Get(int id) + { + if (id == 0) + { + /*MM*/return NotFound(); + } + + return; + } + } +}"; + var testSource = TestSource.Read(source); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Executor.GetDiagnosticsAsync(testSource.Source); + + // Assert + var diagnostic = Assert.Single(result, d => d.Id == ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode.Id); + AnalyzerAssert.DiagnosticLocation(expectedLocation, diagnostic.Location); + } + + [Fact] + public async Task NoDiagnosticsAreReturned_WhenActionDoesNotCompile() + { + // Arrange + var source = @" +namespace Test +{ + [ApiController] + [Route(""[controller]/[action]"") + public class TestController : ControllerBase + { + public IActionResult Get(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Ok(); + } + } +}"; + + // Act + var result = await Executor.GetDiagnosticsAsync(source); + + // Assert + Assert.DoesNotContain(result, d => d.Id == ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode.Id); + } + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode() + => RunTest(ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, 404); + + [Fact] + public Task DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode() + => RunTest(ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, 404); + + [Fact] + public Task DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode() + => RunTest(ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, 200); + + [Fact] + public Task DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes() + => RunTest(ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, 404); + + [Fact] + public Task DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes() + => RunTest(ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, 422); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode() + => RunTest(ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, 400); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode() + => RunTest(ApiDiagnosticDescriptors.API1000_ActionReturnsUndocumentedStatusCode, 202); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation() + => RunTest(ApiDiagnosticDescriptors.API1001_ActionReturnsUndocumentedSuccessResult); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation() + => RunTest(ApiDiagnosticDescriptors.API1001_ActionReturnsUndocumentedSuccessResult); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType() + => RunTest(ApiDiagnosticDescriptors.API1001_ActionReturnsUndocumentedSuccessResult); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode() + => RunTest(ApiDiagnosticDescriptors.API1002_ActionDoesNotReturnDocumentedStatusCode, 400); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode() + => RunTest(ApiDiagnosticDescriptors.API1002_ActionDoesNotReturnDocumentedStatusCode, 404); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotDocumentSuccessStatusCode() + => RunTest(ApiDiagnosticDescriptors.API1002_ActionDoesNotReturnDocumentedStatusCode, 200); + + private async Task RunNoDiagnosticsAreReturned([CallerMemberName] string testMethod = "") + { + // Arrange + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Executor.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Empty(result); + } + + private Task RunTest(DiagnosticDescriptor descriptor, [CallerMemberName] string testMethod = "") + => RunTest(descriptor, Array.Empty(), testMethod); + + private Task RunTest(DiagnosticDescriptor descriptor, int statusCode, [CallerMemberName] string testMethod = "") + => RunTest(descriptor, new[] { statusCode.ToString() }, testMethod); + + private async Task RunTest(DiagnosticDescriptor descriptor, object[] args, [CallerMemberName] string testMethod = "") + { + // Arrange + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Executor.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Collection( + result, + diagnostic => + { + Assert.Equal(descriptor.Id, diagnostic.Id); + Assert.Same(descriptor, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(expectedLocation, diagnostic.Location); + Assert.Equal(string.Format(descriptor.MessageFormat.ToString(), args), diagnostic.GetMessage()); + }); + } + private class ApiConventionWith1006DiagnosticEnabledRunner : MvcDiagnosticAnalyzerRunner + { + public ApiConventionWith1006DiagnosticEnabledRunner() : base(new ApiConventionAnalyzer()) + { + } + + protected override CompilationOptions ConfigureCompilationOptions(CompilationOptions options) + { + var compilationOptions = base.ConfigureCompilationOptions(options); + + // 10006 is disabled by default. Explicitly enable it so we can correctly validate no diagnostics + // are returned scenarios. + var specificDiagnosticOptions = compilationOptions.SpecificDiagnosticOptions.Add( + ApiDiagnosticDescriptors.API1002_ActionDoesNotReturnDocumentedStatusCode.Id, + ReportDiagnostic.Info); + + return compilationOptions.WithSpecificDiagnosticOptions(specificDiagnosticOptions); + } + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/DeclaredApiResponseMetadataTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/DeclaredApiResponseMetadataTest.cs new file mode 100644 index 0000000000..eee4a862f3 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/DeclaredApiResponseMetadataTest.cs @@ -0,0 +1,169 @@ +// 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.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class DeclaredApiResponseMetadataTest + { + private readonly ReturnStatementSyntax ReturnStatement = SyntaxFactory.ReturnStatement(); + private readonly AttributeData AttributeData = new TestAttributeData(); + + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataIsImplicit_AndActualMetadataIsDefaultResponse() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ImplicitResponse; + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataIsImplicit_AndActualMetadataReturns200() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ImplicitResponse; + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, 200, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataIs200_AndActualMetadataIsDefaultResponse() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesResponseType(200, AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + /// + /// [ProducesResponseType(201)] + /// public IActionResult SomeAction => new Model(); + /// + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataIs201_AndActualMetadataIsDefault() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesResponseType(201, AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + /// + /// [ProducesResponseType(201)] + /// public IActionResult SomeAction => Ok(new Model()); + /// + [Fact] + public void Matches_ReturnsFalse_IfDeclaredMetadataIs201_AndActualMetadataIs200() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesResponseType(201, AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, 200, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.False(matches); + } + + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataAndActualMetadataHaveSameStatusCode() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesResponseType(302, AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, 302, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + [Theory] + [InlineData(400)] + [InlineData(409)] + [InlineData(500)] + public void Matches_ReturnsTrue_IfDeclaredMetadataIsDefault_AndActualMetadataIsErrorStatusCode(int actualStatusCode) + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesDefaultResponse(AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, actualStatusCode, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + [Fact] + public void Matches_ReturnsFalse_IfDeclaredMetadataIsDefault_AndActualMetadataIsNotErrorStatusCode() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesDefaultResponse(AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, 204, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.False(matches); + } + + [Fact] + public void Matches_ReturnsFalse_IfDeclaredMetadataIsDefault_AndActualMetadataIsDefaultResponse() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesDefaultResponse(AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, null); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.False(matches); + } + + private class TestAttributeData : AttributeData + { + protected override INamedTypeSymbol CommonAttributeClass => throw new System.NotImplementedException(); + + protected override IMethodSymbol CommonAttributeConstructor => throw new System.NotImplementedException(); + + protected override SyntaxReference CommonApplicationSyntaxReference => throw new System.NotImplementedException(); + + protected override ImmutableArray CommonConstructorArguments => throw new System.NotImplementedException(); + + protected override ImmutableArray> CommonNamedArguments => throw new System.NotImplementedException(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/Mvc.Api.Analyzers.Test.csproj b/src/Mvc/test/Mvc.Api.Analyzers.Test/Mvc.Api.Analyzers.Test.csproj new file mode 100644 index 0000000000..8e9ee75ed7 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/Mvc.Api.Analyzers.Test.csproj @@ -0,0 +1,22 @@ + + + + $(StandardTestTfms) + Microsoft.AspNetCore.Mvc.Api.Analyzers + + + + + + + + + + + + + + + + + diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/MvcFactsTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/MvcFactsTest.cs new file mode 100644 index 0000000000..0544262951 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/MvcFactsTest.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.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Analyzers; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class MvcFactsTest + { + private static readonly string ControllerAttribute = typeof(ControllerAttribute).FullName; + private static readonly string NonControllerAttribute = typeof(NonControllerAttribute).FullName; + private static readonly string NonActionAttribute = typeof(NonActionAttribute).FullName; + private static readonly Type TestIsControllerActionType = typeof(TestIsControllerAction); + + #region IsController + [Fact] + public Task IsController_ReturnsFalseForInterfaces() => IsControllerReturnsFalse(typeof(ITestController)); + + [Fact] + public Task IsController_ReturnsFalseForAbstractTypes() => IsControllerReturnsFalse(typeof(AbstractController)); + + [Fact] + public Task IsController_ReturnsFalseForValueType() => IsControllerReturnsFalse(typeof(ValueTypeController)); + + [Fact] + public Task IsController_ReturnsFalseForGenericType() => IsControllerReturnsFalse(typeof(OpenGenericController<>)); + + [Fact] + public Task IsController_ReturnsFalseForPocoType() => IsControllerReturnsFalse(typeof(PocoType)); + + [Fact] + public Task IsController_ReturnsFalseForTypeDerivedFromPocoType() => IsControllerReturnsFalse(typeof(DerivedPocoType)); + + [Fact] + public Task IsController_ReturnsTrueForTypeDerivingFromController() => IsControllerReturnsTrue(typeof(TypeDerivingFromController)); + + [Fact] + public Task IsController_ReturnsTrueForTypeDerivingFromControllerBase() => IsControllerReturnsTrue(typeof(TypeDerivingFromControllerBase)); + + [Fact] + public Task IsController_ReturnsTrueForTypeDerivingFromController_WithoutSuffix() => IsControllerReturnsTrue(typeof(NoSuffix)); + + [Fact] + public Task IsController_ReturnsTrueForTypeWithSuffix_ThatIsNotDerivedFromController() => IsControllerReturnsTrue(typeof(PocoController)); + + [Fact] + public Task IsController_ReturnsTrueForTypeWithoutSuffix_WithControllerAttribute() => IsControllerReturnsTrue(typeof(CustomBase)); + + [Fact] + public Task IsController_ReturnsTrueForTypeDerivingFromCustomBaseThatHasControllerAttribute() => IsControllerReturnsTrue(typeof(ChildOfCustomBase)); + + [Fact] + public Task IsController_ReturnsFalseForTypeWithNonControllerAttribute() => IsControllerReturnsFalse(typeof(BaseNonController)); + + [Fact] + public Task IsController_ReturnsFalseForTypesDerivingFromTypeWithNonControllerAttribute() => IsControllerReturnsFalse(typeof(BasePocoNonControllerChildController)); + + [Fact] + public Task IsController_ReturnsFalseForTypesDerivingFromTypeWithNonControllerAttributeWithControllerAttribute() => + IsControllerReturnsFalse(typeof(ControllerAttributeDerivingFromNonController)); + + private async Task IsControllerReturnsFalse(Type type) + { + var compilation = await GetIsControllerCompilation(); + var controllerAttribute = compilation.GetTypeByMetadataName(ControllerAttribute); + var nonControllerAttribute = compilation.GetTypeByMetadataName(NonControllerAttribute); + var typeSymbol = compilation.GetTypeByMetadataName(type.FullName); + + // Act + var isController = MvcFacts.IsController(typeSymbol, controllerAttribute, nonControllerAttribute); + + // Assert + Assert.False(isController); + } + + private async Task IsControllerReturnsTrue(Type type) + { + var compilation = await GetIsControllerCompilation(); + var controllerAttribute = compilation.GetTypeByMetadataName(ControllerAttribute); + var nonControllerAttribute = compilation.GetTypeByMetadataName(NonControllerAttribute); + var typeSymbol = compilation.GetTypeByMetadataName(type.FullName); + + // Act + var isController = MvcFacts.IsController(typeSymbol, controllerAttribute, nonControllerAttribute); + + // Assert + Assert.True(isController); + } + + #endregion + + #region IsControllerAction + [Fact] + public Task IsAction_ReturnsFalseForConstructor() => IsActionReturnsFalse(TestIsControllerActionType, ".ctor"); + + [Fact] + public Task IsAction_ReturnsFalseForStaticConstructor() => IsActionReturnsFalse(TestIsControllerActionType, ".cctor"); + + [Fact] + public Task IsAction_ReturnsFalseForPrivateMethod() => IsActionReturnsFalse(TestIsControllerActionType, "PrivateMethod"); + + [Fact] + public Task IsAction_ReturnsFalseForProtectedMethod() => IsActionReturnsFalse(TestIsControllerActionType, "ProtectedMethod"); + + [Fact] + public Task IsAction_ReturnsFalseForInternalMethod() => IsActionReturnsFalse(TestIsControllerActionType, nameof(TestIsControllerAction.InternalMethod)); + + [Fact] + public Task IsAction_ReturnsFalseForGenericMethod() => IsActionReturnsFalse(TestIsControllerActionType, nameof(TestIsControllerAction.GenericMethod)); + + [Fact] + public Task IsAction_ReturnsFalseForStaticMethod() => IsActionReturnsFalse(TestIsControllerActionType, nameof(TestIsControllerAction.StaticMethod)); + + [Fact] + public Task IsAction_ReturnsFalseForNonActionMethod() => IsActionReturnsFalse(TestIsControllerActionType, nameof(TestIsControllerAction.NonAction)); + + [Fact] + public Task IsAction_ReturnsFalseForOverriddenNonActionMethod() => IsActionReturnsFalse(TestIsControllerActionType, nameof(TestIsControllerAction.NonActionBase)); + + [Fact] + public Task IsAction_ReturnsFalseForDisposableDispose() => IsActionReturnsFalse(TestIsControllerActionType, nameof(TestIsControllerAction.Dispose)); + + [Fact] + public Task IsAction_ReturnsFalseForExplicitDisposableDispose() => IsActionReturnsFalse(typeof(ExplicitIDisposable), "System.IDisposable.Dispose"); + + [Fact] + public Task IsAction_ReturnsFalseForAbstractMethods() => IsActionReturnsFalse(typeof(TestIsControllerActionBase), nameof(TestIsControllerActionBase.AbstractMethod)); + + [Fact] + public Task IsAction_ReturnsFalseForObjectEquals() => IsActionReturnsFalse(typeof(object), nameof(object.Equals)); + + [Fact] + public Task IsAction_ReturnsFalseForObjectHashCode() => IsActionReturnsFalse(typeof(object), nameof(object.GetHashCode)); + + [Fact] + public Task IsAction_ReturnsFalseForObjectToString() => IsActionReturnsFalse(typeof(object), nameof(object.ToString)); + + [Fact] + public Task IsAction_ReturnsFalseForOverriddenObjectEquals() => + IsActionReturnsFalse(typeof(OverridesObjectMethods), nameof(OverridesObjectMethods.Equals)); + + [Fact] + public Task IsAction_ReturnsFalseForOverriddenObjectHashCode() => + IsActionReturnsFalse(typeof(OverridesObjectMethods), nameof(OverridesObjectMethods.GetHashCode)); + + private async Task IsActionReturnsFalse(Type type, string methodName) + { + var compilation = await GetIsControllerActionCompilation(); + var nonActionAttribute = compilation.GetTypeByMetadataName(NonActionAttribute); + var disposableDispose = GetDisposableDispose(compilation); + var typeSymbol = compilation.GetTypeByMetadataName(type.FullName); + var method = (IMethodSymbol)typeSymbol.GetMembers(methodName).First(); + + // Act + var isControllerAction = MvcFacts.IsControllerAction(method, nonActionAttribute, disposableDispose); + + // Assert + Assert.False(isControllerAction); + } + + [Fact] + public Task IsAction_ReturnsTrueForNewMethodsOfObject() => IsActionReturnsTrue(typeof(OverridesObjectMethods), nameof(OverridesObjectMethods.ToString)); + + [Fact] + public Task IsAction_ReturnsTrueForNotDisposableDispose() => IsActionReturnsTrue(typeof(NotDisposable), nameof(NotDisposable.Dispose)); + + [Fact] + public Task IsAction_ReturnsTrueForNotDisposableDisposeOnTypeWithExplicitImplementation() => + IsActionReturnsTrue(typeof(NotDisposableWithExplicitImplementation), nameof(NotDisposableWithExplicitImplementation.Dispose)); + + [Fact] + public Task IsAction_ReturnsTrueForOrdinaryAction() => IsActionReturnsTrue(TestIsControllerActionType, nameof(TestIsControllerAction.Ordinary)); + + [Fact] + public Task IsAction_ReturnsTrueForOverriddenMethod() => IsActionReturnsTrue(TestIsControllerActionType, nameof(TestIsControllerAction.AbstractMethod)); + + [Fact] + public async Task IsAction_ReturnsTrueForNotDisposableDisposeOnTypeWithImplicitImplementation() + { + var compilation = await GetIsControllerActionCompilation(); + var nonActionAttribute = compilation.GetTypeByMetadataName(NonActionAttribute); + var disposableDispose = GetDisposableDispose(compilation); + var typeSymbol = compilation.GetTypeByMetadataName(typeof(NotDisposableWithDisposeThatIsNotInterfaceContract).FullName); + var method = typeSymbol.GetMembers(nameof(IDisposable.Dispose)).OfType().First(f => !f.ReturnsVoid); + + // Act + var isControllerAction = MvcFacts.IsControllerAction(method, nonActionAttribute, disposableDispose); + + // Assert + Assert.True(isControllerAction); + } + + private async Task IsActionReturnsTrue(Type type, string methodName) + { + var compilation = await GetIsControllerActionCompilation(); + var nonActionAttribute = compilation.GetTypeByMetadataName(NonActionAttribute); + var disposableDispose = GetDisposableDispose(compilation); + var typeSymbol = compilation.GetTypeByMetadataName(type.FullName); + var method = (IMethodSymbol)typeSymbol.GetMembers(methodName).First(); + + // Act + var isControllerAction = MvcFacts.IsControllerAction(method, nonActionAttribute, disposableDispose); + + // Assert + Assert.True(isControllerAction); + } + + private IMethodSymbol GetDisposableDispose(Compilation compilation) + { + var type = compilation.GetSpecialType(SpecialType.System_IDisposable); + return (IMethodSymbol)type.GetMembers(nameof(IDisposable.Dispose)).First(); + } + #endregion + + + private Task GetIsControllerCompilation() => GetCompilation("IsControllerTests"); + + private Task GetIsControllerActionCompilation() => GetCompilation("IsControllerActionTests"); + + private Task GetCompilation(string test) + { + var testSource = MvcTestSource.Read(GetType().Name, test); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + return project.GetCompilationAsync(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/SymbolApiConventionMatcherTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/SymbolApiConventionMatcherTest.cs new file mode 100644 index 0000000000..864a6b3791 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/SymbolApiConventionMatcherTest.cs @@ -0,0 +1,567 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Xunit; +using static Microsoft.AspNetCore.Mvc.Api.Analyzers.SymbolApiConventionMatcher; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class SymbolApiConventionMatcherTest + { + private static readonly string BaseTypeName = typeof(Base).FullName; + private static readonly string DerivedTypeName = typeof(Derived).FullName; + private static readonly string TestControllerName = typeof(TestController).FullName; + private static readonly string TestConventionName = typeof(TestConvention).FullName; + + [Theory] + [InlineData("Method", "method")] + [InlineData("Method", "ConventionMethod")] + [InlineData("p", "model")] + [InlineData("person", "model")] + public void IsNameMatch_WithAny_AlwaysReturnsTrue(string name, string conventionName) + { + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfNamesDifferInCase() + { + // Arrange + var name = "Name"; + var conventionName = "name"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "Name"; + var conventionName = "Different"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSubString() + { + // Arrange + var name = "RegularName"; + var conventionName = "Regular"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSuperString() + { + // Arrange + var name = "Regular"; + var conventionName = "RegularName"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsTrue_IfExactMatch() + { + // Arrange + var name = "parameterName"; + var conventionName = "parameterName"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsTrue_IfNamesAreExact() + { + // Arrange + var name = "PostPerson"; + var conventionName = "PostPerson"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsTrue_IfNameIsProperPrefix() + { + // Arrange + var name = "PostPerson"; + var conventionName = "Post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "GetPerson"; + var conventionName = "Post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesDifferInCase() + { + // Arrange + var name = "GetPerson"; + var conventionName = "post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsNotProperPrefix() + { + // Arrange + var name = "Postman"; + var conventionName = "Post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsSuffix() + { + // Arrange + var name = "GoPost"; + var conventionName = "Post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "name"; + var conventionName = "diff"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnsFalse_IfNameIsNotSuffix() + { + // Arrange + var name = "personId"; + var conventionName = "idx"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsExact() + { + // Arrange + var name = "test"; + var conventionName = "test"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnFalse_IfNameDiffersInCase() + { + // Arrange + var name = "test"; + var conventionName = "Test"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsProperSuffix() + { + // Arrange + var name = "personId"; + var conventionName = "id"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("candid", "id")] + [InlineData("canDid", "id")] + public void IsNameMatch_WithSuffix_ReturnFalse_IfNameIsNotProperSuffix(string name, string conventionName) + { + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(typeof(object), typeof(object))] + [InlineData(typeof(int), typeof(void))] + [InlineData(typeof(string), typeof(DateTime))] + public async Task IsTypeMatch_WithAny_ReturnsTrue(Type type, Type conventionType) + { + // Arrange + var compilation = await GetCompilationAsync(); + var typeSymbol = compilation.GetTypeByMetadataName(type.FullName); + var conventionTypeSymbol = compilation.GetTypeByMetadataName(conventionType.FullName); + + // Act + var result = IsTypeMatch(typeSymbol, conventionTypeSymbol, SymbolApiConventionTypeMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsTypeMatch_WithAssignableFrom_ReturnsTrueForExact() + { + // Arrange + var compilation = await GetCompilationAsync(); + + var type = compilation.GetTypeByMetadataName(BaseTypeName); + var conventionType = compilation.GetTypeByMetadataName(BaseTypeName); + + // Act + var result = IsTypeMatch(type, conventionType, SymbolApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsTypeMatch_WithAssignableFrom_ReturnsTrueForDerived() + { + // Arrange + var compilation = await GetCompilationAsync(); + + var type = compilation.GetTypeByMetadataName(DerivedTypeName); + var conventionType = compilation.GetTypeByMetadataName(BaseTypeName); + + + // Act + var result = IsTypeMatch(type, conventionType, SymbolApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsTypeMatch_WithAssignableFrom_ReturnsFalseForBaseTypes() + { + // Arrange + var compilation = await GetCompilationAsync(); + + var type = compilation.GetTypeByMetadataName(BaseTypeName); + var conventionType = compilation.GetTypeByMetadataName(DerivedTypeName); + + // Act + var result = IsTypeMatch(type, conventionType, SymbolApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsTypeMatch_WithAssignableFrom_ReturnsFalseForUnrelated() + { + // Arrange + var compilation = await GetCompilationAsync(); + + var type = compilation.GetSpecialType(SpecialType.System_String); + var conventionType = compilation.GetTypeByMetadataName(BaseTypeName); + + // Act + var result = IsTypeMatch(type, conventionType, SymbolApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public Task IsMatch_ReturnsFalse_IfMethodNamesDoNotMatch() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.Post); + var expected = false; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsFalse_IMethodHasMoreParametersThanConvention() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.GetNoArgs); + var expected = false; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsFalse_IfMethodHasFewerParametersThanConvention() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.GetTwoArgs); + var expected = false; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsFalse_IfParametersDoNotMatch() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.GetParameterNotMatching); + var expected = false; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsTrue_IfMethodNameAndParametersMatches() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.Get); + var expected = true; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsTrue_IfParamsArrayMatchesRemainingArguments() + { + // Arrange + var methodName = nameof(TestController.Search); + var conventionMethodName = nameof(TestConvention.Search); + var expected = true; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_WithEmpty_MatchesMethodWithNoParameters() + { + // Arrange + var methodName = nameof(TestController.SearchEmpty); + var conventionMethodName = nameof(TestConvention.SearchWithParams); + var expected = true; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + private async Task RunMatchTest(string methodName, string conventionMethodName, bool expected) + { + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testController = compilation.GetTypeByMetadataName(TestControllerName); + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = (IMethodSymbol)testController.GetMembers(methodName).First(); + var conventionMethod = (IMethodSymbol)testConvention.GetMembers(conventionMethodName).First(); + + // Act + var result = IsMatch(symbolCache, method, conventionMethod); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetNameMatchBehavior_ReturnsExact_WhenNoAttributesArePresent() + { + // Arrange + var expected = SymbolApiConventionNameMatchBehavior.Exact; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = testConvention.GetMembers(nameof(TestConvention.MethodWithoutMatchBehavior)).First(); + + // Act + var result = GetNameMatchBehavior(symbolCache, method); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetNameMatchBehavior_ReturnsExact_WhenNoNameMatchBehaviorAttributeIsSpecified() + { + // Arrange + var expected = SymbolApiConventionNameMatchBehavior.Exact; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = testConvention.GetMembers(nameof(TestConvention.MethodWithRandomAttributes)).First(); + + // Act + var result = GetNameMatchBehavior(symbolCache, method); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetNameMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = SymbolApiConventionNameMatchBehavior.Prefix; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = testConvention.GetMembers(nameof(TestConvention.Get)).First(); + + // Act + var result = GetNameMatchBehavior(symbolCache, method); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoAttributesArePresent() + { + // Arrange + var expected = SymbolApiConventionTypeMatchBehavior.AssignableFrom; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = (IMethodSymbol)testConvention.GetMembers(nameof(TestConvention.Get)).First(); + var parameter = method.Parameters[0]; + + // Act + var result = GetTypeMatchBehavior(symbolCache, parameter); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoMatchingAttributesArePresent() + { + // Arrange + var expected = SymbolApiConventionTypeMatchBehavior.AssignableFrom; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = (IMethodSymbol)testConvention.GetMembers(nameof(TestConvention.MethodParameterWithRandomAttributes)).First(); + var parameter = method.Parameters[0]; + + // Act + var result = GetTypeMatchBehavior(symbolCache, parameter); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetTypeMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = SymbolApiConventionTypeMatchBehavior.Any; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = (IMethodSymbol)testConvention.GetMembers(nameof(TestConvention.MethodWithAnyTypeMatchBehaviorParameter)).First(); + var parameter = method.Parameters[0]; + + // Act + var result = GetTypeMatchBehavior(symbolCache, parameter); + + // Assert + Assert.Equal(expected, result); + } + + private Task GetCompilationAsync(string test = "SymbolApiConventionMatcherTestFile") + { + var testSource = MvcTestSource.Read(GetType().Name, test); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + return project.GetCompilationAsync(); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs new file mode 100644 index 0000000000..57d823e8c6 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs @@ -0,0 +1,442 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.SymbolApiResponseMetadataProviderTest; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class SymbolApiResponseMetadataProviderTest + { + private static readonly string Namespace = typeof(SymbolApiResponseMetadataProviderTest).Namespace; + + [Fact] + public async Task GetResponseMetadata_ReturnsEmptySequence_IfNoAttributesArePresent_ForGetAction() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerWithoutConvention)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerWithoutConvention.GetPerson)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => Assert.True(metadata.IsImplicit)); + } + + [Fact] + public async Task GetResponseMetadata_ReturnsEmptySequence_IfNoAttributesArePresent_ForPostAction() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerWithoutConvention)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerWithoutConvention.PostPerson)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => Assert.True(metadata.IsImplicit)); + } + + [Fact] + public async Task GetResponseMetadata_IgnoresProducesAttribute() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesAttribute)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => Assert.True(metadata.IsImplicit)); + } + + [Fact] + public async Task GetResponseMetadata_ReturnsValueFromProducesResponseType_WhenStatusCodeIsSpecifiedInConstructor() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeInConstructor)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(201, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + Assert.Equal(method, metadata.AttributeSource); + }); + } + + [Fact] + public async Task GetResponseMetadata_ReturnsValueFromProducesResponseType_WhenStatusCodeIsSpecifiedInConstructorWithResponseType() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeAndTypeInConstructor)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(202, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + Assert.Equal(method, metadata.AttributeSource); + }); + } + + [Fact] + public async Task GetResponseMetadata_ReturnsValueFromProducesResponseType_WhenStatusCodeIsSpecifiedInConstructorAndProperty() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeInConstructorAndProperty)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(203, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + Assert.Equal(method, metadata.AttributeSource); + }); + } + + [Fact] + public async Task GetResponseMetadata_ReturnsValueFromProducesResponseType_WhenStatusCodeAndTypeIsSpecifiedInConstructorAndProperty() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeAndTypeInConstructorAndProperty)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(201, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + Assert.Equal(method, metadata.AttributeSource); + }); + } + + [Fact] + public async Task GetResponseMetadata_ReturnsValueFromCustomProducesResponseType() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithCustomProducesResponseTypeAttributeWithArguments)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(201, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + }); + } + + [Fact] + public async Task GetResponseMetadata_ReturnsValuesFromApiConventionMethodAttribute() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.GetResponseMetadata_ReturnsValuesFromApiConventionMethodAttribute)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(200, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + }, + metadata => + { + Assert.Equal(404, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + }, + metadata => + { + Assert.True(metadata.IsDefault); + }); + } + + [Fact] + public async Task GetResponseMetadata_WithProducesResponseTypeAndApiConventionMethod() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.GetResponseMetadata_WithProducesResponseTypeAndApiConventionMethod)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(204, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + }); + } + + [Fact] + public async Task GetResponseMetadata_IgnoresCustomResponseTypeMetadataProvider() + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithCustomApiResponseMetadataProvider)).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => Assert.True(metadata.IsImplicit)); + } + + [Fact] + public Task GetResponseMetadata_IgnoresAttributesWithIncorrectStatusCodeType() + { + return GetResponseMetadata_WorksForInvalidOrUnsupportedAttributes( + nameof(GetResponseMetadata_ControllerActionWithAttributes), + nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseTypeWithIncorrectStatusCodeType)); + } + + [Fact] + public Task GetResponseMetadata_IgnoresDerivedAttributesWithoutPropertyOnConstructorArguments() + { + return GetResponseMetadata_WorksForInvalidOrUnsupportedAttributes( + nameof(GetResponseMetadata_ControllerActionWithAttributes), + nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithCustomProducesResponseTypeAttributeWithoutArguments)); + } + + private async Task GetResponseMetadata_WorksForInvalidOrUnsupportedAttributes(string typeName, string methodName) + { + // Arrange + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{typeName}"); + var method = (IMethodSymbol)controller.GetMembers(methodName).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(200, metadata.StatusCode); + Assert.Same(method, metadata.AttributeSource); + }); + } + + [Fact] + public Task GetStatusCode_ReturnsValueFromConstructor() + { + // Arrange + var actionName = nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeInConstructor); + var expected = 201; + + // Act & Assert + return GetStatusCodeTest(actionName, expected); + } + + [Fact] + public Task GetStatusCode_ReturnsValueFromProperty() + { + // Arrange + var actionName = nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeAndTypeInConstructorAndProperty); + var expected = 201; + + // Act & Assert + return GetStatusCodeTest(actionName, expected); + } + + [Fact] + public Task GetStatusCode_ReturnsValueFromConstructor_WhenTypeIsSpecified() + { + // Arrange + var actionName = nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeAndTypeInConstructor); + var expected = 202; + + // Act & Assert + return GetStatusCodeTest(actionName, expected); + } + + [Fact] + public Task GetStatusCode_Returns200_IfTypeIsNotInteger() + { + // Arrange + var actionName = nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseTypeWithIncorrectStatusCodeType); + var expected = 200; + + // Act & Assert + return GetStatusCodeTest(actionName, expected); + } + + [Fact] + public Task GetStatusCode_ReturnsValueFromDerivedAttributes() + { + // Arrange + var actionName = nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithCustomProducesResponseTypeAttributeWithArguments); + var expected = 201; + + // Act & Assert + return GetStatusCodeTest(actionName, expected); + } + + private async Task GetStatusCodeTest(string actionName, int expected) + { + var compilation = await GetResponseMetadataCompilation(); + var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); + var method = (IMethodSymbol)controller.GetMembers(actionName).First(); + var attribute = method.GetAttributes().First(); + + var statusCode = SymbolApiResponseMetadataProvider.GetStatusCode(attribute); + + Assert.Equal(expected, statusCode); + } + + [Fact] + public async Task GetErrorResponseType_ReturnsProblemDetails_IfNoAttributeIsDiscovered() + { + // Arrange + var compilation = await GetCompilation(nameof(GetErrorResponseType_ReturnsProblemDetails_IfNoAttributeIsDiscovered)); + var expected = compilation.GetTypeByMetadataName(typeof(ProblemDetails).FullName); + + var type = compilation.GetTypeByMetadataName(typeof(GetErrorResponseType_ReturnsProblemDetails_IfNoAttributeIsDiscoveredController).FullName); + var method = (IMethodSymbol)type.GetMembers("Action").First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetErrorResponseType(symbolCache, method); + + // Assert + Assert.Same(expected, result); + } + + [Fact] + public async Task GetErrorResponseType_ReturnsTypeDefinedAtAssembly() + { + // Arrange + var compilation = await GetCompilation(nameof(GetErrorResponseType_ReturnsTypeDefinedAtAssembly)); + var expected = compilation.GetTypeByMetadataName(typeof(GetErrorResponseType_ReturnsTypeDefinedAtAssemblyModel).FullName); + + var type = compilation.GetTypeByMetadataName(typeof(GetErrorResponseType_ReturnsTypeDefinedAtAssemblyController).FullName); + var method = (IMethodSymbol)type.GetMembers("Action").First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetErrorResponseType(symbolCache, method); + + // Assert + Assert.Same(expected, result); + } + + [Fact] + public async Task GetErrorResponseType_ReturnsTypeDefinedAtController() + { + // Arrange + var compilation = await GetCompilation(nameof(GetErrorResponseType_ReturnsTypeDefinedAtController)); + var expected = compilation.GetTypeByMetadataName(typeof(GetErrorResponseType_ReturnsTypeDefinedAtControllerModel).FullName); + + var type = compilation.GetTypeByMetadataName(typeof(GetErrorResponseType_ReturnsTypeDefinedAtControllerController).FullName); + var method = (IMethodSymbol)type.GetMembers("Action").First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetErrorResponseType(symbolCache, method); + + // Assert + Assert.Same(expected, result); + } + + [Fact] + public async Task GetErrorResponseType_ReturnsTypeDefinedAtAction() + { + // Arrange + var compilation = await GetCompilation(nameof(GetErrorResponseType_ReturnsTypeDefinedAtAction)); + var expected = compilation.GetTypeByMetadataName(typeof(GetErrorResponseType_ReturnsTypeDefinedAtActionModel).FullName); + + var type = compilation.GetTypeByMetadataName(typeof(GetErrorResponseType_ReturnsTypeDefinedAtActionController).FullName); + var method = (IMethodSymbol)type.GetMembers("Action").First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + // Act + var result = SymbolApiResponseMetadataProvider.GetErrorResponseType(symbolCache, method); + + // Assert + Assert.Same(expected, result); + } + + private Task GetResponseMetadataCompilation() => GetCompilation("GetResponseMetadataTests"); + + private Task GetCompilation(string test) + { + var testSource = MvcTestSource.Read(GetType().Name, test); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + return project.GetCompilationAsync(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/GetDefaultStatusCodeTest.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/GetDefaultStatusCodeTest.cs new file mode 100644 index 0000000000..8fb75a9e49 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/GetDefaultStatusCodeTest.cs @@ -0,0 +1,12 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [DefaultStatusCode(StatusCodes.Status412PreconditionFailed)] + public class TestActionResultUsingStatusCodesConstants { } + + [DefaultStatusCode((int)HttpStatusCode.Redirect)] + public class TestActionResultUsingHttpStatusCodeCast { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/InspectReturnExpressionTests.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/InspectReturnExpressionTests.cs new file mode 100644 index 0000000000..c7e12719f4 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/InspectReturnExpressionTests.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.InspectReturnExpressionTests +{ + public class TestController : ControllerBase + { + public object InspectReturnExpression_ReturnsDefaultResponseMetadata_IfReturnedTypeIsNotActionResult() + { + return new TestModel(); + } + + public IActionResult InspectReturnExpression_ReturnsStatusCodeFromDefaultStatusCodeAttributeOnActionResult() + { + return Unauthorized(); + } + + public IActionResult InspectReturnExpression_ReturnsStatusCodeFromStatusCodePropertyAssignment() + { + return new ObjectResult(new object()) { StatusCode = 201 }; + } + + public IActionResult InspectReturnExpression_ReturnsStatusCodeFromConstructorAssignment() + { + return new StatusCodeResult(204); + } + + public IActionResult InspectReturnExpression_ReturnsStatusCodeFromHelperMethod() + { + return StatusCode(302); + } + + public IActionResult InspectReturnExpression_UsesExplicitlySpecifiedStatusCode_ForActionResultWithDefaultStatusCode() + { + return new BadRequestObjectResult(new object()) + { + StatusCode = StatusCodes.Status422UnprocessableEntity, + }; + } + + public IActionResult InspectReturnExpression_ReadsStatusCodeConstant() + { + return StatusCode(StatusCodes.Status423Locked); + } + + public IActionResult InspectReturnExpression_DoesNotReadLocalFieldWithConstantValue() + { + var statusCode = StatusCodes.Status429TooManyRequests; + return StatusCode(statusCode); + } + + public IActionResult InspectReturnExpression_FallsBackToDefaultStatusCode_WhenAppliedStatusCodeCannotBeRead() + { + var statusCode = StatusCodes.Status422UnprocessableEntity; + return new BadRequestObjectResult(new object()) { StatusCode = statusCode }; + } + + public IActionResult InspectReturnExpression_SetsReturnType_WhenLiteralTypeIsSpecifiedInConstructor() + { + return new BadRequestObjectResult(new TestModel()); + } + + public IActionResult InspectReturnExpression_SetsReturnType_WhenLocalValueIsSpecifiedInConstructor() + { + var local = new TestModel(); + return new BadRequestObjectResult(local); + } + + public IActionResult InspectReturnExpression_ReturnsNullReturnType_IfValueIsNotSpecified() + { + return NotFound(); + } + + public ActionResult InspectReturnExpression_SetsReturnType_WhenValueIsReturned() + { + var local = new TestModel(); + return local; + } + } + + public class TestModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/TryGetActualResponseMetadataTests.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/TryGetActualResponseMetadataTests.cs new file mode 100644 index 0000000000..389b605adb --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ActualApiResponseMetadataFactoryTest/TryGetActualResponseMetadataTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ActualApiResponseMetadataFactoryTest +{ + public class TryGetActualResponseMetadataController : ControllerBase + { + public async Task>> ActionWithActionResultOfTReturningOkResult() + { + await Task.Yield(); + var models = new List(); + + return Ok(models); + } + + public async Task>> ActionWithActionResultOfTReturningModel() + { + await Task.Yield(); + var models = new List(); + + return models; + } + + public async Task> ActionReturningNotFoundAndModel(int id) + { + await Task.Yield(); + + if (id == 0) + { + /*MM1*/return NoContent(); + } + + /*MM2*/return new TryGetActualResponseMetadataModel(); + } + } + + public class TryGetActualResponseMetadataModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsFullyQualifiedProducesResponseType.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsFullyQualifiedProducesResponseType.Input.cs new file mode 100644 index 0000000000..e1b19d61a7 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsFullyQualifiedProducesResponseType.Input.cs @@ -0,0 +1,35 @@ + +[assembly: Microsoft.AspNetCore.Mvc.ApiConventionType(typeof(Microsoft.AspNetCore.Mvc.DefaultApiConventions))] + +namespace TestApp._INPUT_ +{ + using Microsoft.AspNetCore.Mvc; + + [ApiController] + [Route("[controller]/[action]")] + public class BaseController : ControllerBase + { + + } +} + +namespace TestApp._INPUT_ +{ + public class CodeFixAddsFullyQualifiedProducesResponseType : BaseController + { + public object GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + if (id == 1) + { + return BadRequest(); + } + + return Accepted(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsFullyQualifiedProducesResponseType.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsFullyQualifiedProducesResponseType.Output.cs new file mode 100644 index 0000000000..accec3f2d4 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsFullyQualifiedProducesResponseType.Output.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; + +[assembly: Microsoft.AspNetCore.Mvc.ApiConventionType(typeof(Microsoft.AspNetCore.Mvc.DefaultApiConventions))] + +namespace TestApp._OUTPUT_ +{ + using Microsoft.AspNetCore.Mvc; + + [ApiController] + [Route("[controller]/[action]")] + public class BaseController : ControllerBase + { + + } +} + +namespace TestApp._OUTPUT_ +{ + public class CodeFixAddsFullyQualifiedProducesResponseType : BaseController + { + [Microsoft.AspNetCore.Mvc.ProducesResponseType(StatusCodes.Status202Accepted)] + [Microsoft.AspNetCore.Mvc.ProducesResponseType(StatusCodes.Status400BadRequest)] + [Microsoft.AspNetCore.Mvc.ProducesResponseType(StatusCodes.Status404NotFound)] + [Microsoft.AspNetCore.Mvc.ProducesDefaultResponseType] + public object GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + if (id == 1) + { + return BadRequest(); + } + + return Accepted(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodes.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodes.Input.cs new file mode 100644 index 0000000000..65eb3a68ae --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodes.Input.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsMissingStatusCodes : ControllerBase + { + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + if (id == 1) + { + return BadRequest(); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodes.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodes.Output.cs new file mode 100644 index 0000000000..8453b10ac5 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodes.Output.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsMissingStatusCodes : ControllerBase + { + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + if (id == 1) + { + return BadRequest(); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodesAndTypes.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodesAndTypes.Input.cs new file mode 100644 index 0000000000..43f07eca63 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodesAndTypes.Input.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsMissingStatusCodesAndTypes : ControllerBase + { + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + if (id == 1) + { + return BadRequest(ModelState); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodesAndTypes.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodesAndTypes.Output.cs new file mode 100644 index 0000000000..a758564bcf --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsMissingStatusCodesAndTypes.Output.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsMissingStatusCodesAndTypes : ControllerBase + { + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ModelBinding.ModelStateDictionary), StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + if (id == 1) + { + return BadRequest(ModelState); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsNumericLiteralForNonExistingStatusCodeConstants.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsNumericLiteralForNonExistingStatusCodeConstants.Input.cs new file mode 100644 index 0000000000..93668878b1 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsNumericLiteralForNonExistingStatusCodeConstants.Input.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsNumericLiteralForNonExistingStatusCodeConstantsController : ControllerBase + { + public IActionResult GetItem(int id) + { + if (id == 0) + { + return StatusCode(345); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsNumericLiteralForNonExistingStatusCodeConstants.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsNumericLiteralForNonExistingStatusCodeConstants.Output.cs new file mode 100644 index 0000000000..82a8f44a3b --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsNumericLiteralForNonExistingStatusCodeConstants.Output.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsNumericLiteralForNonExistingStatusCodeConstantsController : ControllerBase + { + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(345)] + [ProducesDefaultResponseType] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return StatusCode(345); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsResponseTypeWhenDifferentFromErrorType.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsResponseTypeWhenDifferentFromErrorType.Input.cs new file mode 100644 index 0000000000..02d2e72efb --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsResponseTypeWhenDifferentFromErrorType.Input.cs @@ -0,0 +1,26 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ProducesErrorResponseType(typeof(CodeFixAddsResponseTypeWhenDifferentErrorModel))] + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsResponseTypeWhenDifferentFromErrorType : ControllerBase + { + public IActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(new CodeFixAddsResponseTypeWhenDifferentErrorModel()); + } + + if (id == 1) + { + var validationProblemDetails = new ValidationProblemDetails(ModelState); + return BadRequest(validationProblemDetails); + } + + return Ok(new object()); + } + } + + public class CodeFixAddsResponseTypeWhenDifferentErrorModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsResponseTypeWhenDifferentFromErrorType.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsResponseTypeWhenDifferentFromErrorType.Output.cs new file mode 100644 index 0000000000..f6be4784e5 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsResponseTypeWhenDifferentFromErrorType.Output.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ProducesErrorResponseType(typeof(CodeFixAddsResponseTypeWhenDifferentErrorModel))] + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsResponseTypeWhenDifferentFromErrorType : ControllerBase + { + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(new CodeFixAddsResponseTypeWhenDifferentErrorModel()); + } + + if (id == 1) + { + var validationProblemDetails = new ValidationProblemDetails(ModelState); + return BadRequest(validationProblemDetails); + } + + return Ok(new object()); + } + } + + public class CodeFixAddsResponseTypeWhenDifferentErrorModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodes.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodes.Input.cs new file mode 100644 index 0000000000..7c3e027608 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodes.Input.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsStatusCodesController : ControllerBase + { + public IActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodes.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodes.Output.cs new file mode 100644 index 0000000000..0da7b06e5c --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodes.Output.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsStatusCodesController : ControllerBase + { + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromConstructorParameters.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromConstructorParameters.Input.cs new file mode 100644 index 0000000000..0fe4637037 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromConstructorParameters.Input.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsStatusCodesFromConstructorParametersController : ControllerBase + { + private const int FieldStatusCode = 201; + + public IActionResult GetItem(int id) + { + if (id == 0) + { + return new StatusCodeResult(422); + } + + if (id == 1) + { + return new StatusCodeResult(StatusCodes.Status202Accepted); + } + + if (id == 2) + { + const int localStatusCode = 204; + + return new StatusCodeResult(localStatusCode); + } + + if (id == 3) + { + return new StatusCodeResult(FieldStatusCode); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromConstructorParameters.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromConstructorParameters.Output.cs new file mode 100644 index 0000000000..dbdf8d7fae --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromConstructorParameters.Output.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsStatusCodesFromConstructorParametersController : ControllerBase + { + private const int FieldStatusCode = 201; + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesDefaultResponseType] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return new StatusCodeResult(422); + } + + if (id == 1) + { + return new StatusCodeResult(StatusCodes.Status202Accepted); + } + + if (id == 2) + { + const int localStatusCode = 204; + + return new StatusCodeResult(localStatusCode); + } + + if (id == 3) + { + return new StatusCodeResult(FieldStatusCode); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromMethodParameters.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromMethodParameters.Input.cs new file mode 100644 index 0000000000..8bc4a372e1 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromMethodParameters.Input.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsStatusCodesFromMethodParametersController : ControllerBase + { + private const int FieldStatusCode = 201; + + public IActionResult GetItem(int id) + { + if (id == 0) + { + return StatusCode(422); + } + + if (id == 1) + { + return StatusCode(StatusCodes.Status202Accepted); + } + + if (id == 2) + { + const int localStatusCode = 204; + + return StatusCode(localStatusCode); + } + + if (id == 3) + { + return StatusCode(FieldStatusCode); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromMethodParameters.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromMethodParameters.Output.cs new file mode 100644 index 0000000000..d1ad5d182d --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromMethodParameters.Output.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsStatusCodesFromMethodParametersController : ControllerBase + { + private const int FieldStatusCode = 201; + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesDefaultResponseType] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return StatusCode(422); + } + + if (id == 1) + { + return StatusCode(StatusCodes.Status202Accepted); + } + + if (id == 2) + { + const int localStatusCode = 204; + + return StatusCode(localStatusCode); + } + + if (id == 3) + { + return StatusCode(FieldStatusCode); + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromObjectInitializer.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromObjectInitializer.Input.cs new file mode 100644 index 0000000000..7041164c5e --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromObjectInitializer.Input.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsStatusCodesFromObjectInitializerController : ControllerBase + { + private const int FieldStatusCode = 201; + + public IActionResult GetItem(int id) + { + if (id == 0) + { + return new ObjectResult(new object()) + { + StatusCode = 422 + }; + } + + if (id == 1) + { + return new ObjectResult(new object()) + { + StatusCode = StatusCodes.Status202Accepted + }; + } + + if (id == 2) + { + const int localStatusCode = 204; + + return new ObjectResult(new object()) + { + StatusCode = localStatusCode + }; + } + + if (id == 3) + { + return new ObjectResult(new object()) + { + ContentTypes = { "application/json" }, + StatusCode = FieldStatusCode + }; + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromObjectInitializer.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromObjectInitializer.Output.cs new file mode 100644 index 0000000000..8f35baeee7 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsStatusCodesFromObjectInitializer.Output.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsStatusCodesFromObjectInitializerController : ControllerBase + { + private const int FieldStatusCode = 201; + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesDefaultResponseType] + public IActionResult GetItem(int id) + { + if (id == 0) + { + return new ObjectResult(new object()) + { + StatusCode = 422 + }; + } + + if (id == 1) + { + return new ObjectResult(new object()) + { + StatusCode = StatusCodes.Status202Accepted + }; + } + + if (id == 2) + { + const int localStatusCode = 204; + + return new ObjectResult(new object()) + { + StatusCode = localStatusCode + }; + } + + if (id == 3) + { + return new ObjectResult(new object()) + { + ContentTypes = { "application/json" }, + StatusCode = FieldStatusCode + }; + } + + return Ok(new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsSuccessStatusCode.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsSuccessStatusCode.Input.cs new file mode 100644 index 0000000000..ff32f9b56d --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsSuccessStatusCode.Input.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsSuccessStatusCode : ControllerBase + { + public ActionResult GetItem(string id) + { + if (!int.TryParse(id, out var idInt)) + { + return BadRequest(); + } + + if (idInt == 0) + { + return NotFound(); + } + + return Created("url", new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsSuccessStatusCode.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsSuccessStatusCode.Output.cs new file mode 100644 index 0000000000..dc64099289 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixAddsSuccessStatusCode.Output.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixAddsSuccessStatusCode : ControllerBase + { + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + public ActionResult GetItem(string id) + { + if (!int.TryParse(id, out var idInt)) + { + return BadRequest(); + } + + if (idInt == 0) + { + return NotFound(); + } + + return Created("url", new object()); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionAddsMissingStatusCodes.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionAddsMissingStatusCodes.Input.cs new file mode 100644 index 0000000000..55a1e429b7 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionAddsMissingStatusCodes.Input.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixWithConventionAddsMissingStatusCodes : ControllerBase + { + public ActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Accepted("Result"); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionAddsMissingStatusCodes.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionAddsMissingStatusCodes.Output.cs new file mode 100644 index 0000000000..657b5e9690 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionAddsMissingStatusCodes.Output.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixWithConventionAddsMissingStatusCodes : ControllerBase + { + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + public ActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Accepted("Result"); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionMethodAddsMissingStatusCodes.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionMethodAddsMissingStatusCodes.Input.cs new file mode 100644 index 0000000000..96e622e16b --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionMethodAddsMissingStatusCodes.Input.cs @@ -0,0 +1,18 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._INPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixWithConventionMethodAddsMissingStatusCodes : ControllerBase + { + [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Find))] + public ActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Accepted("Result"); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionMethodAddsMissingStatusCodes.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionMethodAddsMissingStatusCodes.Output.cs new file mode 100644 index 0000000000..d8d7215a59 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/AddResponseTypeAttributeCodeFixProviderIntegrationTest/CodeFixWithConventionMethodAddsMissingStatusCodes.Output.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers._OUTPUT_ +{ + [ApiController] + [Route("[controller]/[action]")] + public class CodeFixWithConventionMethodAddsMissingStatusCodes : ControllerBase + { + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + public ActionResult GetItem(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Accepted("Result"); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecks.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecks.cs new file mode 100644 index 0000000000..2966097a0f --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecks.cs @@ -0,0 +1,22 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + [ApiController] + [Route("/api/[controller]")] + public class DiagnosticsAreReturned_ForApiActionsWithModelStateChecks : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + { + return BadRequest(); + } + + /*MM*/if (!ModelState.IsValid) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecksUsingEquality.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecksUsingEquality.cs new file mode 100644 index 0000000000..432789e86e --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecksUsingEquality.cs @@ -0,0 +1,22 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + [ApiController] + [Route("/api/[controller]")] + public class DiagnosticsAreReturned_ForApiActionsWithModelStateChecksUsingEquality : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 1) + { + return NotFound(); + } + + /*MM*/if (ModelState.IsValid == false) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecksWithoutBracing.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecksWithoutBracing.cs new file mode 100644 index 0000000000..e62ee68014 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/DiagnosticsAreReturned_ForApiActionsWithModelStateChecksWithoutBracing.cs @@ -0,0 +1,18 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + [ApiController] + [Route("/api/[controller]")] + public class DiagnosticsAreReturned_ForApiActionsWithModelStateChecksWithoutBracing : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + return BadRequest(); + + /*MM*/if (!ModelState.IsValid) + return BadRequest(); + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsCheckingAdditionalConditions.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsCheckingAdditionalConditions.cs new file mode 100644 index 0000000000..8ed22e69b9 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsCheckingAdditionalConditions.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + [ApiController] + [Route("/api/[controller]")] + public class NoDiagnosticsAreReturned_ForApiActionsCheckingAdditionalConditions : ControllerBase + { + public IActionResult Method(int id) + { + if (!ModelState.IsValid && !Request.Query.ContainsKey("skip-validation")) + { + return UnprocessableEntity(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsReturning400FromNonModelStateIsValidBlocks.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsReturning400FromNonModelStateIsValidBlocks.cs new file mode 100644 index 0000000000..698fa72476 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsReturning400FromNonModelStateIsValidBlocks.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + [ApiController] + [Route("/api/[controller]")] + public class NoDiagnosticsAreReturned_ForApiActionsReturning400FromNonModelStateIsValidBlocks : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsReturningNot400FromNonModelStateIsValidBlock.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsReturningNot400FromNonModelStateIsValidBlock.cs new file mode 100644 index 0000000000..86659db6f9 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsReturningNot400FromNonModelStateIsValidBlock.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + [ApiController] + [Route("/api/[controller]")] + public class NoDiagnosticsAreReturned_ForApiActionsReturningNot400FromNonModelStateIsValidBlock : ControllerBase + { + public IActionResult Method(int id) + { + if (!ModelState.IsValid) + { + return UnprocessableEntity(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsWithoutModelStateChecks.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsWithoutModelStateChecks.cs new file mode 100644 index 0000000000..e24c094562 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiActionsWithoutModelStateChecks.cs @@ -0,0 +1,16 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForApiActionsWithoutModelStateChecks : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs new file mode 100644 index 0000000000..c9b044457f --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs @@ -0,0 +1,15 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + public class NoDiagnosticsAreReturned_ForNonApiController : ControllerBase + { + public IActionResult Method(int id) + { + if (!ModelState.IsValid) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs new file mode 100644 index 0000000000..30b7c5cceb --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckAnalyzerIntegrationTest +{ + public class Home : PageModel + { + public IActionResult OnPost(int id) + { + if (!ModelState.IsValid) + { + return BadRequest(); + } + + return Page(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesIfBlockWithoutBraces.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesIfBlockWithoutBraces.Input.cs new file mode 100644 index 0000000000..2608c14bc5 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesIfBlockWithoutBraces.Input.cs @@ -0,0 +1,18 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest._INPUT_ +{ + [ApiController] + [Route("/api/[controller]")] + public class CodeFixRemovesIfBlockWithoutBraces : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + return BadRequest(); + + if (!ModelState.IsValid) + return BadRequest(); + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesIfBlockWithoutBraces.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesIfBlockWithoutBraces.Output.cs new file mode 100644 index 0000000000..9d10cebb31 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesIfBlockWithoutBraces.Output.cs @@ -0,0 +1,14 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest._OUTPUT_ +{ + [ApiController] + [Route("/api/[controller]")] + public class CodeFixRemovesIfBlockWithoutBraces : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + return BadRequest(); + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck.Input.cs new file mode 100644 index 0000000000..9a12968723 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck.Input.cs @@ -0,0 +1,22 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest._INPUT_ +{ + [ApiController] + [Route("/api/[controller]")] + public class CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + { + return BadRequest(); + } + + if (ModelState.IsValid == false) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck.Output.cs new file mode 100644 index 0000000000..8ba42c4bfd --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck.Output.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest._OUTPUT_ +{ + [ApiController] + [Route("/api/[controller]")] + public class CodeFixRemovesModelStateIsInvalidBlockWithEqualityCheck : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck.Input.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck.Input.cs new file mode 100644 index 0000000000..f737c35206 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck.Input.cs @@ -0,0 +1,22 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest._INPUT_ +{ + [ApiController] + [Route("/api/[controller]")] + public class CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + { + return BadRequest(); + } + + if (!ModelState.IsValid) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck.Output.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck.Output.cs new file mode 100644 index 0000000000..a61edaf518 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest/CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck.Output.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiActionsDoNotRequireExplicitModelValidationCheckCodeFixProviderTest._OUTPUT_ +{ + [ApiController] + [Route("/api/[controller]")] + public class CodeFixRemovesModelStateIsInvalidBlockWithIfNotCheck : ControllerBase + { + public IActionResult Method(int id) + { + if (id == 0) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiControllerFactsTest/IsApiControllerAction_ReturnsTrue_IfAttributeIsDeclaredOnAssembly.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiControllerFactsTest/IsApiControllerAction_ReturnsTrue_IfAttributeIsDeclaredOnAssembly.cs new file mode 100644 index 0000000000..a8b5d0ee8b --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiControllerFactsTest/IsApiControllerAction_ReturnsTrue_IfAttributeIsDeclaredOnAssembly.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiController] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.ApiControllerFactsTest +{ + public class IsApiControllerAction_ReturnsTrue_IfAttributeIsDeclaredOnAssemblyController : ControllerBase + { + public IActionResult Action() => null; + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiControllerFactsTest/TestFile.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiControllerFactsTest/TestFile.cs new file mode 100644 index 0000000000..7eb8c8584c --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiControllerFactsTest/TestFile.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class ApiConventionAnalyzerTest_IndexModel : PageModel + { + public IActionResult OnGet() => null; + } + + public class ApiConventionAnalyzerTest_NotApiController : Controller + { + public IActionResult Index() => null; + } + + public class ApiConventionAnalyzerTest_NotAction : Controller + { + [NonAction] + public IActionResult Index() => null; + } + + [ApiController] + public class ApiConventionAnalyzerTest_Valid : Controller + { + public IActionResult Index() => null; + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes.cs new file mode 100644 index 0000000000..42ce3bd069 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes : ControllerBase + { + public ActionResult Method(Guid? id) + { + if (id == null) + { + /*MM*/return NotFound(); + } + + return "Hello world"; + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes.cs new file mode 100644 index 0000000000..1b341aed17 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 404)] + public IActionResult Put(int id, object model) + { + if (id == 0) + { + return NotFound(); + } + + if (!ModelState.IsValid) + { + /*MM*/return UnprocessableEntity(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForControllerWithCustomConvention.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForControllerWithCustomConvention.cs new file mode 100644 index 0000000000..884d613e04 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForControllerWithCustomConvention.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Api.Analyzers; + +[assembly: ApiConventionType(typeof(DiagnosticsAreReturned_ForControllerWithCustomConvention))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_ForControllerWithCustomConventionController : ControllerBase + { + public async Task Update(int id, Product product) + { + if (id < 0) + { + /*MM*/return BadRequest(); + } + + try + { + await product.Update(); + + } + catch + { + return Conflict(); + } + + return Ok(); + } + } + + public static class DiagnosticsAreReturned_ForControllerWithCustomConvention + { + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public static void Update(int id, Product product) + { + + } + } + + public class Product + { + public Task Update() => Task.CompletedTask; + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..3e263cffd9 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode : ControllerBase + { + [ProducesResponseType(typeof(string), 404)] + public async ValueTask Method(int id) + { + await Task.Yield(); + if (id == 0) + { + return NotFound(); + } + + /*MM*/return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..d61842d16f --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + public async Task Method(int id) + { + await Task.Yield(); + if (id == 0) + { + /*MM*/return NotFound(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..f09255b7d0 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode : ControllerBase + { + [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Post))] + public IActionResult Get(int id) + { + /*MM*/return Accepted(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation.cs new file mode 100644 index 0000000000..4a93438cfd --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation : ControllerBase + { + [ProducesResponseType(404)] + public async Task> Method(int id) + { + await Task.Yield(); + + if (id == 0) + { + return NotFound(); + } + + /*MM*/return new DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentationModel(); + } + } + + public class DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentationModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation.cs new file mode 100644 index 0000000000..1c4a8f59cf --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation.cs @@ -0,0 +1,19 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation : ControllerBase + { + [ProducesResponseType(404)] + public ActionResult Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + /*MM*/return new DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentationModel(); + } + } + + public class DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentationModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType.cs new file mode 100644 index 0000000000..82d6805a68 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType : ControllerBase + { + [ProducesResponseType(404)] + public ActionResult Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + /*MM*/return new DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedTypeDerived(); + } + } + + public class DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedTypeBaseModel { } + + public class DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedTypeDerived : DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedTypeBaseModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode.cs new file mode 100644 index 0000000000..62929c1f2e --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode : ControllerBase + { + public IActionResult /*MM*/Delete(int id) + { + if (!ModelState.IsValid) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..a49f31108a --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Api.Analyzers; + +[assembly: ApiConventionType(typeof(DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCodeConvention))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode : ControllerBase + { + public IActionResult Get(int id) + { + if (id < 0) + { + /*MM*/return BadRequest(); + } + + if (id == 0) + { + return NotFound(); + } + + return Ok(); + } + } + + public static class DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCodeConvention + { + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public static void Get(int id) { } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotDocumentSuccessStatusCode.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotDocumentSuccessStatusCode.cs new file mode 100644 index 0000000000..a45428d467 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotDocumentSuccessStatusCode.cs @@ -0,0 +1,20 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotDocumentSuccessStatusCode : ControllerBase + { + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public ActionResult /*MM*/Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + throw new NotImplementedException(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode.cs new file mode 100644 index 0000000000..59b1c99c88 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode : ControllerBase + { + [ProducesResponseType(200)] + [ProducesResponseType(400)] + [ProducesResponseType(404)] + public IActionResult /*MM*/Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..ad4c183a51 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + public IActionResult Method(int id) + { + if (id == 0) + { + /*MM*/return NotFound(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred.cs new file mode 100644 index 0000000000..e2866071d4 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred.cs @@ -0,0 +1,12 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred : ControllerBase + { + [ProducesResponseType(201)] + public IActionResult Method(int id) + { + return id == 0 ? (IActionResult)NotFound() : Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes.cs new file mode 100644 index 0000000000..c7aaac3596 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 400)] + [ProducesResponseType(typeof(string), 404)] + public IActionResult Put(int id, object model) + { + if (id == 0) + { + return NotFound(); + } + + if (!ModelState.IsValid) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs new file mode 100644 index 0000000000..1d790dab91 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class NoDiagnosticsAreReturned_ForNonApiController : Controller + { + [ProducesResponseType(typeof(string), 200)] + public IActionResult Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForOkResultReturningAction.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForOkResultReturningAction.cs new file mode 100644 index 0000000000..e5ae8feb2c --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForOkResultReturningAction.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForOkResultReturningAction : ControllerBase + { + public async Task>> Action() + { + await Task.Yield(); + var models = new List(); + + return Ok(models); + } + } + + public class NoDiagnosticsAreReturned_ForOkResultReturningActionModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs new file mode 100644 index 0000000000..fa410dc115 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class Home : PageModel + { + [ProducesResponseType(302)] + public IActionResult OnPost(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Page(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLambdas.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLambdas.cs new file mode 100644 index 0000000000..0770de818d --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLambdas.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForReturnStatementsInLambdas : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 404)] + public IActionResult Put(int id, object model) + { + Func someLambda = () => + { + if (id < -1) + { + // We should not process this. + return UnprocessableEntity(); + } + + return null; + }; + + + if (id == 0) + { + return NotFound(); + } + + + if (id == 1) + { + return someLambda(); + } + + return Ok(); + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions.cs new file mode 100644 index 0000000000..329bde822d --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions.cs @@ -0,0 +1,34 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 404)] + public IActionResult Put(int id, object model) + { + if (id == 0) + { + return NotFound(); + } + + if (id == 1) + { + return LocalFunction(); + } + + return Ok(); + + IActionResult LocalFunction() + { + if (id < -1) + { + // We should not process this. + return UnprocessableEntity(); + } + + return null; + } + } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/MvcFactsTest/IsControllerActionTests.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/MvcFactsTest/IsControllerActionTests.cs new file mode 100644 index 0000000000..085ba88d9a --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/MvcFactsTest/IsControllerActionTests.cs @@ -0,0 +1,75 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public abstract class TestIsControllerActionBase : ControllerBase + { + public abstract IActionResult AbstractMethod(); + + public virtual IActionResult VirtualMethod() => null; + + public virtual IActionResult MethodInBase() => null; + + [NonAction] + public virtual IActionResult NonActionBase() => null; + } + + public class TestIsControllerAction : TestIsControllerActionBase, IDisposable + { + static TestIsControllerAction() { } + + public override IActionResult AbstractMethod() => null; + + private IActionResult PrivateMethod() => null; + + protected IActionResult ProtectedMethod() => null; + + internal IActionResult InternalMethod() => null; + + public IActionResult GenericMethod() => null; + + public static IActionResult StaticMethod() => null; + + [NonAction] + public IActionResult NonAction() => null; + + public override IActionResult NonActionBase() => null; + + public IActionResult Ordinary() => null; + + public void Dispose() { } + } + + public class OverridesObjectMethods : ControllerBase + { + public override bool Equals(object obj) => false; + + public override int GetHashCode() => 0; + + public new string ToString() => null; + } + + public class ExplicitIDisposable : ControllerBase, IDisposable + { + void IDisposable.Dispose() { } + } + + public class NotDisposable + { + public IActionResult Dispose() => null; + } + + public class NotDisposableWithExplicitImplementation : IDisposable + { + public IActionResult Dispose() => null; + + void IDisposable.Dispose() { } + } + + public class NotDisposableWithDisposeThatIsNotInterfaceContract : IDisposable + { + public IActionResult Dispose(int id) => null; + + void IDisposable.Dispose() { } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/MvcFactsTest/IsControllerTests.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/MvcFactsTest/IsControllerTests.cs new file mode 100644 index 0000000000..1c765c6fd3 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/MvcFactsTest/IsControllerTests.cs @@ -0,0 +1,44 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public interface ITestController { } + + public abstract class AbstractController : Controller { } + + public class DerivedAbstractController : AbstractController { } + + public struct ValueTypeController { } + + public class OpenGenericController : Controller { } + + public class PocoType { } + + public class DerivedPocoType : PocoType { } + + public class TypeDerivingFromController : Controller { } + + public class TypeDerivingFromControllerBase : ControllerBase { } + + public abstract class NoControllerAttributeBaseController { } + + public class NoSuffixNoControllerAttribute : NoControllerAttributeBaseController { } + + public class DerivedGenericController : OpenGenericController { } + + public class NoSuffix : Controller { } + + public class PocoController { } + + [Controller] + public class CustomBase { } + + [Controller] + public class ChildOfCustomBase : CustomBase { } + + [NonController] + public class BaseNonController { } + + [Controller] + public class ControllerAttributeDerivingFromNonController : BaseNonController { } + + public class BasePocoNonControllerChildController : BaseNonController { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiConventionMatcherTest/SymbolApiConventionMatcherTestFile.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiConventionMatcherTest/SymbolApiConventionMatcherTestFile.cs new file mode 100644 index 0000000000..3d27daef10 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiConventionMatcherTest/SymbolApiConventionMatcherTestFile.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class Base { } + + public class Derived : Base { } + + public class TestController + { + public IActionResult Get(int id) => null; + + public IActionResult Search(string searchTerm, bool sortDescending, int page) => null; + + public IActionResult SearchEmpty() => null; + } + + public static class TestConvention + { + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Get(int id) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void GetNoArgs() { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void GetTwoArgs(int id, string name) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Post(Derived model) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void GetParameterNotMatching([ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.AssignableFrom)] Derived model) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void Search( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Exact)] + string searchTerm, + params object[] others) + { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void SearchWithParams(params object[] others) { } + + public static void MethodWithoutMatchBehavior() { } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void MethodWithRandomAttributes() { } + + public static void MethodParameterWithRandomAttributes([FromRoute] int value) { } + + public static void MethodWithAnyTypeMatchBehaviorParameter([ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] int value) { } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsProblemDetails_IfNoAttributeIsDiscovered.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsProblemDetails_IfNoAttributeIsDiscovered.cs new file mode 100644 index 0000000000..7e7834edbe --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsProblemDetails_IfNoAttributeIsDiscovered.cs @@ -0,0 +1,7 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.SymbolApiResponseMetadataProviderTest +{ + public class GetErrorResponseType_ReturnsProblemDetails_IfNoAttributeIsDiscoveredController + { + public void Action() { } + } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtAction.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtAction.cs new file mode 100644 index 0000000000..9e8f37d0c6 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtAction.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.SymbolApiResponseMetadataProviderTest +{ + [ProducesErrorResponseType(typeof(ModelStateDictionary))] + public class GetErrorResponseType_ReturnsTypeDefinedAtActionController + { + [ProducesErrorResponseType(typeof(GetErrorResponseType_ReturnsTypeDefinedAtActionModel))] + public void Action() { } + } + + public class GetErrorResponseType_ReturnsTypeDefinedAtActionModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtAssembly.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtAssembly.cs new file mode 100644 index 0000000000..b5199490d3 --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtAssembly.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ProducesErrorResponseType(typeof(Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.SymbolApiResponseMetadataProviderTest.GetErrorResponseType_ReturnsTypeDefinedAtAssemblyModel))] + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.SymbolApiResponseMetadataProviderTest +{ + public class GetErrorResponseType_ReturnsTypeDefinedAtAssemblyController + { + public void Action() { } + } + + public class GetErrorResponseType_ReturnsTypeDefinedAtAssemblyModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtController.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtController.cs new file mode 100644 index 0000000000..b731f3f0eb --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetErrorResponseType_ReturnsTypeDefinedAtController.cs @@ -0,0 +1,10 @@ +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.SymbolApiResponseMetadataProviderTest +{ + [ProducesErrorResponseType(typeof(GetErrorResponseType_ReturnsTypeDefinedAtControllerModel))] + public class GetErrorResponseType_ReturnsTypeDefinedAtControllerController + { + public void Action() { } + } + + public class GetErrorResponseType_ReturnsTypeDefinedAtControllerModel { } +} diff --git a/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs new file mode 100644 index 0000000000..389617609b --- /dev/null +++ b/src/Mvc/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs @@ -0,0 +1,93 @@ +// 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.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class GetResponseMetadata_ControllerWithoutConvention : ControllerBase + { + public ActionResult GetPerson(int id) => null; + + public ActionResult PostPerson(Person person) => null; + } + + public class GetResponseMetadata_ControllerActionWithAttributes : ControllerBase + { + [Produces(typeof(Person))] + public IActionResult ActionWithProducesAttribute(int id) => null; + + [ProducesResponseType(201)] + public IActionResult ActionWithProducesResponseType_StatusCodeInConstructor() => null; + + [ProducesResponseType(typeof(Person), 202)] + public IActionResult ActionWithProducesResponseType_StatusCodeAndTypeInConstructor() => null; + + [ProducesResponseType(200, StatusCode = 203)] + public IActionResult ActionWithProducesResponseType_StatusCodeInConstructorAndProperty() => null; + + [ProducesResponseType(typeof(object), 200, Type = typeof(Person), StatusCode = 201)] + public IActionResult ActionWithProducesResponseType_StatusCodeAndTypeInConstructorAndProperty() => null; + + [CustomResponseType(Type = typeof(Person), StatusCode = 204)] + public IActionResult ActionWithCustomApiResponseMetadataProvider() => null; + + [Produces201ResponseType] + public IActionResult ActionWithCustomProducesResponseTypeAttributeWithoutArguments() => null; + + [Produces201ResponseType(201)] + public IActionResult ActionWithCustomProducesResponseTypeAttributeWithArguments() => null; + + [CustomInvalidProducesResponseType(Type = typeof(Person), StatusCode = "204")] + public IActionResult ActionWithProducesResponseTypeWithIncorrectStatusCodeType() => null; + + [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Find))] + public IActionResult GetResponseMetadata_ReturnsValuesFromApiConventionMethodAttribute() => null; + + [ProducesResponseType(204)] + [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Find))] + public IActionResult GetResponseMetadata_WithProducesResponseTypeAndApiConventionMethod() => null; + } + + public class Person { } + + public class CustomResponseTypeAttribute : Attribute, IApiResponseMetadataProvider + { + public Type Type { get; set; } + + public int StatusCode { get; set; } + + public void SetContentTypes(MediaTypeCollection contentTypes) + { + } + } + + public class Produces201ResponseTypeAttribute : ProducesResponseTypeAttribute + { + public Produces201ResponseTypeAttribute() : base(201) { } + + public Produces201ResponseTypeAttribute(int statusCode) : base(statusCode) { } + } + + public class CustomInvalidProducesResponseTypeAttribute : ProducesResponseTypeAttribute + { + private string _statusCode; + + public CustomInvalidProducesResponseTypeAttribute() + : base(0) + { + } + + public new string StatusCode + { + get => _statusCode; + set + { + _statusCode = value; + base.StatusCode = int.Parse(value); + } + } + } +} diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/ActionDescriptorChangeProvider.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/ActionDescriptorChangeProvider.cs index abc1b3b8b3..266f99d3aa 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/ActionDescriptorChangeProvider.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/ActionDescriptorChangeProvider.cs @@ -9,20 +9,22 @@ namespace ApiExplorerWebSite { public class ActionDescriptorChangeProvider : IActionDescriptorChangeProvider { - private ActionDescriptorChangeProvider() + public ActionDescriptorChangeProvider(WellKnownChangeToken changeToken) { + ChangeToken = changeToken; } - public static ActionDescriptorChangeProvider Instance { get; } = new ActionDescriptorChangeProvider(); - - public CancellationTokenSource TokenSource { get; private set; } - - public bool HasChanged { get; set; } + public WellKnownChangeToken ChangeToken { get; } public IChangeToken GetChangeToken() { - TokenSource = new CancellationTokenSource(); - return new CancellationChangeToken(TokenSource.Token); + if (ChangeToken.TokenSource.IsCancellationRequested) + { + var changeTokenSource = new CancellationTokenSource(); + return new CancellationChangeToken(changeTokenSource.Token); + } + + return new CancellationChangeToken(ChangeToken.TokenSource.Token); } } } diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs index fdf64194eb..d365caf755 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs @@ -28,8 +28,8 @@ namespace ApiExplorerWebSite public void OnResourceExecuting(ResourceExecutingContext context) { - var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; - if (controllerActionDescriptor != null && controllerActionDescriptor.MethodInfo.IsDefined(typeof(PassThruAttribute))) + if (context.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor && + controllerActionDescriptor.MethodInfo.IsDefined(typeof(PassThruAttribute))) { return; } @@ -69,6 +69,8 @@ namespace ApiExplorerWebSite Name = parameter.Name, Source = parameter.Source.Id, Type = parameter.Type?.FullName, + DefaultValue = parameter.DefaultValue?.ToString(), + IsRequired = parameter.IsRequired, }; if (parameter.RouteInfo != null) @@ -143,6 +145,10 @@ namespace ApiExplorerWebSite public string Source { get; set; } public string Type { get; set; } + + public string DefaultValue { get; set; } + + public bool IsRequired { get; set; } } // Used to serialize data between client and server diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerRouteChangeConvention.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerRouteChangeConvention.cs new file mode 100644 index 0000000000..c329b28c42 --- /dev/null +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/ApiExplorerRouteChangeConvention.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace ApiExplorerWebSite +{ + public class ApiExplorerRouteChangeConvention : Attribute, IActionModelConvention + { + public ApiExplorerRouteChangeConvention(WellKnownChangeToken changeToken) + { + ChangeToken = changeToken; + } + + public WellKnownChangeToken ChangeToken { get; } + + public void Apply(ActionModel action) + { + if (action.Attributes.OfType().Any() && ChangeToken.TokenSource.IsCancellationRequested) + { + action.ActionName = "NewIndex"; + action.Selectors.Clear(); + action.Selectors.Add(new SelectorModel + { + AttributeRouteModel = new AttributeRouteModel + { + Template = "NewIndex" + } + }); + } + } + } +} diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerApiController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerApiController.cs index d45f0bb07a..a32ebed154 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerApiController.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerApiController.cs @@ -1,8 +1,9 @@ // 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; +using System.IO; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; namespace ApiExplorerWebSite { @@ -27,5 +28,8 @@ namespace ApiExplorerWebSite public void ActionWithFormFileCollectionParameter(IFormFileCollection formFile) { } + + [Produces("application/pdf", Type = typeof(Stream))] + public IActionResult ProducesWithUnsupportedContentType() => null; } } \ No newline at end of file diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerParametersController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerParametersController.cs index a422553574..e890e3d4d3 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerParametersController.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerParametersController.cs @@ -1,7 +1,9 @@ // 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.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace ApiExplorerWebSite.Controllers { @@ -28,5 +30,13 @@ namespace ApiExplorerWebSite.Controllers public void ComplexModel([FromQuery] OrderDTO order) { } + + public void DefaultValueParameters(string searchTerm, int top = 10, DayOfWeek searchDay = DayOfWeek.Wednesday) + { + } + + public void IsRequiredParameters([BindRequired] string requiredParam, string notRequiredParam, Product product) + { + } } } \ No newline at end of file diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerReloadableController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerReloadableController.cs index 31103ba0f2..d6372a8a86 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerReloadableController.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerReloadableController.cs @@ -1,45 +1,23 @@ // 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.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace ApiExplorerWebSite { [Route("ApiExplorerReload")] public class ApiExplorerReloadableController : Controller { - [ApiExplorerRouteChangeConvention] [Route("Index")] + [Reload] public string Index() => "Hello world"; [Route("Reload")] [PassThru] - public IActionResult Reload() + public IActionResult Reload([FromServices] WellKnownChangeToken changeToken) { - ActionDescriptorChangeProvider.Instance.HasChanged = true; - ActionDescriptorChangeProvider.Instance.TokenSource.Cancel(); + changeToken.TokenSource.Cancel(); return Ok(); } - - public class ApiExplorerRouteChangeConventionAttribute : Attribute, IActionModelConvention - { - public void Apply(ActionModel action) - { - if (ActionDescriptorChangeProvider.Instance.HasChanged) - { - action.ActionName = "NewIndex"; - action.Selectors.Clear(); - action.Selectors.Add(new SelectorModel - { - AttributeRouteModel = new AttributeRouteModel - { - Template = "NewIndex" - } - }); - } - } - } } } diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs new file mode 100644 index 0000000000..dcfb5a9892 --- /dev/null +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs @@ -0,0 +1,54 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace ApiExplorerWebSite +{ + [ApiController] + [Route("ApiExplorerResponseTypeWithApiConventionController/[Action]")] + [ApiConventionType(typeof(DefaultApiConventions))] + public class ApiExplorerResponseTypeWithApiConventionController : Controller + { + [HttpGet] + public Product GetProduct(int id) => null; + + [HttpGet] + public Task> GetTaskOfActionResultOfProduct(int id) => null; + + [HttpGet] + public IEnumerable GetProducts() => null; + + [HttpPost] + [Produces("application/json")] + [ProducesResponseType(202)] + [ProducesResponseType(403)] + public IActionResult PostWithConventions() => null; + + [HttpPost] + [Produces("application/json", "text/json")] + public IActionResult PostWithProduces(Product p) => null; + + [HttpPost] + public Task PostTaskOfProduct(Product p) => null; + + [HttpPut] + public Task Put(string id, Product product) => null; + + [HttpDelete] + public Task DeleteProductAsync(object id) => null; + + [HttpPost] + [ApiConventionMethod(typeof(CustomConventions), nameof(CustomConventions.CustomConventionMethod))] + public Task PostItem(Product p) => null; + } + + public static class CustomConventions + { + [ProducesResponseType(302)] + [ProducesResponseType(409)] + public static void CustomConventionMethod() { } + } +} diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityDisabledByConventionController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityDisabledByConventionController.cs index d29a11bc5f..be93692211 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityDisabledByConventionController.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityDisabledByConventionController.cs @@ -5,8 +5,8 @@ using Microsoft.AspNetCore.Mvc; namespace ApiExplorerWebSite { - [Route("ApiExplorerVisbilityDisabledByConvention")] - public class ApiExplorerVisbilityDisabledByConventionController : Controller + [Route("ApiExplorerVisibilityDisabledByConvention")] + public class ApiExplorerVisibilityDisabledByConventionController : Controller { [HttpGet] public void Get() diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityEnabledByConventionController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityEnabledByConventionController.cs index 7b674cb022..055e91e0ea 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityEnabledByConventionController.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityEnabledByConventionController.cs @@ -5,8 +5,8 @@ using Microsoft.AspNetCore.Mvc; namespace ApiExplorerWebSite { - [Route("ApiExplorerVisbilityEnabledByConvention")] - public class ApiExplorerVisbilityEnabledByConventionController : Controller + [Route("ApiExplorerVisibilityEnabledByConvention")] + public class ApiExplorerVisibilityEnabledByConventionController : Controller { [HttpGet] public void Get() diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Models/Product.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Models/Product.cs index 1844de7e13..245df86d8b 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Models/Product.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Models/Product.cs @@ -1,12 +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 System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.ModelBinding; + namespace ApiExplorerWebSite { public class Product { + [BindRequired] public int Id { get; set; } + [Required] public string Name { get; set; } } } \ No newline at end of file diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/ReloadAttribute.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/ReloadAttribute.cs new file mode 100644 index 0000000000..9cb7222539 --- /dev/null +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/ReloadAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace ApiExplorerWebSite +{ + public class ReloadAttribute : Attribute + { + } +} diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Startup.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Startup.cs index e794f807ef..08965fe0bf 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Startup.cs @@ -6,6 +6,7 @@ using System.Linq; using ApiExplorerWebSite.Controllers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -19,26 +20,30 @@ namespace ApiExplorerWebSite public void ConfigureServices(IServiceCollection services) { services.AddTransient(); + + var wellKnownChangeToken = new WellKnownChangeToken(); services.AddMvc(options => { options.Filters.AddService(typeof(ApiExplorerDataFilter)); options.Conventions.Add(new ApiExplorerVisibilityEnabledConvention()); options.Conventions.Add(new ApiExplorerVisibilityDisabledConvention( - typeof(ApiExplorerVisbilityDisabledByConventionController))); + typeof(ApiExplorerVisibilityDisabledByConventionController))); options.Conventions.Add(new ApiExplorerInboundOutboundConvention( typeof(ApiExplorerInboundOutBoundController))); + options.Conventions.Add(new ApiExplorerRouteChangeConvention(wellKnownChangeToken)); var jsonOutputFormatter = options.OutputFormatters.OfType().First(); options.OutputFormatters.Clear(); options.OutputFormatters.Add(jsonOutputFormatter); options.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter()); - }); + }) + .SetCompatibilityVersion(CompatibilityVersion.Latest); services.AddSingleton(); - services.AddSingleton(ActionDescriptorChangeProvider.Instance); - services.AddSingleton(ActionDescriptorChangeProvider.Instance); + services.AddSingleton(); + services.AddSingleton(wellKnownChangeToken); } public void Configure(IApplicationBuilder app) diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/WellKnownChangeToken.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/WellKnownChangeToken.cs new file mode 100644 index 0000000000..a862f4f88f --- /dev/null +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/WellKnownChangeToken.cs @@ -0,0 +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.Threading; + +namespace ApiExplorerWebSite +{ + public class WellKnownChangeToken + { + public CancellationTokenSource TokenSource { get; } = new CancellationTokenSource(); + } +} diff --git a/src/Mvc/test/WebSites/ApplicationModelWebSite/ApplicationModelWebSite.csproj b/src/Mvc/test/WebSites/ApplicationModelWebSite/ApplicationModelWebSite.csproj index cfda2f9c35..212a619b2e 100644 --- a/src/Mvc/test/WebSites/ApplicationModelWebSite/ApplicationModelWebSite.csproj +++ b/src/Mvc/test/WebSites/ApplicationModelWebSite/ApplicationModelWebSite.csproj @@ -10,5 +10,6 @@ + diff --git a/src/Mvc/test/WebSites/ApplicationModelWebSite/Startup.cs b/src/Mvc/test/WebSites/ApplicationModelWebSite/Startup.cs index 37e97287fb..899b3c13fa 100644 --- a/src/Mvc/test/WebSites/ApplicationModelWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/ApplicationModelWebSite/Startup.cs @@ -4,6 +4,7 @@ using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace ApplicationModelWebSite @@ -20,7 +21,8 @@ namespace ApplicationModelWebSite options.Conventions.Add(new FromHeaderConvention()); options.Conventions.Add(new MultipleAreasControllerConvention()); options.Conventions.Add(new CloneActionConvention()); - }); + }) + .SetCompatibilityVersion(CompatibilityVersion.Latest); } public void Configure(IApplicationBuilder app) diff --git a/src/Mvc/test/WebSites/BasicWebSite/BasicWebSite.csproj b/src/Mvc/test/WebSites/BasicWebSite/BasicWebSite.csproj index 55879651a0..2744228b2e 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/BasicWebSite.csproj +++ b/src/Mvc/test/WebSites/BasicWebSite/BasicWebSite.csproj @@ -4,17 +4,30 @@ $(StandardTestWebsiteTfms) + + + + + + + + + + diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/AntiforgeryController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/AntiforgeryController.cs index 7da1b783c2..415a34ba46 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/AntiforgeryController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/AntiforgeryController.cs @@ -1,6 +1,7 @@ // 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 BasicWebSite.Filters; using BasicWebSite.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -39,6 +40,16 @@ namespace BasicWebSite.Controllers return "OK"; } + // POST: /Antiforgery/LoginWithRedirectResultFilter + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + [TypeFilter(typeof(RedirectAntiforgeryValidationFailedResultFilter))] + public string LoginWithRedirectResultFilter(LoginViewModel model) + { + return "Ok"; + } + // GET: /Antiforgery/FlushAsyncLogin [AllowAnonymous] public ActionResult FlushAsyncLogin(string returnUrl = null) diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs index 77a757f1ff..c2d3400e1f 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs @@ -1,9 +1,6 @@ // 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 System.Threading; using System.Threading.Tasks; using BasicWebSite.Models; @@ -88,6 +85,47 @@ namespace BasicWebSite return foo; } + [HttpGet("[action]")] + public ActionResult ActionReturningStatusCodeResult() + { + return NotFound(); + } + + [HttpGet("[action]")] + public ActionResult ActionReturningProblemDetails() + { + return NotFound(new ProblemDetails + { + Title = "Not Found", + Type = "Type", + Detail = "Detail", + Status = 404, + Instance = "Instance", + Extensions = + { + ["tracking-id"] = 27, + }, + }); + } + + [HttpGet("[action]")] + public ActionResult ActionReturningValidationProblemDetails() + { + return BadRequest(new ValidationProblemDetails + { + Title = "Error", + Status = 400, + Extensions = + { + ["tracking-id"] = "27", + }, + Errors = + { + { "Error1", new[] { "Error Message" } }, + }, + }); + } + private class TestModelBinder : IModelBinder { public Task BindModelAsync(ModelBindingContext bindingContext) diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/PageRouteController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/PageRouteController.cs index 2022d5e221..2f2073be06 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/PageRouteController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/PageRouteController.cs @@ -9,14 +9,32 @@ namespace BasicWebSite.Controllers // without affecting view lookups. public class PageRouteController : Controller { + private readonly TestResponseGenerator _generator; + + public PageRouteController(TestResponseGenerator generator) + { + _generator = generator; + } + public IActionResult ConventionalRoute(string page) + { + return _generator.Generate("/PageRoute/ConventionalRoute/" + page); + } + + [HttpGet("/PageRoute/Attribute/{page}")] + public IActionResult AttributeRoute(string page) + { + return _generator.Generate("/PageRoute/Attribute/" + page); + } + + public IActionResult ConventionalRouteView(string page) { ViewData["page"] = page; return View(); } - [HttpGet("/PageRoute/Attribute/{page}")] - public IActionResult AttributeRoute(string page) + [HttpGet("/PageRoute/AttributeView/{page}")] + public IActionResult AttributeRouteView(string page) { ViewData["page"] = page; return View(); diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs index e7a89377b3..a7bd552217 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs @@ -10,8 +10,8 @@ namespace BasicWebSite { // This only matches a specific requestId value [HttpGet] - [RequestScopedActionConstraint("b40f6ec1-8a6b-41c1-b3fe-928f581ebaf5")] - public string FromActionConstraint() + [RequestScopedConstraint("b40f6ec1-8a6b-41c1-b3fe-928f581ebaf5")] + public string FromConstraint() { return "b40f6ec1-8a6b-41c1-b3fe-928f581ebaf5"; } diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RoutingController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RoutingController.cs index c5576b4685..e2c416505d 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RoutingController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RoutingController.cs @@ -1,49 +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 System.Linq; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.AspNetCore.Mvc.Filters; namespace BasicWebSite { public class RoutingController : Controller { - public object Conventional() + public ActionResult HasEndpointMatch() { - return GetData(); - } - - [Route("Routing/Attribute")] - public object Attribute() - { - return GetData(); - } - - public object DataTokens() - { - return GetData(); - } - - public override void OnActionExecuting(ActionExecutingContext context) - { - if (!context.RouteData.DataTokens.ContainsKey("actionName")) - { - context.RouteData.DataTokens.Add("actionName", ((ControllerActionDescriptor)context.ActionDescriptor).ActionName); - } - } - - private object GetData() - { - var routers = RouteData.Routers.Select(r => r.GetType().FullName).ToArray(); - var dataTokens = RouteData.DataTokens; - - return new - { - DataTokens = dataTokens, - Routers = routers - }; + var endpointFeature = HttpContext.Features.Get(); + return Json(endpointFeature?.Endpoint != null); } } } \ No newline at end of file diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/TestingController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/TestingController.cs index 07e76a3d6b..882ed935c9 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/TestingController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/TestingController.cs @@ -37,6 +37,32 @@ namespace BasicWebSite.Controllers return Ok(new RedirectHandlerResponse { Url = value, Body = number.Value }); } + [HttpGet("Testing/RedirectHandler/Headers")] + public IActionResult RedirectHandlerHeaders() + { + if (!Request.Headers.TryGetValue("X-Added-Header", out var value)) + { + return Content("No header present"); + } + else + { + return RedirectToAction(nameof(RedirectHandlerHeadersRedirect)); + } + } + + [HttpGet("Testing/RedirectHandler/Headers/Redirect")] + public IActionResult RedirectHandlerHeadersRedirect() + { + if (Request.Headers.TryGetValue("X-Added-Header", out var value)) + { + return Content("true"); + } + else + { + return Content("false"); + } + } + [HttpGet("Testing/AntiforgerySimulator/{value}")] public IActionResult AntiforgerySimulator([FromRoute]int value) { diff --git a/src/Mvc/test/WebSites/BasicWebSite/Filters/RedirectAntiforgeryValidationFailedResultFilter.cs b/src/Mvc/test/WebSites/BasicWebSite/Filters/RedirectAntiforgeryValidationFailedResultFilter.cs new file mode 100644 index 0000000000..ca7c61781b --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/Filters/RedirectAntiforgeryValidationFailedResultFilter.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Core.Infrastructure; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace BasicWebSite.Filters +{ + public class RedirectAntiforgeryValidationFailedResultFilter : IAlwaysRunResultFilter + { + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is IAntiforgeryValidationFailedResult result) + { + context.Result = new RedirectResult("http://example.com/antiforgery-redirect"); + } + } + + public void OnResultExecuted(ResultExecutedContext context) + { } + } +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/RequestIdService.cs b/src/Mvc/test/WebSites/BasicWebSite/Filters/RequestIdService.cs similarity index 100% rename from src/Mvc/test/WebSites/BasicWebSite/RequestIdService.cs rename to src/Mvc/test/WebSites/BasicWebSite/Filters/RequestIdService.cs diff --git a/src/Mvc/test/WebSites/BasicWebSite/RequestScopedActionConstraint.cs b/src/Mvc/test/WebSites/BasicWebSite/RequestScopedActionConstraint.cs index 3a5be47cf4..ed5a267e3b 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/RequestScopedActionConstraint.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/RequestScopedActionConstraint.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.DependencyInjection; namespace BasicWebSite { // Only matches when the requestId is the same as the one passed in the constructor. - public class RequestScopedActionConstraintAttribute : Attribute, IActionConstraintFactory + public class RequestScopedConstraintAttribute : Attribute, IActionConstraintFactory { private readonly string _requestId; private readonly Func CreateFactory = @@ -19,15 +19,20 @@ namespace BasicWebSite public bool IsReusable => false; - public RequestScopedActionConstraintAttribute(string requestId) + public RequestScopedConstraintAttribute(string requestId) { _requestId = requestId; } - public IActionConstraint CreateInstance(IServiceProvider services) + IActionConstraint IActionConstraintFactory.CreateInstance(IServiceProvider services) + { + return CreateInstanceCore(services); + } + + private Constraint CreateInstanceCore(IServiceProvider services) { var constraintType = typeof(Constraint); - return (Constraint)ActivatorUtilities.CreateInstance(services, typeof(Constraint),new[] { _requestId }); + return (Constraint)ActivatorUtilities.CreateInstance(services, typeof(Constraint), new[] { _requestId }); } private class Constraint : IActionConstraint @@ -43,7 +48,12 @@ namespace BasicWebSite public int Order { get; private set; } - public bool Accept(ActionConstraintContext context) + bool IActionConstraint.Accept(ActionConstraintContext context) + { + return AcceptCore(); + } + + private bool AcceptCore() { return _requestId == _requestIdService.RequestId; } diff --git a/src/Mvc/test/WebSites/BasicWebSite/Startup.cs b/src/Mvc/test/WebSites/BasicWebSite/Startup.cs index 9e20cb447c..4cd7646bfb 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Startup.cs @@ -1,12 +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.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; namespace BasicWebSite @@ -26,26 +26,13 @@ namespace BasicWebSite options.Conventions.Add(new ApplicationDescription("This is a basic website.")); // Filter that records a value in HttpContext.Items options.Filters.Add(new TraceResourceFilter()); + + // Remove when all URL generation tests are passing - https://github.com/aspnet/Routing/issues/590 + options.EnableEndpointRouting = false; }) .SetCompatibilityVersion(CompatibilityVersion.Latest) .AddXmlDataContractSerializerFormatters(); - services.Configure(options => - { - var previous = options.InvalidModelStateResponseFactory; - options.InvalidModelStateResponseFactory = context => - { - var result = (BadRequestObjectResult)previous(context); - if (context.ActionDescriptor.FilterDescriptors.Any(f => f.Filter is VndErrorAttribute)) - { - result.ContentTypes.Clear(); - result.ContentTypes.Add("application/vnd.error+json"); - } - - return result; - }; - }); - services.ConfigureBaseWebSiteAuthPolicies(); services.AddTransient(); @@ -56,6 +43,8 @@ namespace BasicWebSite services.AddSingleton(); services.AddScoped(); services.AddTransient(); + services.AddScoped(); + services.AddSingleton(); } public void Configure(IApplicationBuilder app) diff --git a/src/Mvc/test/WebSites/BasicWebSite/StartupRequestLimitSize.cs b/src/Mvc/test/WebSites/BasicWebSite/StartupRequestLimitSize.cs index 0338d0b217..066fc406da 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/StartupRequestLimitSize.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/StartupRequestLimitSize.cs @@ -5,6 +5,7 @@ using System; using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace BasicWebSite @@ -13,7 +14,8 @@ namespace BasicWebSite { public void ConfigureServices(IServiceCollection services) { - services.AddMvc(); + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest); services.ConfigureBaseWebSiteAuthPolicies(); } diff --git a/src/Mvc/test/WebSites/BasicWebSite/StartupWithCookieTempDataProviderAndCookieConsent.cs b/src/Mvc/test/WebSites/BasicWebSite/StartupWithCookieTempDataProviderAndCookieConsent.cs index 0fdb7383b6..db15febae3 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/StartupWithCookieTempDataProviderAndCookieConsent.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/StartupWithCookieTempDataProviderAndCookieConsent.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace BasicWebSite @@ -10,7 +11,8 @@ namespace BasicWebSite { public void ConfigureServices(IServiceCollection services) { - services.AddMvc(); + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest); services.Configure(o => { diff --git a/src/Mvc/test/WebSites/BasicWebSite/StartupWithCustomInvalidModelStateFactory.cs b/src/Mvc/test/WebSites/BasicWebSite/StartupWithCustomInvalidModelStateFactory.cs new file mode 100644 index 0000000000..27c7b58761 --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/StartupWithCustomInvalidModelStateFactory.cs @@ -0,0 +1,52 @@ +// 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.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace BasicWebSite +{ + public class StartupWithCustomInvalidModelStateFactory + { + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + services.AddAuthentication() + .AddScheme("Api", _ => { }); + + services + .AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest); + + services.Configure(options => + { + var previous = options.InvalidModelStateResponseFactory; + options.InvalidModelStateResponseFactory = context => + { + var result = (BadRequestObjectResult)previous(context); + if (context.ActionDescriptor.FilterDescriptors.Any(f => f.Filter is VndErrorAttribute)) + { + result.ContentTypes.Clear(); + result.ContentTypes.Add("application/vnd.error+json"); + } + + return result; + }; + }); + + services.ConfigureBaseWebSiteAuthPolicies(); + + services.AddLogging(); + services.AddSingleton(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseDeveloperExceptionPage(); + app.UseMvc(); + } + } +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/StartupWithEndpointRouting.cs b/src/Mvc/test/WebSites/BasicWebSite/StartupWithEndpointRouting.cs new file mode 100644 index 0000000000..01726c53e5 --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/StartupWithEndpointRouting.cs @@ -0,0 +1,46 @@ +// 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.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace BasicWebSite +{ + public class StartupWithEndpointRouting + { + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + services.AddRouting(); + + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest) // this compat version enables endpoint routing + .AddXmlDataContractSerializerFormatters(); + + services.ConfigureBaseWebSiteAuthPolicies(); + + services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + } + + public void Configure(IApplicationBuilder app) + { + // Initializes the RequestId service for each request + app.UseMiddleware(); + + app.UseMvc(routes => + { + routes.MapRoute( + "ActionAsMethod", + "{controller}/{action}", + defaults: new { controller = "Home", action = "Index" }); + + routes.MapRoute("PageRoute", "{controller}/{action}/{page}"); + }); + } + } +} \ No newline at end of file diff --git a/src/Mvc/test/WebSites/BasicWebSite/StartupWithSessionTempDataProvider.cs b/src/Mvc/test/WebSites/BasicWebSite/StartupWithSessionTempDataProvider.cs index c05e83cb95..1d868bc638 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/StartupWithSessionTempDataProvider.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/StartupWithSessionTempDataProvider.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace BasicWebSite @@ -13,7 +14,8 @@ namespace BasicWebSite // CookieTempDataProvider is the default ITempDataProvider, so we must override it with session. services .AddMvc() - .AddSessionStateTempDataProvider(); + .AddSessionStateTempDataProvider() + .SetCompatibilityVersion(CompatibilityVersion.Latest); services.AddSession(); services.ConfigureBaseWebSiteAuthPolicies(); diff --git a/src/Mvc/test/WebSites/BasicWebSite/Views/PageRoute/AttributeRoute.cshtml b/src/Mvc/test/WebSites/BasicWebSite/Views/PageRoute/AttributeRouteView.cshtml similarity index 100% rename from src/Mvc/test/WebSites/BasicWebSite/Views/PageRoute/AttributeRoute.cshtml rename to src/Mvc/test/WebSites/BasicWebSite/Views/PageRoute/AttributeRouteView.cshtml diff --git a/src/Mvc/test/WebSites/BasicWebSite/Views/PageRoute/ConventionalRoute.cshtml b/src/Mvc/test/WebSites/BasicWebSite/Views/PageRoute/ConventionalRouteView.cshtml similarity index 100% rename from src/Mvc/test/WebSites/BasicWebSite/Views/PageRoute/ConventionalRoute.cshtml rename to src/Mvc/test/WebSites/BasicWebSite/Views/PageRoute/ConventionalRouteView.cshtml diff --git a/src/Mvc/test/WebSites/VersioningWebSite/TestResponseGenerator.cs b/src/Mvc/test/WebSites/Common/TestResponseGenerator.cs similarity index 96% rename from src/Mvc/test/WebSites/VersioningWebSite/TestResponseGenerator.cs rename to src/Mvc/test/WebSites/Common/TestResponseGenerator.cs index d9ff985c34..69427de69a 100644 --- a/src/Mvc/test/WebSites/VersioningWebSite/TestResponseGenerator.cs +++ b/src/Mvc/test/WebSites/Common/TestResponseGenerator.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; @@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.DependencyInjection; -namespace VersioningWebSite +namespace Microsoft.AspNetCore.Mvc { // Generates a response based on the expected URL and action context public class TestResponseGenerator @@ -63,4 +63,4 @@ namespace VersioningWebSite return urlHelper; } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/WebSites/ControllersFromServicesWebSite/AssemblyMetadataReferenceFeatureProvider.cs b/src/Mvc/test/WebSites/ControllersFromServicesWebSite/AssemblyMetadataReferenceFeatureProvider.cs index 99c96ceb38..63f1877a45 100644 --- a/src/Mvc/test/WebSites/ControllersFromServicesWebSite/AssemblyMetadataReferenceFeatureProvider.cs +++ b/src/Mvc/test/WebSites/ControllersFromServicesWebSite/AssemblyMetadataReferenceFeatureProvider.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis; namespace ControllersFromServicesWebSite { +#pragma warning disable CS0618 // Type or member is obsolete public class AssemblyMetadataReferenceFeatureProvider : IApplicationFeatureProvider { public void PopulateFeature(IEnumerable parts, MetadataReferenceFeature feature) @@ -17,4 +18,5 @@ namespace ControllersFromServicesWebSite feature.MetadataReferences.Add(MetadataReference.CreateFromFile(currentAssembly.Location)); } } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/Mvc/test/WebSites/ControllersFromServicesWebSite/ControllersFromServicesWebSite.csproj b/src/Mvc/test/WebSites/ControllersFromServicesWebSite/ControllersFromServicesWebSite.csproj index 24bd4fa3f8..022617ccd6 100644 --- a/src/Mvc/test/WebSites/ControllersFromServicesWebSite/ControllersFromServicesWebSite.csproj +++ b/src/Mvc/test/WebSites/ControllersFromServicesWebSite/ControllersFromServicesWebSite.csproj @@ -12,5 +12,6 @@ + diff --git a/src/Mvc/test/WebSites/ControllersFromServicesWebSite/Startup.cs b/src/Mvc/test/WebSites/ControllersFromServicesWebSite/Startup.cs index 05653ebde6..b11534fedb 100644 --- a/src/Mvc/test/WebSites/ControllersFromServicesWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/ControllersFromServicesWebSite/Startup.cs @@ -11,7 +11,7 @@ using ControllersFromServicesWebSite.Components; using ControllersFromServicesWebSite.TagHelpers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; @@ -36,7 +36,8 @@ namespace ControllersFromServicesWebSite }) .AddControllersAsServices() .AddViewComponentsAsServices() - .AddTagHelpersAsServices(); + .AddTagHelpersAsServices() + .SetCompatibilityVersion(CompatibilityVersion.Latest); services.AddTransient(); services.AddTransient(); diff --git a/src/Mvc/test/WebSites/CorsWebSite/Program.cs b/src/Mvc/test/WebSites/CorsWebSite/Program.cs new file mode 100644 index 0000000000..b58c5ff2f4 --- /dev/null +++ b/src/Mvc/test/WebSites/CorsWebSite/Program.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.AspNetCore.Hosting; + +namespace CorsWebSite +{ + public class Program + { + public static void Main(string[] args) + { + var host = CreateWebHostBuilder(args) + .Build(); + + host.Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + new WebHostBuilder() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseKestrel() + .UseIISIntegration(); + } +} diff --git a/src/Mvc/test/WebSites/CorsWebSite/Startup.cs b/src/Mvc/test/WebSites/CorsWebSite/Startup.cs index 6ee44e7f39..6ef084b83b 100644 --- a/src/Mvc/test/WebSites/CorsWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/CorsWebSite/Startup.cs @@ -1,10 +1,9 @@ // 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 Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace CorsWebSite @@ -13,7 +12,8 @@ namespace CorsWebSite { public void ConfigureServices(IServiceCollection services) { - services.AddMvc(); + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest); services.Configure(options => { options.AddPolicy( @@ -76,20 +76,5 @@ namespace CorsWebSite { app.UseMvc(); } - - public static void Main(string[] args) - { - var host = CreateWebHostBuilder(args) - .Build(); - - host.Run(); - } - - public static IWebHostBuilder CreateWebHostBuilder(string[] args) => - new WebHostBuilder() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseStartup() - .UseKestrel() - .UseIISIntegration(); } } diff --git a/src/Mvc/test/WebSites/CorsWebSite/StartupWith21Compat.cs b/src/Mvc/test/WebSites/CorsWebSite/StartupWith21Compat.cs new file mode 100644 index 0000000000..b34b71f80c --- /dev/null +++ b/src/Mvc/test/WebSites/CorsWebSite/StartupWith21Compat.cs @@ -0,0 +1,80 @@ +// 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.Builder; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace CorsWebSite +{ + public class StartupWith21Compat + { + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + services.Configure(options => + { + options.AddPolicy( + "AllowAnySimpleRequest", + builder => + { + builder.AllowAnyOrigin() + .WithMethods("GET", "POST", "HEAD"); + }); + + options.AddPolicy( + "AllowSpecificOrigin", + builder => + { + builder.WithOrigins("http://example.com"); + }); + + options.AddPolicy( + "WithCredentials", + builder => + { + builder.AllowCredentials() + .WithOrigins("http://example.com"); + }); + + options.AddPolicy( + "WithCredentialsAnyOrigin", + builder => + { + builder.AllowCredentials() + .AllowAnyOrigin() + .AllowAnyHeader() + .WithMethods("PUT", "POST") + .WithExposedHeaders("exposed1", "exposed2"); + }); + + options.AddPolicy( + "AllowAll", + builder => + { + builder.AllowCredentials() + .AllowAnyMethod() + .AllowAnyHeader() + .AllowAnyOrigin(); + }); + + options.AddPolicy( + "Allow example.com", + builder => + { + builder.AllowCredentials() + .AllowAnyMethod() + .AllowAnyHeader() + .WithOrigins("http://example.com"); + }); + }); + } + + public void Configure(IApplicationBuilder app) + { + app.UseMvc(); + } + } +} diff --git a/src/Mvc/test/WebSites/Directory.Build.props b/src/Mvc/test/WebSites/Directory.Build.props index ed8c546e2a..2ff6b1fb54 100644 --- a/src/Mvc/test/WebSites/Directory.Build.props +++ b/src/Mvc/test/WebSites/Directory.Build.props @@ -3,9 +3,9 @@ - netcoreapp2.1 + netcoreapp2.2 $(DeveloperBuildTestWebsiteTfms) - netcoreapp2.1 + netcoreapp2.2 $(StandardTestWebsiteTfms);net461 diff --git a/src/Mvc/test/WebSites/ErrorPageMiddlewareWebSite/Startup.cs b/src/Mvc/test/WebSites/ErrorPageMiddlewareWebSite/Startup.cs index 864fce79ea..3fc459a06f 100644 --- a/src/Mvc/test/WebSites/ErrorPageMiddlewareWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/ErrorPageMiddlewareWebSite/Startup.cs @@ -4,6 +4,7 @@ using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace ErrorPageMiddlewareWebSite @@ -13,7 +14,8 @@ namespace ErrorPageMiddlewareWebSite // Set up application services public void ConfigureServices(IServiceCollection services) { - services.AddMvc(); + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest); } public void Configure(IApplicationBuilder app) diff --git a/src/Mvc/test/WebSites/FilesWebSite/Startup.cs b/src/Mvc/test/WebSites/FilesWebSite/Startup.cs index aaa363af54..3e1404b3ff 100644 --- a/src/Mvc/test/WebSites/FilesWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/FilesWebSite/Startup.cs @@ -4,6 +4,7 @@ using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace FilesWebSite @@ -13,7 +14,8 @@ namespace FilesWebSite // Set up application services public void ConfigureServices(IServiceCollection services) { - services.AddMvc(); + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest); } public void Configure(IApplicationBuilder app) diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/PolymorhpicPropertyBindingController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/PolymorphicPropertyBindingController.cs similarity index 90% rename from src/Mvc/test/WebSites/FormatterWebSite/Controllers/PolymorhpicPropertyBindingController.cs rename to src/Mvc/test/WebSites/FormatterWebSite/Controllers/PolymorphicPropertyBindingController.cs index 27a2643cfc..2d428f9f4f 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/PolymorhpicPropertyBindingController.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/PolymorphicPropertyBindingController.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc; namespace FormatterWebSite.Controllers { - public class PolymorhpicPropertyBindingController : ControllerBase + public class PolymorphicPropertyBindingController : ControllerBase { [FromBody] public IModel Person { get; set; } diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/TestApiController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/TestApiController.cs new file mode 100644 index 0000000000..832a5917c6 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/WebSites/FormatterWebSite/Controllers/ValidationController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/ValidationController.cs index efc2441c22..5aee43c803 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/ValidationController.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/ValidationController.cs @@ -67,5 +67,22 @@ namespace FormatterWebSite { return Json(simpleTypePropertiesModel); } + + [HttpPost] + public IActionResult ValidationProviderAttribute([FromBody] ValidationProviderAttributeModel validationProviderAttributeModel) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + return Ok(); + } + + [HttpPost] + public IActionResult ValidationThrowsError_WhenValidationExceedsMaxValidationDepth([FromBody] InfinitelyRecursiveModel model) + { + return Ok(); + } } } \ No newline at end of file diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Models/BookModelWithNoValidation.cs b/src/Mvc/test/WebSites/FormatterWebSite/Models/BookModelWithNoValidation.cs new file mode 100644 index 0000000000..ad13253e03 --- /dev/null +++ b/src/Mvc/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/src/Mvc/test/WebSites/FormatterWebSite/Models/InfinitelyRecursiveModel.cs b/src/Mvc/test/WebSites/FormatterWebSite/Models/InfinitelyRecursiveModel.cs new file mode 100644 index 0000000000..370151143e --- /dev/null +++ b/src/Mvc/test/WebSites/FormatterWebSite/Models/InfinitelyRecursiveModel.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Newtonsoft.Json; + +namespace FormatterWebSite +{ + public class InfinitelyRecursiveModel + { + [JsonConverter(typeof(StringIdentifierConverter))] + public RecursiveIdentifier Id { get; set; } + + private class StringIdentifierConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(RecursiveIdentifier); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return new RecursiveIdentifier(reader.Value.ToString()); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs b/src/Mvc/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs new file mode 100644 index 0000000000..49e8ab2e91 --- /dev/null +++ b/src/Mvc/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs @@ -0,0 +1,27 @@ +// 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 : IValidatableObject + { + public RecursiveIdentifier(string identifier) + { + Value = identifier; + } + + 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/src/Mvc/test/WebSites/FormatterWebSite/Models/ValidationProviderAttributeModel.cs b/src/Mvc/test/WebSites/FormatterWebSite/Models/ValidationProviderAttributeModel.cs new file mode 100644 index 0000000000..05a3a0da75 --- /dev/null +++ b/src/Mvc/test/WebSites/FormatterWebSite/Models/ValidationProviderAttributeModel.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.DataAnnotations; + +namespace FormatterWebSite +{ + public class ValidationProviderAttributeModel + { + [FirstName] + public string FirstName { get; set; } + + [StringLength(maximumLength: 5)] + [LastName] + public string LastName { get; set; } + } + + public class FirstNameAttribute : ValidationProviderAttribute + { + public override IEnumerable GetValidationAttributes() + { + return new List + { + new RequiredAttribute(), + new RegularExpressionAttribute(pattern: "[A-Za-z]*"), + new StringLengthAttribute(maximumLength: 5) + }; + } + } + + public class LastNameAttribute : ValidationProviderAttribute + { + public override IEnumerable GetValidationAttributes() + { + return new List + { + new RequiredAttribute() + }; + } + } +} diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Startup.cs b/src/Mvc/test/WebSites/FormatterWebSite/Startup.cs index ee2744a028..c1b5895cea 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Startup.cs @@ -19,12 +19,12 @@ namespace FormatterWebSite options.InputFormatters.Add(new StringInputFormatter()); }) - .AddXmlDataContractSerializerFormatters(); + .AddXmlDataContractSerializerFormatters() + .SetCompatibilityVersion(CompatibilityVersion.Latest); services.Configure(options => { options.SerializerSettings.Converters.Insert(0, new IModelConverter()); }); } - public void Configure(IApplicationBuilder app) { app.UseMvc(routes => diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml new file mode 100644 index 0000000000..fc6b995ab1 --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml @@ -0,0 +1,3 @@ +@page + + \ No newline at end of file diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml new file mode 100644 index 0000000000..c3c8ecd2cb --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml @@ -0,0 +1,3 @@ +@page + +
\ No newline at end of file diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml new file mode 100644 index 0000000000..dee9334704 --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml @@ -0,0 +1 @@ +Hello from fallback \ No newline at end of file diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..a757b413b9 --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs index 27dd20482f..184055fb75 100644 --- a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs @@ -238,6 +238,11 @@ namespace HtmlGenerationWebSite.Controllers return View(); } + public IActionResult ValidationProviderAttribute() => View(); + + [HttpPost] + public IActionResult ValidationProviderAttribute(ValidationProviderAttributeModel model) => View(model); + public IActionResult PartialTagHelperWithoutModel() => View(); public IActionResult StatusMessage() => View(new StatusMessageModel { Message = "Some status message"}); diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/HtmlGenerationWebSite.csproj b/src/Mvc/test/WebSites/HtmlGenerationWebSite/HtmlGenerationWebSite.csproj index cfda2f9c35..212a619b2e 100644 --- a/src/Mvc/test/WebSites/HtmlGenerationWebSite/HtmlGenerationWebSite.csproj +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/HtmlGenerationWebSite.csproj @@ -10,5 +10,6 @@ + diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Models/ValidationProviderAttributeModel.cs b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Models/ValidationProviderAttributeModel.cs new file mode 100644 index 0000000000..4c6669f060 --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Models/ValidationProviderAttributeModel.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.DataAnnotations; + +namespace HtmlGenerationWebSite.Models +{ + public class ValidationProviderAttributeModel + { + [FirstName] + public string FirstName { get; set; } + + [StringLength(maximumLength: 6)] + [LastName] + public string LastName { get; set; } + } + + public class FirstNameAttribute : ValidationProviderAttribute + { + public override IEnumerable GetValidationAttributes() + { + return new List + { + new RequiredAttribute(), + new RegularExpressionAttribute(pattern: "[A-Za-z]*"), + new StringLengthAttribute(maximumLength: 5) + }; + } + } + + public class LastNameAttribute : ValidationProviderAttribute + { + public override IEnumerable GetValidationAttributes() + { + return new List + { + new RequiredAttribute() + }; + } + } +} diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Pages/CacheTagHelper_VaryByCulture.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Pages/CacheTagHelper_VaryByCulture.cshtml new file mode 100644 index 0000000000..97cb7f7d52 --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Pages/CacheTagHelper_VaryByCulture.cshtml @@ -0,0 +1,15 @@ +@page +@addTagHelper *, HtmlGenerationWebSite +@using System.Globalization +@functions +{ + [BindProperty(SupportsGet = true)] + public int CorrelationId { get; set; } +} + +

@CultureInfo.CurrentCulture

+

@CultureInfo.CurrentUICulture

+@CorrelationId + + @CorrelationId + diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Pages/_ViewImports.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..a757b413b9 --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Pages/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Startup.cs b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Startup.cs index a0c93159d6..a1b2a64b26 100644 --- a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Startup.cs @@ -4,6 +4,7 @@ using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.TagHelpers; using Microsoft.Extensions.DependencyInjection; @@ -16,7 +17,9 @@ namespace HtmlGenerationWebSite { // Add MVC services to the services container. Change default FormTagHelper.AntiForgery to false. Usually // null which is interpreted as true unless element includes an action attribute. - services.AddMvc().InitializeTagHelper((helper, _) => helper.Antiforgery = false); + services.AddMvc() + .InitializeTagHelper((helper, _) => helper.Antiforgery = false) + .SetCompatibilityVersion(CompatibilityVersion.Latest); services.AddSingleton(typeof(ISignalTokenProviderService<>), typeof(SignalTokenProviderService<>)); services.AddSingleton(); diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/StartupWith21CompatibilityBehavior.cs b/src/Mvc/test/WebSites/HtmlGenerationWebSite/StartupWith21CompatibilityBehavior.cs new file mode 100644 index 0000000000..5c4fce1ed8 --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/StartupWith21CompatibilityBehavior.cs @@ -0,0 +1,55 @@ +// 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 Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.TagHelpers; +using Microsoft.Extensions.DependencyInjection; + +namespace HtmlGenerationWebSite +{ + public class StartupWith21CompatibilityBehavior + { + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + // Add MVC services to the services container. Change default FormTagHelper.AntiForgery to false. Usually + // null which is interpreted as true unless element includes an action attribute. + services.AddMvc() + .InitializeTagHelper((helper, _) => helper.Antiforgery = false) + .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + + services.AddSingleton(typeof(ISignalTokenProviderService<>), typeof(SignalTokenProviderService<>)); + services.AddSingleton(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseStaticFiles(); + app.UseMvc(routes => + { + routes.MapRoute( + name: "areaRoute", + template: "{area:exists}/{controller}/{action}/{id?}", + defaults: new { action = "Index" }); + routes.MapRoute( + name: "productRoute", + template: "Product/{action}", + defaults: new { controller = "Product" }); + routes.MapRoute( + name: "default", + template: "{controller}/{action}/{id?}", + defaults: new { controller = "HtmlGeneration_Home", action = "Index" }); + }); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + new WebHostBuilder() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseKestrel() + .UseIISIntegration(); + } +} diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/StartupWithCultureReplace.cs b/src/Mvc/test/WebSites/HtmlGenerationWebSite/StartupWithCultureReplace.cs new file mode 100644 index 0000000000..1214b96f88 --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/StartupWithCultureReplace.cs @@ -0,0 +1,45 @@ +// 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.Globalization; +using System.IO; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace HtmlGenerationWebSite +{ + public class StartupWithCultureReplace + { + private readonly Startup Startup = new Startup(); + + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + services.AddLocalization(); + Startup.ConfigureServices(services); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRequestLocalization(options => + { + options.SupportedCultures.Add(new CultureInfo("fr-FR")); + options.SupportedCultures.Add(new CultureInfo("en-GB")); + + options.SupportedUICultures.Add(new CultureInfo("fr-FR")); + options.SupportedUICultures.Add(new CultureInfo("fr-CA")); + options.SupportedUICultures.Add(new CultureInfo("en-GB")); + }); + + Startup.Configure(app); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + new WebHostBuilder() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseKestrel() + .UseIISIntegration(); + } +} diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/TestCacheTagHelper.cs b/src/Mvc/test/WebSites/HtmlGenerationWebSite/TestCacheTagHelper.cs new file mode 100644 index 0000000000..e8f710d17e --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/TestCacheTagHelper.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.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.TagHelpers; +using Microsoft.AspNetCore.Mvc.TagHelpers.Cache; +using Microsoft.AspNetCore.Mvc.TagHelpers.Internal; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Logging; + +namespace HtmlGenerationWebSite +{ + // This TagHelper enables us to investigate potential flakiness in the test that uses this tracked by https://github.com/aspnet/Mvc/issues/8281 + public class TestCacheTagHelper : CacheTagHelper + { + private readonly ILogger _logger; + + public TestCacheTagHelper( + CacheTagHelperMemoryCacheFactory factory, + HtmlEncoder htmlEncoder, + ILoggerFactory loggerFactory) : base(factory, htmlEncoder) + { + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + _logger = loggerFactory.CreateLogger(); + } + + public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + var cacheKey = new CacheTagKey(this, context); + if (MemoryCache.TryGetValue(cacheKey, out var _)) + { + _logger.LogInformation("Cache entry exists with key: " + cacheKey.GenerateKey()); + } + else + { + _logger.LogInformation("Cache entry does NOT exist with key: " + cacheKey.GenerateKey()); + } + + return base.ProcessAsync(context, output); + } + } +} diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Index.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Index.cshtml index c505df763e..24830788f5 100644 --- a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Index.cshtml +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Index.cshtml @@ -19,7 +19,7 @@ Default Controller
Product Submit Fragment @@ -46,7 +46,7 @@
diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Link.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Link.cshtml index 09f614154a..dbd8dd6272 100644 --- a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Link.cshtml +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Link.cshtml @@ -46,6 +46,35 @@ asp-fallback-test-property="visibility" asp-fallback-test-value="hidden" /> + + + + + + + + + - + // Fallback to globbed src + + + + + +