diff --git a/AspNetCore.sln b/AspNetCore.sln index 1805c5617512..52fe9c9b652c 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1584,7 +1584,7 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "aspnetcoreCA", "src\Install EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "iisca", "src\Installers\Windows\AspNetCoreModule-Setup\IIS-Setup\iisca\lib\iisca.vcxproj", "{7324770C-0871-4D73-BE3D-5E2F3E9E1B1E}" EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CommonLib", "src\Installers\Windows\AspNetCoreModule-Setup\IIS-Setup\IIS-Common\lib\IISSetup.CommonLib.vcxproj", "{B54A8F61-60DE-4AD9-87CA-D102F230678E}" +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "IISSetup.CommonLib", "src\Installers\Windows\AspNetCoreModule-Setup\IIS-Setup\IIS-Common\lib\IISSetup.CommonLib.vcxproj", "{B54A8F61-60DE-4AD9-87CA-D102F230678E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspNetCoreModule-Setup", "AspNetCoreModule-Setup", "{D30A658D-61F6-444B-9AC7-F66A1A1B86B6}" EndProject @@ -1812,6 +1812,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Assets", "Assets", "{2B858B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.App.Internal.Assets", "src\Assets\Microsoft.AspNetCore.App.Internal.Assets.csproj", "{2AAE7819-BC3E-48F4-9CFA-5DD4CD5FFD62}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenApi", "OpenApi", "{96EC4DD3-028E-6E27-5B14-08C21B07CE89}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gen", "gen", "{1BBD75D2-429D-D565-A98E-36437448E8C0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.SourceGenerators", "src\OpenApi\gen\Microsoft.AspNetCore.OpenApi.SourceGenerators.csproj", "{C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{4C4590E4-54F5-3693-9633-60A8DCA89385}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests", "src\OpenApi\test\Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests\Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests.csproj", "{51BE4B37-D772-4BFA-8DAE-CF18730BEB89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.Build.Tests", "src\OpenApi\test\Microsoft.AspNetCore.OpenApi.Build.Tests\Microsoft.AspNetCore.OpenApi.Build.Tests.csproj", "{10F96346-4215-4642-B837-0B5B08AC27AE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10959,6 +10971,54 @@ Global {2AAE7819-BC3E-48F4-9CFA-5DD4CD5FFD62}.Release|x64.Build.0 = Release|Any CPU {2AAE7819-BC3E-48F4-9CFA-5DD4CD5FFD62}.Release|x86.ActiveCfg = Release|Any CPU {2AAE7819-BC3E-48F4-9CFA-5DD4CD5FFD62}.Release|x86.Build.0 = Release|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Debug|arm64.ActiveCfg = Debug|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Debug|arm64.Build.0 = Debug|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Debug|x64.ActiveCfg = Debug|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Debug|x64.Build.0 = Debug|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Debug|x86.ActiveCfg = Debug|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Debug|x86.Build.0 = Debug|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Release|Any CPU.Build.0 = Release|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Release|arm64.ActiveCfg = Release|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Release|arm64.Build.0 = Release|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Release|x64.ActiveCfg = Release|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Release|x64.Build.0 = Release|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Release|x86.ActiveCfg = Release|Any CPU + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2}.Release|x86.Build.0 = Release|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Debug|arm64.ActiveCfg = Debug|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Debug|arm64.Build.0 = Debug|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Debug|x64.ActiveCfg = Debug|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Debug|x64.Build.0 = Debug|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Debug|x86.ActiveCfg = Debug|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Debug|x86.Build.0 = Debug|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Release|Any CPU.Build.0 = Release|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Release|arm64.ActiveCfg = Release|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Release|arm64.Build.0 = Release|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Release|x64.ActiveCfg = Release|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Release|x64.Build.0 = Release|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Release|x86.ActiveCfg = Release|Any CPU + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89}.Release|x86.Build.0 = Release|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Debug|arm64.ActiveCfg = Debug|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Debug|arm64.Build.0 = Debug|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Debug|x64.ActiveCfg = Debug|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Debug|x64.Build.0 = Debug|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Debug|x86.ActiveCfg = Debug|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Debug|x86.Build.0 = Debug|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Release|Any CPU.Build.0 = Release|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Release|arm64.ActiveCfg = Release|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Release|arm64.Build.0 = Release|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Release|x64.ActiveCfg = Release|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Release|x64.Build.0 = Release|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Release|x86.ActiveCfg = Release|Any CPU + {10F96346-4215-4642-B837-0B5B08AC27AE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11745,116 +11805,12 @@ Global {7324770C-0871-4D73-BE3D-5E2F3E9E1B1E} = {D30A658D-61F6-444B-9AC7-F66A1A1B86B6} {B54A8F61-60DE-4AD9-87CA-D102F230678E} = {D30A658D-61F6-444B-9AC7-F66A1A1B86B6} {D30A658D-61F6-444B-9AC7-F66A1A1B86B6} = {5E46DC83-C39C-4E3A-B242-C064607F4367} - {5244BC49-2568-4701-80A6-EAB8950AB5FA} = {31854B06-DC6B-4416-97C3-559CC390B8B4} - {6F1B115C-1903-40CB-837D-7961AB610F4E} = {5E46DC83-C39C-4E3A-B242-C064607F4367} - {56DFE643-7F0F-40C8-9F7F-8EA5357781EF} = {3CBC4802-E9B8-48B7-BC8C-B0AFB9EEC643} - {E0BE6B86-F8DB-405D-AC05-78C8C9D3857D} = {C3722C5D-E159-4AB3-AF60-769185B31B47} - {8EB0B983-8851-4565-B92F-366F1B126E61} = {C3722C5D-E159-4AB3-AF60-769185B31B47} - {93D3CC76-9FA9-4198-B49D-54BA918105EE} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} - {6C06163A-80E9-49C1-817C-B391852BA563} = {0B200A66-B809-4ED3-A790-CB1C2E80975E} - {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} = {6C06163A-80E9-49C1-817C-B391852BA563} - {825BCF97-67A9-4834-B3A8-C3DC97A90E41} = {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} - {DC349A25-0DBF-4468-99E1-B95C22D3A7EF} = {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} - {AA5ABFBC-177C-421E-B743-005E0FD1248B} = {E5963C9F-20A6-4385-B364-814D2581FADF} - {5D5A3B60-A014-447C-9126-B1FA6C821C8D} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} - {B5AC1D8B-9D43-4261-AE0F-6B7574656F2C} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} - {C3FFA4E4-0E7E-4866-A15F-034245BFD800} = {B5AC1D8B-9D43-4261-AE0F-6B7574656F2C} - {5465F96F-33D5-454E-9C40-494E58AEEE5D} = {E5963C9F-20A6-4385-B364-814D2581FADF} - {97996D39-7722-4AFC-A41A-AD61CA7A413D} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} - {37144E52-611B-40E8-807C-2821F5A814CB} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} - {559FE354-7E08-4310-B4F3-AE30F34DEED5} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} - {94F95276-7CDF-44A8-B159-D09702EF6794} = {0B200A66-B809-4ED3-A790-CB1C2E80975E} - {EA7D844B-C180-41C7-9D55-273AD88BF71F} = {94F95276-7CDF-44A8-B159-D09702EF6794} - {7A331A1C-E2C4-4E37-B0A0-B5AA10661229} = {8DAC59BE-CB96-4F04-909C-56C22E7665EB} - {DD076DDA-7956-4361-A7D4-2B8025AB3DFD} = {8DAC59BE-CB96-4F04-909C-56C22E7665EB} - {5CDB8ABC-9DD0-4A9F-8948-EED5FFE89F67} = {DD076DDA-7956-4361-A7D4-2B8025AB3DFD} - {151E6F9E-107B-4DDC-A2B1-95115801FD14} = {DD076DDA-7956-4361-A7D4-2B8025AB3DFD} - {B43BE3EB-9846-4484-88D8-05165202A0FC} = {DD076DDA-7956-4361-A7D4-2B8025AB3DFD} - {9A8AE587-A3DB-4211-8354-430C4CCBEB9B} = {B43BE3EB-9846-4484-88D8-05165202A0FC} - {2E28881D-A188-47AF-800A-B5877AD8C288} = {9A8AE587-A3DB-4211-8354-430C4CCBEB9B} - {A53696E8-6065-41BA-84FB-E89E0DACFF6C} = {9A8AE587-A3DB-4211-8354-430C4CCBEB9B} - {109C702D-DACE-4F82-A490-15E5AFA94005} = {151E6F9E-107B-4DDC-A2B1-95115801FD14} - {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11} = {151E6F9E-107B-4DDC-A2B1-95115801FD14} - {90CF4DC6-AC53-459F-9EAB-623A11EADAA3} = {B43BE3EB-9846-4484-88D8-05165202A0FC} - {F18E97AE-3A3F-424D-8DC2-4D001A167F98} = {B43BE3EB-9846-4484-88D8-05165202A0FC} - {8C3E422A-F281-4B93-A567-88C7A1ED0412} = {B43BE3EB-9846-4484-88D8-05165202A0FC} - {EB14F068-AD55-4970-B9B4-1FBE33704243} = {5CDB8ABC-9DD0-4A9F-8948-EED5FFE89F67} - {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} - {489020F2-80D9-4468-A5D3-07E785837A5D} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} - {8FED7E65-A7DD-4F13-8980-BF03E77B6C85} = {489020F2-80D9-4468-A5D3-07E785837A5D} - {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} - {9716D0D0-2251-44DD-8596-67D253EEF41C} = {323C3EB6-1D15-4B3D-918D-699D7F64DED9} - {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} - {EFC8EA45-572D-4D8D-A597-9045A2D8EC40} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} - {8EE73488-2B92-42BD-96C9-0DD65405C828} = {1D865E78-7A66-4CA9-92EE-2B350E45281F} - {41FF4F96-98D2-4482-A2A7-4B179E80D285} = {1D865E78-7A66-4CA9-92EE-2B350E45281F} - {51D07AA9-6297-4F66-A7BD-71CE7E3F4A3F} = {0F84F170-57D0-496B-8E2C-7984178EF69F} - {487EF7BE-5009-4C70-B79E-45519BDD9603} = {412D4C15-F48F-4DB1-940A-131D1AA87088} - {1D865E78-7A66-4CA9-92EE-2B350E45281F} = {E5963C9F-20A6-4385-B364-814D2581FADF} - {3309FA1E-4E95-49E9-BE2A-827D01FD63C0} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} - {B34CB502-0286-4939-B25F-45998528A802} = {AB4B9E75-719C-4589-B852-20FBFD727730} - {AB4B9E75-719C-4589-B852-20FBFD727730} = {0B200A66-B809-4ED3-A790-CB1C2E80975E} - {7F079E92-32D5-4257-B95B-CFFB0D49C160} = {7FD32066-C831-4E29-978C-9A2215E85C67} - {89896261-C5DD-4901-BCA7-7A5F718BC008} = {AB4B9E75-719C-4589-B852-20FBFD727730} - {F0FBA346-D8BC-4FAE-A4B2-85B33FA23055} = {08D53E58-4AAE-40C4-8497-63EC8664F304} - {AA7445F5-BD28-400C-8507-E2E0D3CF7D7E} = {08D53E58-4AAE-40C4-8497-63EC8664F304} - {A5946454-4788-4871-8F23-A9471D55F115} = {4FDDC525-4E60-4CAF-83A3-261C5B43721F} - {76C3E22D-092B-4E8A-81F0-DCF071BFF4CD} = {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} - {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} - {7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9} = {08D53E58-4AAE-40C4-8497-63EC8664F304} - {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9} = {1D865E78-7A66-4CA9-92EE-2B350E45281F} - {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853} = {7B976D8F-EA31-4C0B-97BD-DFD9B3CC86FB} - {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF} = {E19E55A2-1562-47A7-8EA6-B51F2CA0CC4C} - {BA649043-EF2B-42DC-B422-A46127BE8296} = {7B976D8F-EA31-4C0B-97BD-DFD9B3CC86FB} - {3A6FD623-F7F3-404B-8A39-CAFB40CA6A08} = {08D53E58-4AAE-40C4-8497-63EC8664F304} - {890B5210-48EF-488F-93A2-F13BCB07C780} = {4DA84F2B-1948-439B-85AB-E99E31331A9C} - {A8E2AB77-8F57-47C2-A961-2F316793CAFF} = {890B5210-48EF-488F-93A2-F13BCB07C780} - {F43CC5EA-6032-4A11-A9B2-6D48CB5EB082} = {4DA84F2B-1948-439B-85AB-E99E31331A9C} - {74377D3E-E0C6-41A4-89ED-11A9C00142A9} = {166E48ED-9738-4E13-8618-0D805F6F0F65} - {3C7C65BF-0C13-418E-90BD-EC9C3CD282CB} = {74377D3E-E0C6-41A4-89ED-11A9C00142A9} - {F057512B-55BF-4A8B-A027-A0505F8BA10C} = {4FDDC525-4E60-4CAF-83A3-261C5B43721F} - {10173568-A65E-44E5-8C6F-4AA49D0577A1} = {F057512B-55BF-4A8B-A027-A0505F8BA10C} - {97C7D2A4-87E5-4A4A-A170-D736427D5C21} = {F057512B-55BF-4A8B-A027-A0505F8BA10C} - {4730F56D-24EF-4BB2-AA75-862E31205F3A} = {225AEDCF-7162-4A86-AC74-06B84660B379} - {C406D9E0-1585-43F9-AA8F-D468AF84A996} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} - {7757E360-40F5-4C90-9D7F-E6B0E62BA9E2} = {C406D9E0-1585-43F9-AA8F-D468AF84A996} - {F0BF2260-5AE2-4248-81DE-AC5B9FC6A931} = {C406D9E0-1585-43F9-AA8F-D468AF84A996} - {A05652B3-953E-4915-9D7F-0E361D988815} = {0CE1CC26-98CE-4022-A81C-E32AAFC9B819} - {7BAEB9BF-28F4-4DFD-9A04-E5193683C261} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} - {AE4D272D-6F13-42C8-9404-C149188AFA33} = {7BAEB9BF-28F4-4DFD-9A04-E5193683C261} - {5D438258-CB19-4282-814F-974ABBC71411} = {7BAEB9BF-28F4-4DFD-9A04-E5193683C261} - {F5AE525F-F435-40F9-A567-4D5EC3B50D6E} = {5FE1FBC1-8CE3-4355-9866-44FE1307C5F1} - {56291265-B7BF-4756-92AB-FC30F09381D1} = {822D1519-77F0-484A-B9AB-F694C2CC25F1} - {66FA1041-5556-43A0-9CA3-F9937F085F6E} = {56291265-B7BF-4756-92AB-FC30F09381D1} - {37FC77EA-AC44-4D08-B002-8EFF415C424A} = {64B2A28F-6D82-4F2B-B0BB-88DE5216DD2C} - {87D58D50-20D1-4091-88C5-8D88DCCC2DE3} = {6126DCE4-9692-4EE2-B240-C65743572995} - {F7BCD3AD-31E2-4223-B215-851C3D0AB78A} = {B55A5DE1-5AF3-4B18-AF04-C1735B071DA6} - {055F86AA-FB37-40CC-B39E-C29CE7547BB7} = {B8825E86-B8EA-4666-B681-C443D027C95D} - {137AD17B-066F-4ED4-80FA-8D21C7B76CA6} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} - {CAEB7F57-28A8-451C-95D0-45FCAA3C726C} = {C445B129-0A4D-41F5-8347-6534B6B12303} - {A939893A-B3CD-48F6-80D3-340C8A6E275B} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} - {F232B503-D412-45EE-8B31-EFD46B9FA302} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} - {B0A8E5D4-BC5A-448E-B222-431B6B2EB58E} = {05A169C7-4F20-4516-B10A-B13C5649D346} - {15D08EA7-8C63-45FB-8B4D-C5F8E43B433E} = {05A169C7-4F20-4516-B10A-B13C5649D346} - {433F91E4-E39D-4EB0-B798-2998B3969A2C} = {6126DCE4-9692-4EE2-B240-C65743572995} - {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13} = {6126DCE4-9692-4EE2-B240-C65743572995} - {6DEC24A8-A166-432F-8E3B-58FFCDA92F52} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} - {6469F11E-8CEE-4292-820B-324DFFC88EBC} = {0F39820F-F4A5-41C6-9809-D79B68F032EF} - {8D2CC6ED-5105-4F52-8757-C21F4DE78589} = {6469F11E-8CEE-4292-820B-324DFFC88EBC} - {9DC6B242-457B-4767-A84B-C3D23B76C642} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} - {D53F0EF7-0CDC-49B4-AA2D-229901B0A734} = {9DC6B242-457B-4767-A84B-C3D23B76C642} - {5B5F86CC-3598-463C-9F9B-F78FBB6642F4} = {8275510E-0E6C-45A8-99DF-4F106BC7F075} - {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} - {4D8DE54A-4F32-4881-B07B-DDC79619E573} = {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} - {9536C284-65B4-4884-BB50-06D629095C3E} = {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} - {6A19D94D-2BC6-4198-BE2E-342688FDBA4B} = {A1B75FC7-A777-4412-A635-D0C9ED8FE7A0} - {D8F7091E-A2D1-4E81-BA7C-97EAE392D683} = {A1B75FC7-A777-4412-A635-D0C9ED8FE7A0} - {757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63} = {5FE1FBC1-8CE3-4355-9866-44FE1307C5F1} - {B32FF7A7-9CB3-4DCD-AE97-3B2594DB9DAC} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} - {B9BBC1A8-7F58-4F43-94C3-5F3CB125CEF7} = {B32FF7A7-9CB3-4DCD-AE97-3B2594DB9DAC} - {C3928C15-1836-46DB-A09D-9EFBCCA33E08} = {B5D98AEB-9409-4280-8225-9C1EC6A791B2} - {2B858B82-5F0B-4A24-B3C0-5E99149F70D6} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} - {2AAE7819-BC3E-48F4-9CFA-5DD4CD5FFD62} = {2B858B82-5F0B-4A24-B3C0-5E99149F70D6} + {96EC4DD3-028E-6E27-5B14-08C21B07CE89} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} + {1BBD75D2-429D-D565-A98E-36437448E8C0} = {96EC4DD3-028E-6E27-5B14-08C21B07CE89} + {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2} = {1BBD75D2-429D-D565-A98E-36437448E8C0} + {4C4590E4-54F5-3693-9633-60A8DCA89385} = {96EC4DD3-028E-6E27-5B14-08C21B07CE89} + {51BE4B37-D772-4BFA-8DAE-CF18730BEB89} = {4C4590E4-54F5-3693-9633-60A8DCA89385} + {10F96346-4215-4642-B837-0B5B08AC27AE} = {4C4590E4-54F5-3693-9633-60A8DCA89385} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 26dc86e0e31c..69b6ce66a6c3 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -223,6 +223,7 @@ and are generated based on the last package release. + diff --git a/eng/Versions.props b/eng/Versions.props index ed5451e43aa8..dfa71c0b14ec 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -326,6 +326,7 @@ 2.7.27 5.0.0 6.6.2 + 2.2.0 19.14.0 2.0.3 1.15.0 diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf index 3d616bfa4daf..87b2bee86a31 100644 --- a/src/OpenApi/OpenApi.slnf +++ b/src/OpenApi/OpenApi.slnf @@ -10,10 +10,12 @@ "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj", "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", + "src\\OpenApi\\gen\\Microsoft.AspNetCore.OpenApi.SourceGenerators.csproj", "src\\OpenApi\\perf\\Microbenchmarks\\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj", "src\\OpenApi\\sample\\Sample.csproj", "src\\OpenApi\\src\\Microsoft.AspNetCore.OpenApi.csproj", "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", + "src\\OpenApi\\test\\Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests\\Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests.csproj", "src\\OpenApi\\test\\Microsoft.AspNetCore.OpenApi.Tests\\Microsoft.AspNetCore.OpenApi.Tests.csproj", "src\\OpenApi\\sample\\Sample.csproj", "src\\OpenApi\\perf\\Microbenchmarks\\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj" diff --git a/src/OpenApi/build/Microsoft.AspNetCore.OpenApi.targets b/src/OpenApi/build/Microsoft.AspNetCore.OpenApi.targets new file mode 100644 index 000000000000..7b1add5c1666 --- /dev/null +++ b/src/OpenApi/build/Microsoft.AspNetCore.OpenApi.targets @@ -0,0 +1,17 @@ + + + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + + + + + + diff --git a/src/OpenApi/gen/Helpers/AddOpenApiInvocation.cs b/src/OpenApi/gen/Helpers/AddOpenApiInvocation.cs new file mode 100644 index 000000000000..464d4bd17a96 --- /dev/null +++ b/src/OpenApi/gen/Helpers/AddOpenApiInvocation.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators; + +/// +/// Represents an invocation of the AddOpenApi method. +/// +/// The variant of the AddOpenApi method. +/// The invocation expression. +/// The location of the invocation that can be intercepted. +internal sealed record AddOpenApiInvocation( + AddOpenApiOverloadVariant Variant, + InvocationExpressionSyntax InvocationExpression, + InterceptableLocation? Location); diff --git a/src/OpenApi/gen/Helpers/AddOpenApiInvocationComparer.cs b/src/OpenApi/gen/Helpers/AddOpenApiInvocationComparer.cs new file mode 100644 index 000000000000..98589347a5be --- /dev/null +++ b/src/OpenApi/gen/Helpers/AddOpenApiInvocationComparer.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators; + +internal class AddOpenApiInvocationComparer : IEqualityComparer +{ + public static AddOpenApiInvocationComparer Instance { get; } = new(); + public bool Equals(AddOpenApiInvocation? x, AddOpenApiInvocation? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + if (x is null || y is null) + { + return false; + } + + return x.Variant.Equals(y.Variant); + } + + public int GetHashCode(AddOpenApiInvocation obj) => + HashCode.Combine(obj.Variant); +} diff --git a/src/OpenApi/gen/Helpers/AddOpenApiOverloadVariant.cs b/src/OpenApi/gen/Helpers/AddOpenApiOverloadVariant.cs new file mode 100644 index 000000000000..8ed1b42fc721 --- /dev/null +++ b/src/OpenApi/gen/Helpers/AddOpenApiOverloadVariant.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators; + +internal enum AddOpenApiOverloadVariant +{ + AddOpenApi, + AddOpenApiDocumentName, + AddOpenApiDocumentNameConfigureOptions, + AddOpenApiConfigureOptions, + Unknown +} diff --git a/src/OpenApi/gen/Helpers/AssemblyTypeSymbolsVisitor.cs b/src/OpenApi/gen/Helpers/AssemblyTypeSymbolsVisitor.cs new file mode 100644 index 000000000000..5f962a0c48fd --- /dev/null +++ b/src/OpenApi/gen/Helpers/AssemblyTypeSymbolsVisitor.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators; + +/// +/// Visits the assembly symbols to collect public types, properties, and methods that might +/// contain XML documentation comments. +/// +internal sealed class AssemblyTypeSymbolsVisitor(IAssemblySymbol assemblySymbol, CancellationToken cancellation) : SymbolVisitor +{ + private readonly CancellationToken _cancellationToken = cancellation; + private readonly HashSet _exportedTypes = new(SymbolEqualityComparer.Default); + private readonly HashSet _exportedProperties = new(SymbolEqualityComparer.Default); + private readonly HashSet _exportedMethods = new(SymbolEqualityComparer.Default); + + public ImmutableArray GetPublicTypes() => [.. _exportedTypes]; + public ImmutableArray GetPublicProperties() => [.. _exportedProperties]; + public ImmutableArray GetPublicMethods() => [.. _exportedMethods]; + + public void VisitAssembly() => VisitAssembly(assemblySymbol); + + public override void VisitAssembly(IAssemblySymbol symbol) + { + _cancellationToken.ThrowIfCancellationRequested(); + symbol.GlobalNamespace.Accept(this); + } + + public override void VisitNamespace(INamespaceSymbol symbol) + { + foreach (var namespaceOrType in symbol.GetMembers()) + { + _cancellationToken.ThrowIfCancellationRequested(); + namespaceOrType.Accept(this); + } + } + + public override void VisitNamedType(INamedTypeSymbol type) + { + _cancellationToken.ThrowIfCancellationRequested(); + + if (!IsDeclaredInAssembly(type) || !_exportedTypes.Add(type)) + { + return; + } + + var nestedTypes = type.GetTypeMembers(); + + foreach (var nestedType in nestedTypes) + { + _cancellationToken.ThrowIfCancellationRequested(); + nestedType.Accept(this); + } + + var properties = type.GetMembers().OfType(); + foreach (var property in properties) + { + _cancellationToken.ThrowIfCancellationRequested(); + if (IsDeclaredInAssembly(property) && _exportedProperties.Add(property)) + { + property.Type.Accept(this); + } + } + var methods = type.GetMembers().OfType(); + foreach (var method in methods) + { + _cancellationToken.ThrowIfCancellationRequested(); + if (IsDeclaredInAssembly(method) && _exportedMethods.Add(method)) + { + method.Accept(this); + } + } + } + + private bool IsDeclaredInAssembly(ISymbol symbol) => + SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, assemblySymbol); +} diff --git a/src/OpenApi/gen/Helpers/DocumentationCommentXmlNames.cs b/src/OpenApi/gen/Helpers/DocumentationCommentXmlNames.cs new file mode 100644 index 000000000000..d9c2fb98d2e3 --- /dev/null +++ b/src/OpenApi/gen/Helpers/DocumentationCommentXmlNames.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml; + +internal static class DocumentationCommentXmlNames +{ + public const string CElementName = "c"; + public const string CodeElementName = "code"; + public const string CompletionListElementName = "completionlist"; + public const string DescriptionElementName = "description"; + public const string ExampleElementName = "example"; + public const string ExceptionElementName = "exception"; + public const string IncludeElementName = "include"; + public const string InheritdocElementName = "inheritdoc"; + public const string ItemElementName = "item"; + public const string ListElementName = "list"; + public const string ListHeaderElementName = "listheader"; + public const string ParaElementName = "para"; + public const string ParameterElementName = "param"; + public const string ParameterReferenceElementName = "paramref"; + public const string PermissionElementName = "permission"; + public const string PlaceholderElementName = "placeholder"; + public const string PreliminaryElementName = "preliminary"; + public const string RemarksElementName = "remarks"; + public const string ReturnsElementName = "returns"; + public const string SeeElementName = "see"; + public const string SeeAlsoElementName = "seealso"; + public const string SummaryElementName = "summary"; + public const string TermElementName = "term"; + public const string ThreadSafetyElementName = "threadsafety"; + public const string TypeParameterElementName = "typeparam"; + public const string TypeParameterReferenceElementName = "typeparamref"; + public const string ValueElementName = "value"; + public const string CrefAttributeName = "cref"; + public const string HrefAttributeName = "href"; + public const string FileAttributeName = "file"; + public const string InstanceAttributeName = "instance"; + public const string LangwordAttributeName = "langword"; + public const string NameAttributeName = "name"; + public const string PathAttributeName = "path"; + public const string StaticAttributeName = "static"; + public const string TypeAttributeName = "type"; + + public static bool ElementEquals(string name1, string name2, bool fromVb = false) + { + return string.Equals(name1, name2, fromVb ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); + } + + public static bool AttributeEquals(string name1, string name2) + { + return string.Equals(name1, name2, StringComparison.Ordinal); + } + + public static new bool Equals(object left, object right) + { + return object.Equals(left, right); + } +} diff --git a/src/OpenApi/gen/Helpers/ISymbolExtensions.cs b/src/OpenApi/gen/Helpers/ISymbolExtensions.cs new file mode 100644 index 000000000000..176bfad73faf --- /dev/null +++ b/src/OpenApi/gen/Helpers/ISymbolExtensions.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml; + +internal static class ISymbolExtensions +{ + public static ImmutableArray ExplicitOrImplicitInterfaceImplementations(this ISymbol symbol) + { + if (symbol.Kind is not SymbolKind.Method and not SymbolKind.Property and not SymbolKind.Event) + { + return []; + } + + var result = ImmutableArray.CreateBuilder(); + + foreach (var iface in symbol.ContainingType.AllInterfaces) + { + foreach (var interfaceMember in iface.GetMembers()) + { + var impl = symbol.ContainingType.FindImplementationForInterfaceMember(interfaceMember); + if (SymbolEqualityComparer.Default.Equals(symbol, impl)) + { + result.Add(interfaceMember); + } + } + } + + // There are explicit methods that FindImplementationForInterfaceMember. For example `abstract explicit impls` + // like `abstract void I.M()`. So add these back in directly using symbol.ExplicitInterfaceImplementations. + result.AddRange(symbol.ExplicitInterfaceImplementations()); + + return result.ToImmutable(); + } + + public static ImmutableArray ExplicitInterfaceImplementations(this ISymbol symbol) + => symbol switch + { + IEventSymbol @event => ImmutableArray.CastUp(@event.ExplicitInterfaceImplementations), + IMethodSymbol method => ImmutableArray.CastUp(method.ExplicitInterfaceImplementations), + IPropertySymbol property => ImmutableArray.CastUp(property.ExplicitInterfaceImplementations), + _ => [], + }; + + public static ImmutableArray GetAllTypeParameters(this ISymbol? symbol) + { + var results = ImmutableArray.CreateBuilder(); + + while (symbol != null) + { + results.AddRange(symbol.GetTypeParameters()); + symbol = symbol.ContainingType; + } + + return results.ToImmutable(); + } + + public static ImmutableArray GetTypeParameters(this ISymbol? symbol) + => symbol switch + { + IMethodSymbol m => m.TypeParameters, + INamedTypeSymbol nt => nt.TypeParameters, + _ => [], + }; + + public static ImmutableArray GetAllTypeArguments(this ISymbol symbol) + { + var results = ImmutableArray.CreateBuilder(); + results.AddRange(symbol.GetTypeArguments()); + + var containingType = symbol.ContainingType; + while (containingType != null) + { + results.AddRange(containingType.GetTypeArguments()); + containingType = containingType.ContainingType; + } + + return results.ToImmutable(); + } + + public static ImmutableArray GetTypeArguments(this ISymbol? symbol) + => symbol switch + { + IMethodSymbol m => m.TypeArguments, + INamedTypeSymbol nt => nt.TypeArguments, + _ => [], + }; + + public static ISymbol? GetOverriddenMember(this ISymbol? symbol, bool allowLooseMatch = false) + { + if (symbol is null) + { + return null; + } + + ISymbol? exactMatch = symbol switch + { + IMethodSymbol method => method.OverriddenMethod, + IPropertySymbol property => property.OverriddenProperty, + IEventSymbol @event => @event.OverriddenEvent, + _ => null, + }; + + if (exactMatch != null) + { + return exactMatch; + } + + if (allowLooseMatch && + (symbol.IsVirtual || symbol.IsAbstract || symbol.IsOverride)) + { + foreach (var baseType in symbol.ContainingType.GetBaseTypes()) + { + if (TryFindLooseMatch(symbol, baseType, out var looseMatch)) + { + return looseMatch; + } + } + } + + return null; + + static bool TryFindLooseMatch(ISymbol symbol, INamedTypeSymbol baseType, [NotNullWhen(true)] out ISymbol? looseMatch) + { + IMethodSymbol? bestMethod = null; + var parameterCount = symbol.GetParameters().Length; + + foreach (var member in baseType.GetMembers(symbol.Name)) + { + if (member.Kind != symbol.Kind) + { + continue; + } + + if (!member.IsOverridable()) + { + continue; + } + + if (symbol.Kind is SymbolKind.Event or SymbolKind.Property) + { + // We've found a matching event/property in the base type (perhaps differing by return type). This + // is a good enough match to return as a loose match for the starting symbol. + looseMatch = member; + return true; + } + else if (member is IMethodSymbol method) + { + // Prefer methods that are closed in parameter count to the original method we started with. + if (bestMethod is null || Math.Abs(method.Parameters.Length - parameterCount) < Math.Abs(bestMethod.Parameters.Length - parameterCount)) + { + bestMethod = method; + } + } + } + + looseMatch = bestMethod; + return looseMatch != null; + } + } + + public static bool IsOverridable([NotNullWhen(true)] this ISymbol? symbol) + { + // Members can only have overrides if they are virtual, abstract or override and is not sealed. + return symbol is { ContainingType.TypeKind: TypeKind.Class, IsSealed: false } && + (symbol.IsVirtual || symbol.IsAbstract || symbol.IsOverride); + } + + public static ImmutableArray GetParameters(this ISymbol? symbol) + => symbol switch + { + IMethodSymbol m => m.Parameters, + IPropertySymbol nt => nt.Parameters, + _ => [], + }; + + public static IEnumerable GetBaseTypes(this ITypeSymbol? type) + { + var current = type?.BaseType; + while (current != null) + { + yield return current; + current = current.BaseType; + } + } +} diff --git a/src/OpenApi/gen/Helpers/StringExtensions.cs b/src/OpenApi/gen/Helpers/StringExtensions.cs new file mode 100644 index 000000000000..ee4a81284295 --- /dev/null +++ b/src/OpenApi/gen/Helpers/StringExtensions.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml; + +/// +/// Extension methods for string manipulation. +/// +public static class StringExtensions +{ + /// + /// Trims whitespace from each line of text while preserving relative indentation. + /// + /// The text to trim. + /// Optional indentation to apply. + /// The trimmed text with preserved indentation structure. + public static string TrimEachLine(this string text, string indent = "") + { + var minLeadingWhitespace = int.MaxValue; + var lines = text.ReadLines().ToList(); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var leadingWhitespace = 0; + while (leadingWhitespace < line.Length && char.IsWhiteSpace(line[leadingWhitespace])) + { + leadingWhitespace++; + } + + minLeadingWhitespace = Math.Min(minLeadingWhitespace, leadingWhitespace); + } + + var builder = new StringBuilder(); + + // Trim leading empty lines + var trimStart = true; + + // Apply indentation to all lines except the first, + // since the first new line in
 is significant
+        var firstLine = true;
+
+        foreach (var line in lines)
+        {
+            if (trimStart && string.IsNullOrWhiteSpace(line))
+            {
+                continue;
+            }
+
+            if (firstLine)
+            {
+                firstLine = false;
+            }
+            else
+            {
+                builder.Append(indent);
+            }
+
+            if (string.IsNullOrWhiteSpace(line))
+            {
+                builder.AppendLine();
+                continue;
+            }
+
+            trimStart = false;
+            builder.AppendLine(line.Substring(minLeadingWhitespace));
+        }
+
+        return builder.ToString().TrimEnd();
+    }
+
+    public static IEnumerable ReadLines(this string text)
+    {
+        string line;
+        using var sr = new StringReader(text);
+        while ((line = sr.ReadLine()) != null)
+        {
+            yield return line;
+        }
+    }
+}
diff --git a/src/OpenApi/gen/Microsoft.AspNetCore.OpenApi.SourceGenerators.csproj b/src/OpenApi/gen/Microsoft.AspNetCore.OpenApi.SourceGenerators.csproj
new file mode 100644
index 000000000000..8ca8ad11dd21
--- /dev/null
+++ b/src/OpenApi/gen/Microsoft.AspNetCore.OpenApi.SourceGenerators.csproj
@@ -0,0 +1,32 @@
+
+
+  
+    netstandard2.0
+    true
+    true
+    true
+    false
+    enable
+    true
+    false
+    false
+  
+
+  
+    
+    
+  
+
+  
+    
+    
+    
+    
+    
+  
+
+  
+    
+  
+
+
diff --git a/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
new file mode 100644
index 000000000000..f91854fa7825
--- /dev/null
+++ b/src/OpenApi/gen/XmlCommentGenerator.Emitter.cs
@@ -0,0 +1,549 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml;
+using System.Threading;
+using System.Linq;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators;
+
+public sealed partial class XmlCommentGenerator : IIncrementalGenerator
+{
+    public static string GeneratedCodeConstructor => $@"System.CodeDom.Compiler.GeneratedCodeAttribute(""{typeof(XmlCommentGenerator).Assembly.FullName}"", ""{typeof(XmlCommentGenerator).Assembly.GetName().Version}"")";
+    public static string GeneratedCodeAttribute => $"[{GeneratedCodeConstructor}]";
+
+    internal static string GenerateXmlCommentSupportSource(string commentsFromXmlFile, string? commentsFromCompilation, ImmutableArray<(AddOpenApiInvocation Source, int Index, ImmutableArray Elements)> groupedAddOpenApiInvocations) => $$"""
+//------------------------------------------------------------------------------
+// 
+//     This code was generated by a tool.
+//
+//     Changes to this file may cause incorrect behavior and will be lost if
+//     the code is regenerated.
+// 
+//------------------------------------------------------------------------------
+#nullable enable
+
+namespace System.Runtime.CompilerServices
+{
+    {{GeneratedCodeAttribute}}
+    [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+    file sealed class InterceptsLocationAttribute : System.Attribute
+    {
+        public InterceptsLocationAttribute(int version, string data)
+        {
+        }
+    }
+}
+
+namespace Microsoft.AspNetCore.OpenApi.Generated
+{
+    using System;
+    using System.Collections.Generic;
+    using System.Diagnostics.CodeAnalysis;
+    using System.Linq;
+    using System.Reflection;
+    using System.Text.Json;
+    using System.Text.Json.Nodes;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Microsoft.AspNetCore.OpenApi;
+    using Microsoft.AspNetCore.Mvc.Controllers;
+    using Microsoft.Extensions.DependencyInjection;
+    using Microsoft.OpenApi.Models;
+    using Microsoft.OpenApi.Any;
+
+    {{GeneratedCodeAttribute}}
+    file record XmlComment(
+        string? Summary,
+        string? Description,
+        string? Remarks,
+        string? Returns,
+        string? Value,
+        bool Deprecated,
+        List? Examples,
+        List? Parameters,
+        List? Responses);
+
+    {{GeneratedCodeAttribute}}
+    file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated);
+
+    {{GeneratedCodeAttribute}}
+    file record XmlResponseComment(string Code, string? Description, string? Example);
+
+    {{GeneratedCodeAttribute}}
+    file sealed record MemberKey(
+        Type? DeclaringType,
+        MemberType MemberKind,
+        string? Name,
+        Type? ReturnType,
+        Type[]? Parameters) : IEquatable
+    {
+        public bool Equals(MemberKey? other)
+        {
+            if (other is null) return false;
+
+            // Check member kind
+            if (MemberKind != other.MemberKind) return false;
+
+            // Check declaring type, handling generic types
+            if (!TypesEqual(DeclaringType, other.DeclaringType)) return false;
+
+            // Check name
+            if (Name != other.Name) return false;
+
+            // For methods, check return type and parameters
+            if (MemberKind == MemberType.Method)
+            {
+                if (!TypesEqual(ReturnType, other.ReturnType)) return false;
+                if (Parameters is null || other.Parameters is null) return false;
+                if (Parameters.Length != other.Parameters.Length) return false;
+
+                for (int i = 0; i < Parameters.Length; i++)
+                {
+                    if (!TypesEqual(Parameters[i], other.Parameters[i])) return false;
+                }
+            }
+
+            return true;
+        }
+
+        private static bool TypesEqual(Type? type1, Type? type2)
+        {
+            if (type1 == type2) return true;
+            if (type1 == null || type2 == null) return false;
+
+            if (type1.IsGenericType && type2.IsGenericType)
+            {
+                return type1.GetGenericTypeDefinition() == type2.GetGenericTypeDefinition();
+            }
+
+            return type1 == type2;
+        }
+
+        public override int GetHashCode()
+        {
+            var hash = new HashCode();
+            hash.Add(GetTypeHashCode(DeclaringType));
+            hash.Add(MemberKind);
+            hash.Add(Name);
+
+            if (MemberKind == MemberType.Method)
+            {
+                hash.Add(GetTypeHashCode(ReturnType));
+                if (Parameters is not null)
+                {
+                    foreach (var param in Parameters)
+                    {
+                        hash.Add(GetTypeHashCode(param));
+                    }
+                }
+            }
+
+            return hash.ToHashCode();
+        }
+
+        private static int GetTypeHashCode(Type? type)
+        {
+            if (type == null) return 0;
+            return type.IsGenericType ? type.GetGenericTypeDefinition().GetHashCode() : type.GetHashCode();
+        }
+
+        public static MemberKey FromMethodInfo(MethodInfo method)
+        {
+            return new MemberKey(
+                method.DeclaringType,
+                MemberType.Method,
+                method.Name,
+                method.ReturnType.IsGenericParameter ? typeof(object) : method.ReturnType,
+                method.GetParameters().Select(p => p.ParameterType.IsGenericParameter ? typeof(object) : p.ParameterType).ToArray());
+        }
+
+        public static MemberKey FromPropertyInfo(PropertyInfo property)
+        {
+            return new MemberKey(
+                property.DeclaringType,
+                MemberType.Property,
+                property.Name,
+                null,
+                null);
+        }
+
+        public static MemberKey FromTypeInfo(Type type)
+        {
+            return new MemberKey(
+                type,
+                MemberType.Type,
+                null,
+                null,
+                null);
+        }
+    }
+
+    file enum MemberType
+    {
+        Type,
+        Property,
+        Method
+    }
+
+    {{GeneratedCodeAttribute}}
+    file static class XmlCommentCache
+    {
+        private static Dictionary? _cache;
+        public static Dictionary Cache => _cache ??= GenerateCacheEntries();
+
+        private static Dictionary GenerateCacheEntries()
+        {
+            var _cache = new Dictionary();
+{{commentsFromXmlFile}}
+{{commentsFromCompilation}}
+            return _cache;
+        }
+
+        internal static bool TryGetXmlComment(Type? type, MethodInfo? methodInfo, [NotNullWhen(true)] out XmlComment? xmlComment)
+        {
+            if (methodInfo is null)
+            {
+                return Cache.TryGetValue(new MemberKey(type, MemberType.Property, null, null, null), out xmlComment);
+            }
+
+            return Cache.TryGetValue(MemberKey.FromMethodInfo(methodInfo), out xmlComment);
+        }
+
+        internal static bool TryGetXmlComment(Type? type, string? memberName, [NotNullWhen(true)] out XmlComment? xmlComment)
+        {
+            return Cache.TryGetValue(new MemberKey(type, memberName is null ? MemberType.Type : MemberType.Property, memberName, null, null), out xmlComment);
+        }
+    }
+
+    {{GeneratedCodeAttribute}}
+    file class XmlCommentOperationTransformer : IOpenApiOperationTransformer
+    {
+        public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+        {
+            var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
+                ? controllerActionDescriptor.MethodInfo
+                : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault();
+
+            if (methodInfo is null)
+            {
+                return Task.CompletedTask;
+            }
+            if (XmlCommentCache.TryGetXmlComment(methodInfo.DeclaringType, methodInfo, out var methodComment))
+            {
+                if (methodComment.Summary is { } summary)
+                {
+                    operation.Summary = summary;
+                }
+                if (methodComment.Description is { } description)
+                {
+                    operation.Description = description;
+                }
+                if (methodComment.Remarks is { } remarks)
+                {
+                    operation.Description = remarks;
+                }
+                if (methodComment.Parameters is { Count: > 0})
+                {
+                    foreach (var parameterComment in methodComment.Parameters)
+                    {
+                        var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name);
+                        var operationParameter = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name);
+                        if (operationParameter is not null)
+                        {
+                            operationParameter.Description = parameterComment.Description;
+                            if (parameterComment.Example is { } jsonString)
+                            {
+                                operationParameter.Example = jsonString.Parse();
+                            }
+                            operationParameter.Deprecated = parameterComment.Deprecated;
+                        }
+                        else
+                        {
+                            var requestBody = operation.RequestBody;
+                            if (requestBody is not null)
+                            {
+                                requestBody.Description = parameterComment.Description;
+                                if (parameterComment.Example is { } jsonString)
+                                {
+                                    foreach (var mediaType in requestBody.Content.Values)
+                                    {
+                                        mediaType.Example = jsonString.Parse();
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+                if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 })
+                {
+                    foreach (var response in operation.Responses)
+                    {
+                        var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key);
+                        if (responseComment is not null)
+                        {
+                            response.Value.Description = responseComment.Description;
+                        }
+                    }
+                }
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+
+    {{GeneratedCodeAttribute}}
+    file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer
+    {
+        public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
+        {
+            if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo })
+            {
+                if (XmlCommentCache.TryGetXmlComment(propertyInfo.DeclaringType, propertyInfo.Name, out var propertyComment))
+                {
+                    schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary;
+                    if (propertyComment.Examples?.FirstOrDefault() is { } jsonString)
+                    {
+                        schema.Example = jsonString.Parse();
+                    }
+                }
+            }
+            if (XmlCommentCache.TryGetXmlComment(context.JsonTypeInfo.Type, (string?)null, out var typeComment))
+            {
+                schema.Description = typeComment.Summary;
+                if (typeComment.Examples?.FirstOrDefault() is { } jsonString)
+                {
+                    schema.Example = jsonString.Parse();
+                }
+            }
+            return Task.CompletedTask;
+        }
+    }
+
+    file static class JsonNodeExtensions
+    {
+        public static JsonNode? Parse(this string? json)
+        {
+            if (json is null)
+            {
+                return null;
+            }
+
+            try
+            {
+                return JsonNode.Parse(json);
+            }
+            catch (JsonException)
+            {
+                try
+                {
+                    // If parsing fails, try wrapping in quotes to make it a valid JSON string
+                    return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\"");
+                }
+                catch (JsonException)
+                {
+                    return null;
+                }
+            }
+        }
+    }
+
+    {{GeneratedCodeAttribute}}
+    file static class GeneratedServiceCollectionExtensions
+    {
+{{GenerateAddOpenApiInterceptions(groupedAddOpenApiInvocations)}}
+    }
+}
+""";
+
+    internal static string GetAddOpenApiInterceptor(AddOpenApiOverloadVariant overloadVariant) => overloadVariant switch
+    {
+        AddOpenApiOverloadVariant.AddOpenApi => """
+        public static IServiceCollection AddOpenApi(this IServiceCollection services)
+                {
+                    return services.AddOpenApi("v1", options =>
+                    {
+                        options.AddSchemaTransformer(new XmlCommentSchemaTransformer());
+                        options.AddOperationTransformer(new XmlCommentOperationTransformer());
+                    });
+                }
+        """,
+        AddOpenApiOverloadVariant.AddOpenApiDocumentName => """
+        public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName)
+                {
+                    return services.AddOpenApi(documentName, options =>
+                    {
+                        options.AddSchemaTransformer(new XmlCommentSchemaTransformer());
+                        options.AddOperationTransformer(new XmlCommentOperationTransformer());
+                    });
+                }
+        """,
+        AddOpenApiOverloadVariant.AddOpenApiConfigureOptions => """
+        public static IServiceCollection AddOpenApi(this IServiceCollection services, Action configureOptions)
+                {
+                    return services.AddOpenApi("v1", options =>
+                    {
+                        options.AddSchemaTransformer(new XmlCommentSchemaTransformer());
+                        options.AddOperationTransformer(new XmlCommentOperationTransformer());
+                        configureOptions(options);
+                    });
+                }
+        """,
+        AddOpenApiOverloadVariant.AddOpenApiDocumentNameConfigureOptions => """
+        public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName, Action configureOptions)
+                {
+                    // This overload is not intercepted.
+                    return OpenApiServiceCollectionExtensions.AddOpenApi(services, documentName, options =>
+                    {
+                        options.AddSchemaTransformer(new XmlCommentSchemaTransformer());
+                        options.AddOperationTransformer(new XmlCommentOperationTransformer());
+                        configureOptions(options);
+                    });
+                }
+        """,
+        _ => string.Empty // Effectively no-op for AddOpenApi invocations that do not conform to a variant
+    };
+
+    internal static string GenerateAddOpenApiInterceptions(ImmutableArray<(AddOpenApiInvocation Source, int Index, ImmutableArray Elements)> groupedAddOpenApiInvocations)
+    {
+        var writer = new StringWriter();
+        var codeWriter = new CodeWriter(writer, baseIndent: 2);
+        foreach (var (source, _, locations) in groupedAddOpenApiInvocations)
+        {
+            foreach (var location in locations)
+            {
+                if (location is not null)
+                {
+                    codeWriter.WriteLine(location.GetInterceptsLocationAttributeSyntax());
+                }
+            }
+            codeWriter.WriteLine(GetAddOpenApiInterceptor(source.Variant));
+        }
+        return writer.ToString();
+    }
+
+    internal static string EmitCommentsCache(IEnumerable<(MemberKey MemberKey, XmlComment? Comment)> comments, CancellationToken cancellationToken)
+    {
+        var writer = new StringWriter();
+        var codeWriter = new CodeWriter(writer, baseIndent: 3);
+        foreach (var (memberKey, comment) in comments)
+        {
+            if (comment is not null)
+            {
+                codeWriter.WriteLine($"_cache.Add(new MemberKey(" +
+                    $"{FormatLiteralOrNull(memberKey.DeclaringType)}, " +
+                    $"MemberType.{memberKey.MemberKind}, " +
+                    $"{FormatLiteralOrNull(memberKey.Name, true)}, " +
+                    $"{FormatLiteralOrNull(memberKey.ReturnType)}, " +
+                    $"[{(memberKey.Parameters != null ? string.Join(", ", memberKey.Parameters.Select(p => SymbolDisplay.FormatLiteral(p, false))) : "")}]), " +
+                    $"{EmitSourceGeneratedXmlComment(comment)});");
+            }
+        }
+        return writer.ToString();
+
+        static string FormatLiteralOrNull(string? input, bool quote = false)
+        {
+            return input == null ? "null" : SymbolDisplay.FormatLiteral(input, quote);
+        }
+    }
+
+    private static string FormatStringForCode(string? input)
+    {
+        if (input == null)
+        {
+            return "null";
+        }
+
+        var formatted = input
+            .Replace("\"", "\"\""); // Escape double quotes
+
+        return $"@\"{formatted}\"";
+    }
+
+    internal static string EmitSourceGeneratedXmlComment(XmlComment comment)
+    {
+        var writer = new StringWriter();
+        var codeWriter = new CodeWriter(writer, baseIndent: 0);
+        codeWriter.Write($"new XmlComment(");
+        codeWriter.Write(FormatStringForCode(comment.Summary) + ", ");
+        codeWriter.Write(FormatStringForCode(comment.Description) + ", ");
+        codeWriter.Write(FormatStringForCode(comment.Remarks) + ", ");
+        codeWriter.Write(FormatStringForCode(comment.Returns) + ", ");
+        codeWriter.Write(FormatStringForCode(comment.Value) + ", ");
+        codeWriter.Write(comment.Deprecated == true ? "true" : "false" + ", ");
+        if (comment.Examples is null || comment.Examples.Count == 0)
+        {
+            codeWriter.Write("null, ");
+        }
+        else
+        {
+            codeWriter.Write("[");
+            for (int i = 0; i < comment.Examples.Count; i++)
+            {
+                var example = comment.Examples[i];
+                codeWriter.Write(FormatStringForCode(example));
+                if (i < comment.Examples.Count - 1)
+                {
+                    codeWriter.Write(", ");
+                }
+            }
+            codeWriter.Write("], ");
+        }
+
+        if (comment.Parameters is null || comment.Parameters.Count == 0)
+        {
+            codeWriter.Write("null, ");
+        }
+        else
+        {
+            codeWriter.Write("[");
+            for (int i = 0; i < comment.Parameters.Count; i++)
+            {
+                var parameter = comment.Parameters[i];
+                var exampleLiteral = string.IsNullOrEmpty(parameter.Example)
+                    ? "null"
+                    : FormatStringForCode(parameter.Example!);
+                codeWriter.Write($"new XmlParameterComment(@\"{parameter.Name}\", @\"{parameter.Description}\", {exampleLiteral}, {(parameter.Deprecated == true ? "true" : "false")})");
+                if (i < comment.Parameters.Count - 1)
+                {
+                    codeWriter.Write(", ");
+                }
+            }
+            codeWriter.Write("], ");
+        }
+
+        if (comment.Responses is null || comment.Responses.Count == 0)
+        {
+            codeWriter.Write("null");
+        }
+        else
+        {
+            codeWriter.Write("[");
+            for (int i = 0; i < comment.Responses.Count; i++)
+            {
+                var response = comment.Responses[i];
+                codeWriter.Write($"new XmlResponseComment(@\"{response.Code}\", @\"{response.Description}\", {(response.Example is null ? "null" : FormatStringForCode(response.Example))})");
+                if (i < comment.Responses.Count - 1)
+                {
+                    codeWriter.Write(", ");
+                }
+            }
+            codeWriter.Write("]");
+        }
+        codeWriter.Write(")");
+        return writer.ToString();
+    }
+
+    internal static void Emit(SourceProductionContext context,
+        string commentsFromXmlFile,
+        string commentsFromCompilation,
+        ImmutableArray<(AddOpenApiInvocation Source, int Index, ImmutableArray Elements)> groupedAddOpenApiInvocations)
+    {
+        context.AddSource("OpenApiXmlCommentSupport.generated.cs", GenerateXmlCommentSupportSource(commentsFromXmlFile, commentsFromCompilation, groupedAddOpenApiInvocations));
+    }
+}
diff --git a/src/OpenApi/gen/XmlCommentGenerator.Parser.cs b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs
new file mode 100644
index 000000000000..fad7e9318e45
--- /dev/null
+++ b/src/OpenApi/gen/XmlCommentGenerator.Parser.cs
@@ -0,0 +1,163 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading;
+using System.Xml.Linq;
+using Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators;
+
+public sealed partial class XmlCommentGenerator
+{
+    internal static List<(string, string)> ParseXmlFile(AdditionalText additionalText, CancellationToken cancellationToken)
+    {
+        var text = additionalText.GetText(cancellationToken);
+        if (text is null)
+        {
+            return [];
+        }
+        XElement xml;
+        try
+        {
+            xml = XElement.Parse(text.ToString());
+        }
+        catch
+        {
+            return [];
+        }
+        var members = xml.Descendants("member");
+        var comments = new List<(string, string)>();
+        foreach (var member in members)
+        {
+            var name = member.Attribute(DocumentationCommentXmlNames.NameAttributeName)?.Value;
+            if (name is not null)
+            {
+                comments.Add((name, member.ToString()));
+            }
+        }
+        return comments;
+    }
+
+    internal static List<(string, string)> ParseCompilation(Compilation compilation, CancellationToken cancellationToken)
+    {
+        var visitor = new AssemblyTypeSymbolsVisitor(compilation.Assembly, cancellationToken);
+        visitor.VisitAssembly();
+        var types = visitor.GetPublicTypes();
+        var comments = new List<(string, string)>();
+        foreach (var type in types)
+        {
+            if (DocumentationCommentId.CreateDeclarationId(type) is string name &&
+                type.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
+            {
+                comments.Add((name, xml));
+            }
+        }
+        var properties = visitor.GetPublicProperties();
+        foreach (var property in properties)
+        {
+            if (DocumentationCommentId.CreateDeclarationId(property) is string name &&
+                property.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
+            {
+                comments.Add((name, xml));
+            }
+        }
+        var methods = visitor.GetPublicMethods();
+        foreach (var method in methods)
+        {
+            // If the method is a constructor for a record, skip it because we will have already processed the type.
+            if (method.MethodKind == MethodKind.Constructor)
+            {
+                continue;
+            }
+            if (DocumentationCommentId.CreateDeclarationId(method) is string name &&
+                method.GetDocumentationCommentXml(CultureInfo.InvariantCulture, expandIncludes: true, cancellationToken: cancellationToken) is string xml)
+            {
+                comments.Add((name, xml));
+            }
+        }
+        return comments;
+    }
+
+    internal static IEnumerable<(MemberKey, XmlComment?)> ParseComments(
+        (List<(string, string)> RawComments, Compilation Compilation) input,
+        CancellationToken cancellationToken)
+    {
+        var compilation = input.Compilation;
+        var comments = new List<(MemberKey, XmlComment?)>();
+        foreach (var (name, value) in input.RawComments)
+        {
+            if (DocumentationCommentId.GetFirstSymbolForDeclarationId(name, compilation) is ISymbol symbol)
+            {
+                var parsedComment = XmlComment.Parse(symbol, compilation, value, cancellationToken);
+                if (parsedComment is not null)
+                {
+                    var memberKey = symbol switch
+                    {
+                        IMethodSymbol methodSymbol => MemberKey.FromMethodSymbol(methodSymbol),
+                        IPropertySymbol propertySymbol => MemberKey.FromPropertySymbol(propertySymbol),
+                        INamedTypeSymbol typeSymbol => MemberKey.FromTypeSymbol(typeSymbol),
+                        _ => null
+                    };
+                    if (memberKey is not null)
+                    {
+                        comments.Add((memberKey, parsedComment));
+                    }
+                }
+            }
+        }
+        return comments;
+    }
+
+    internal static bool FilterInvocations(SyntaxNode node, CancellationToken _)
+        => node is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: "AddOpenApi" } };
+
+    internal static AddOpenApiInvocation GetAddOpenApiOverloadVariant(GeneratorSyntaxContext context, CancellationToken cancellationToken)
+    {
+        var invocationExpression = (InvocationExpressionSyntax)context.Node;
+
+        // Soft check to validate that the method is from the OpenApiServiceCollectionExtensions class
+        // in the Microsoft.AspNetCore.OpenApi assembly.
+        var symbol = context.SemanticModel.GetSymbolInfo(invocationExpression, cancellationToken).Symbol;
+        if (symbol is not IMethodSymbol methodSymbol
+            || methodSymbol.ContainingType.Name != "OpenApiServiceCollectionExtensions"
+            || methodSymbol.ContainingAssembly.Name != "Microsoft.AspNetCore.OpenApi")
+        {
+            return new(AddOpenApiOverloadVariant.Unknown, invocationExpression, null);
+        }
+
+        var interceptableLocation = context.SemanticModel.GetInterceptableLocation(invocationExpression, cancellationToken);
+        var argumentsCount = invocationExpression.ArgumentList.Arguments.Count;
+        if (argumentsCount == 0)
+        {
+            return new(AddOpenApiOverloadVariant.AddOpenApi, invocationExpression, interceptableLocation);
+        }
+        else if (argumentsCount == 2)
+        {
+            return new(AddOpenApiOverloadVariant.AddOpenApiDocumentNameConfigureOptions, invocationExpression, interceptableLocation);
+        }
+        else
+        {
+            // We need to disambiguate between the two overloads that take a string and a delegate
+            // AddOpenApi("v1") vs. AddOpenApi(options => { }). The implementation here is pretty naive and
+            // won't handle cases where the document name is provided by a variable or a method call.
+            var argument = invocationExpression.ArgumentList.Arguments[0];
+            if (argument.Expression is LiteralExpressionSyntax)
+            {
+                return new(AddOpenApiOverloadVariant.AddOpenApiDocumentName, invocationExpression, interceptableLocation);
+            }
+            else if (argument.Expression is LambdaExpressionSyntax)
+            {
+                return new(AddOpenApiOverloadVariant.AddOpenApiConfigureOptions, invocationExpression, interceptableLocation);
+            }
+            else
+            {
+                return new(AddOpenApiOverloadVariant.Unknown, invocationExpression, null);
+            }
+        }
+    }
+}
diff --git a/src/OpenApi/gen/XmlCommentGenerator.cs b/src/OpenApi/gen/XmlCommentGenerator.cs
new file mode 100644
index 000000000000..25f716493b83
--- /dev/null
+++ b/src/OpenApi/gen/XmlCommentGenerator.cs
@@ -0,0 +1,57 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators;
+
+[Generator]
+public sealed partial class XmlCommentGenerator : IIncrementalGenerator
+{
+    public void Initialize(IncrementalGeneratorInitializationContext context)
+    {
+        // Pull out XML comments from referenced assemblies passed in as AdditionalFiles.
+        var commentsFromXmlFile = context.AdditionalTextsProvider
+            .Where(file => file.Path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
+            .Select(ParseXmlFile);
+        // Pull out XML comments from the target assembly using the information produced
+        // by Roslyn into the compilation.
+        var commentsFromTargetAssembly = context.CompilationProvider
+            .Select(ParseCompilation);
+        // Map string XML comments to structured data from both the AdditionalFiles
+        // and the target assembly.
+        var parsedCommentsFromXmlFile = commentsFromXmlFile
+            .Combine(context.CompilationProvider)
+            .Select(ParseComments);
+        var parsedCommentsFromCompilation = commentsFromTargetAssembly
+            .Combine(context.CompilationProvider)
+            .Select(ParseComments);
+        // Discover AddOpenApi invocations so that we can intercept them with an implicit
+        // registration of the transformers for mapping XML doc comments to the OpenAPI file.
+        var groupedAddOpenApiInvocations = context.SyntaxProvider
+            .CreateSyntaxProvider(FilterInvocations, GetAddOpenApiOverloadVariant)
+            .GroupWith((variantDetails) => variantDetails.Location, AddOpenApiInvocationComparer.Instance)
+            .Collect();
+
+        var generatedCommentsFromXmlFile = parsedCommentsFromXmlFile
+            .Select(EmitCommentsCache);
+        var generatedCommentsFromCompilation = parsedCommentsFromCompilation
+            .Select(EmitCommentsCache);
+
+        var result = generatedCommentsFromXmlFile.Collect()
+            .Combine(generatedCommentsFromCompilation)
+            .Combine(groupedAddOpenApiInvocations);
+
+        context.RegisterSourceOutput(result, (context, output) =>
+        {
+            var groupedAddOpenApiInvocations = output.Right;
+            var (generatedCommentsFromXmlFile, generatedCommentsFromCompilation) = output.Left;
+            var compiledXmlFileComments = !generatedCommentsFromXmlFile.IsDefaultOrEmpty
+                ? string.Join("\n", generatedCommentsFromXmlFile)
+                : string.Empty;
+            Emit(context, compiledXmlFileComments, generatedCommentsFromCompilation, groupedAddOpenApiInvocations);
+        });
+    }
+}
diff --git a/src/OpenApi/gen/XmlComments/MemberKey.cs b/src/OpenApi/gen/XmlComments/MemberKey.cs
new file mode 100644
index 000000000000..c6cb3d0741ab
--- /dev/null
+++ b/src/OpenApi/gen/XmlComments/MemberKey.cs
@@ -0,0 +1,92 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml;
+
+internal sealed record MemberKey(
+    string? DeclaringType,
+    MemberType MemberKind,
+    string? Name,
+    string? ReturnType,
+    string[]? Parameters) : IEquatable
+{
+    private static readonly SymbolDisplayFormat _typeKeyFormat = new(
+        globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included,
+        typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
+        genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters);
+
+    public static MemberKey FromMethodSymbol(IMethodSymbol method)
+    {
+        return new MemberKey(
+            $"typeof({ReplaceGenericArguments(method.ContainingType.ToDisplayString(_typeKeyFormat))})",
+            MemberType.Method,
+            method.MetadataName,
+            method.ReturnType.TypeKind == TypeKind.TypeParameter
+                ? "typeof(object)"
+                : $"typeof({method.ReturnType.ToDisplayString(_typeKeyFormat)})",
+            [.. method.Parameters.Select(p =>
+                p.Type.TypeKind == TypeKind.TypeParameter
+                    ? "typeof(object)"
+                    : $"typeof({p.Type.ToDisplayString(_typeKeyFormat)})")]);
+    }
+
+    public static MemberKey FromPropertySymbol(IPropertySymbol property)
+    {
+        return new MemberKey(
+            $"typeof({ReplaceGenericArguments(property.ContainingType.ToDisplayString(_typeKeyFormat))})",
+            MemberType.Property,
+            property.Name,
+            null,
+            null);
+    }
+
+    public static MemberKey FromTypeSymbol(INamedTypeSymbol type)
+    {
+        return new MemberKey(
+            $"typeof({ReplaceGenericArguments(type.ToDisplayString(_typeKeyFormat))})",
+            MemberType.Type,
+            null,
+            null,
+            null);
+    }
+
+    /// Supports replacing generic type arguments to support use of open
+    /// generics in `typeof` expressions for the declaring type.
+    private static string ReplaceGenericArguments(string typeName)
+    {
+        var stack = new Stack();
+        var result = new StringBuilder(typeName);
+        for (var i = 0; i < result.Length; i++)
+        {
+            if (result[i] == '<')
+            {
+                stack.Push(i);
+            }
+            else if (result[i] == '>' && stack.Count > 0)
+            {
+                var start = stack.Pop();
+                // Replace everything between < and > with empty strings separated by commas
+                var segment = result.ToString(start + 1, i - start - 1);
+                var commaCount = segment.Count(c => c == ',');
+                var replacement = new string(',', commaCount);
+                result.Remove(start + 1, i - start - 1);
+                result.Insert(start + 1, replacement);
+                i = start + replacement.Length + 1;
+            }
+        }
+        return result.ToString();
+    }
+}
+
+internal enum MemberType
+{
+    Type,
+    Property,
+    Method
+}
diff --git a/src/OpenApi/gen/XmlComments/XmlComment.InheritDoc.cs b/src/OpenApi/gen/XmlComments/XmlComment.InheritDoc.cs
new file mode 100644
index 000000000000..ee6d503449a0
--- /dev/null
+++ b/src/OpenApi/gen/XmlComments/XmlComment.InheritDoc.cs
@@ -0,0 +1,443 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Xml;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using Microsoft.AspNetCore.Analyzers.Infrastructure;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml;
+
+/// 
+/// Source code in this class is derived from Roslyn's Xml documentation comment processing
+/// to support the full resolution of  tags.
+/// For the original code, see https://github.com/dotnet/roslyn/blob/ef1d7fe925c94e96a93e4c9af50983e0f675a9fd/src/Workspaces/Core/Portable/Shared/Extensions/ISymbolExtensions.cs.
+/// 
+internal sealed partial class XmlComment
+{
+    private static string? GetDocumentationComment(ISymbol symbol, string xmlText, HashSet? visitedSymbols, Compilation compilation, CancellationToken cancellationToken)
+    {
+        try
+        {
+            if (string.IsNullOrEmpty(xmlText))
+            {
+                if (IsEligibleForAutomaticInheritdoc(symbol))
+                {
+                    xmlText = $@"";
+                }
+                else
+                {
+                    return string.Empty;
+                }
+            }
+
+            try
+            {
+                var element = XElement.Parse(xmlText, LoadOptions.PreserveWhitespace);
+                element.ReplaceNodes(RewriteMany(symbol, visitedSymbols, compilation, [.. element.Nodes()], cancellationToken));
+                xmlText = element.ToString(SaveOptions.DisableFormatting);
+            }
+            catch (Exception)
+            {
+                return null;
+            }
+            return xmlText;
+        }
+        catch (XmlException)
+        {
+            return null;
+        }
+    }
+
+    private static bool IsEligibleForAutomaticInheritdoc(ISymbol symbol)
+    {
+        // Only the following symbols are eligible to inherit documentation without an  element:
+        //
+        // * Members that override an inherited member
+        // * Members that implement an interface member
+        if (symbol.IsOverride)
+        {
+            return true;
+        }
+
+        if (symbol.ContainingType is null)
+        {
+            // Observed with certain implicit operators, such as operator==(void*, void*).
+            return false;
+        }
+
+        switch (symbol.Kind)
+        {
+            case SymbolKind.Method:
+            case SymbolKind.Property:
+            case SymbolKind.Event:
+                if (symbol.ExplicitOrImplicitInterfaceImplementations().Any())
+                {
+                    return true;
+                }
+
+                break;
+
+            default:
+                break;
+        }
+
+        return false;
+    }
+
+    private static XNode[] RewriteMany(ISymbol symbol, HashSet? visitedSymbols, Compilation compilation, XNode[] nodes, CancellationToken cancellationToken)
+    {
+        var result = new List();
+        foreach (var child in nodes)
+        {
+            result.AddRange(RewriteInheritdocElements(symbol, visitedSymbols, compilation, child, cancellationToken));
+        }
+
+        return [.. result];
+    }
+
+    private static XNode[] RewriteInheritdocElements(ISymbol symbol, HashSet? visitedSymbols, Compilation compilation, XNode node, CancellationToken cancellationToken)
+    {
+        if (node.NodeType == XmlNodeType.Element)
+        {
+            var element = (XElement)node;
+            if (ElementNameIs(element, DocumentationCommentXmlNames.InheritdocElementName))
+            {
+                var rewritten = RewriteInheritdocElement(symbol, visitedSymbols, compilation, element, cancellationToken);
+                if (rewritten is not null)
+                {
+                    return rewritten;
+                }
+            }
+        }
+
+        if (node is not XContainer container)
+        {
+            return [Copy(node, copyAttributeAnnotations: false)];
+        }
+
+        var oldNodes = container.Nodes();
+
+        // Do this after grabbing the nodes, so we don't see copies of them.
+        container = Copy(container, copyAttributeAnnotations: false);
+
+        // WARN: don't use node after this point - use container since it's already been copied.
+
+        if (oldNodes != null)
+        {
+            var rewritten = RewriteMany(symbol, visitedSymbols, compilation, [.. oldNodes], cancellationToken);
+            container.ReplaceNodes(rewritten);
+        }
+
+        return [container];
+    }
+
+    private static XNode[]? RewriteInheritdocElement(ISymbol memberSymbol, HashSet? visitedSymbols, Compilation compilation, XElement element, CancellationToken cancellationToken)
+    {
+        var crefAttribute = element.Attribute(XName.Get(DocumentationCommentXmlNames.CrefAttributeName));
+        var pathAttribute = element.Attribute(XName.Get(DocumentationCommentXmlNames.PathAttributeName));
+
+        var candidate = GetCandidateSymbol(memberSymbol);
+        var hasCandidateCref = candidate is object;
+
+        var hasCrefAttribute = crefAttribute is not null;
+        var hasPathAttribute = pathAttribute is not null;
+        if (!hasCrefAttribute && !hasCandidateCref)
+        {
+            // No cref available
+            return null;
+        }
+
+        ISymbol? symbol;
+        if (crefAttribute is null)
+        {
+            if (candidate is null)
+            {
+                return null;
+            }
+            symbol = candidate;
+        }
+        else
+        {
+            var crefValue = crefAttribute.Value;
+            symbol = DocumentationCommentId.GetFirstSymbolForDeclarationId(crefValue, compilation);
+            if (symbol is null)
+            {
+                return null;
+            }
+        }
+
+        visitedSymbols ??= [];
+        if (!visitedSymbols.Add(symbol!))
+        {
+            // Prevent recursion
+            return null;
+        }
+
+        try
+        {
+            var xmlDocumentation = symbol.GetDocumentationCommentXml(cancellationToken: cancellationToken);
+            if (xmlDocumentation is null)
+            {
+                return [];
+            }
+            var inheritedDocumentation = GetDocumentationComment(symbol, xmlDocumentation, visitedSymbols, compilation, cancellationToken);
+            if (inheritedDocumentation == string.Empty)
+            {
+                return [];
+            }
+
+            var document = XDocument.Parse(inheritedDocumentation, LoadOptions.PreserveWhitespace);
+            string xpathValue;
+            if (string.IsNullOrEmpty(pathAttribute?.Value))
+            {
+                xpathValue = BuildXPathForElement(element.Parent!);
+            }
+            else
+            {
+                xpathValue = pathAttribute!.Value;
+                if (xpathValue.StartsWith("/", StringComparison.InvariantCulture))
+                {
+                    // Account for the root  or  element
+                    xpathValue = "/*" + xpathValue;
+                }
+            }
+
+            // Consider the following code, we want Test.Clone to say "Clones a Test" instead of "Clones a int", thus
+            // we rewrite `typeparamref`s as cref pointing to the correct type:
+            /*
+                public class Test : ICloneable>
+                {
+                    /// 
+                    public Test Clone() => new();
+                }
+
+                /// A type that has clonable instances.
+                /// The type of instances that can be cloned.
+                public interface ICloneable
+                {
+                    /// Clones a .
+                    public T Clone();
+                }
+            */
+            // Note: there is no way to cref an instantiated generic type. See https://github.com/dotnet/csharplang/issues/401
+            var typeParameterRefs = document.Descendants(DocumentationCommentXmlNames.TypeParameterReferenceElementName).ToImmutableArray();
+            foreach (var typeParameterRef in typeParameterRefs)
+            {
+                if (typeParameterRef.Attribute(DocumentationCommentXmlNames.NameAttributeName) is XAttribute typeParamName)
+                {
+                    var targetTypeParameter = symbol.GetAllTypeParameters().FirstOrDefault(p => p.Name == typeParamName.Value);
+                    if (targetTypeParameter is not null
+                        && symbol.OriginalDefinition.GetAllTypeParameters().IndexOf(targetTypeParameter) is int index
+                        && index >= 0)
+                    {
+                        var typeArgs = symbol.GetAllTypeArguments();
+                        if (index < typeArgs.Length)
+                        {
+                            var docId = typeArgs[index].GetDocumentationCommentId();
+                            if (docId != null && !docId.StartsWith("!", StringComparison.OrdinalIgnoreCase))
+                            {
+                                var replacement = new XElement(DocumentationCommentXmlNames.SeeElementName);
+                                replacement.SetAttributeValue(DocumentationCommentXmlNames.CrefAttributeName, docId);
+                                typeParameterRef.ReplaceWith(replacement);
+                            }
+                        }
+                    }
+                }
+            }
+
+            var loadedElements = TrySelectNodes(document, xpathValue);
+            return loadedElements ?? [];
+        }
+        catch (XmlException)
+        {
+            return [];
+        }
+        finally
+        {
+            visitedSymbols.Remove(symbol);
+        }
+
+        // Local functions
+        static ISymbol? GetCandidateSymbol(ISymbol memberSymbol)
+        {
+            if (memberSymbol.ExplicitInterfaceImplementations().Any())
+            {
+                return memberSymbol.ExplicitInterfaceImplementations().First();
+            }
+            else if (memberSymbol.IsOverride)
+            {
+                return memberSymbol.GetOverriddenMember();
+            }
+
+            if (memberSymbol is IMethodSymbol methodSymbol)
+            {
+                if (methodSymbol.MethodKind is MethodKind.Constructor or MethodKind.StaticConstructor)
+                {
+                    var baseType = memberSymbol.ContainingType.BaseType;
+#nullable disable // Can 'baseType' be null here? https://github.com/dotnet/roslyn/issues/39166
+                    return baseType.Constructors.Where(c => IsSameSignature(methodSymbol, c)).FirstOrDefault();
+#nullable enable
+                }
+                else
+                {
+                    // check for implicit interface
+                    return methodSymbol.ExplicitOrImplicitInterfaceImplementations().FirstOrDefault();
+                }
+            }
+            else if (memberSymbol is INamedTypeSymbol typeSymbol)
+            {
+                if (typeSymbol.TypeKind == TypeKind.Class)
+                {
+                    // Classes use the base type as the default inheritance candidate. A different target (e.g. an
+                    // interface) can be provided via the 'path' attribute.
+                    return typeSymbol.BaseType;
+                }
+                else if (typeSymbol.TypeKind == TypeKind.Interface)
+                {
+                    return typeSymbol.Interfaces.FirstOrDefault();
+                }
+                else
+                {
+                    // This includes structs, enums, and delegates as mentioned in the inheritdoc spec
+                    return null;
+                }
+            }
+
+            return memberSymbol.ExplicitOrImplicitInterfaceImplementations().FirstOrDefault();
+        }
+
+        static bool IsSameSignature(IMethodSymbol left, IMethodSymbol right)
+        {
+            if (left.Parameters.Length != right.Parameters.Length)
+            {
+                return false;
+            }
+
+            if (left.IsStatic != right.IsStatic)
+            {
+                return false;
+            }
+
+            if (!SymbolEqualityComparer.Default.Equals(left.ReturnType, right.ReturnType))
+            {
+                return false;
+            }
+
+            for (var i = 0; i < left.Parameters.Length; i++)
+            {
+                if (!SymbolEqualityComparer.Default.Equals(left.Parameters[i].Type, right.Parameters[i].Type))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        static string BuildXPathForElement(XElement element)
+        {
+            if (ElementNameIs(element, "member") || ElementNameIs(element, "doc"))
+            {
+                // Avoid string concatenation allocations for inheritdoc as a top-level element
+                return "/*/node()[not(self::overloads)]";
+            }
+
+            var path = "/node()[not(self::overloads)]";
+            for (var current = element; current != null; current = current.Parent)
+            {
+                var currentName = current.Name.ToString();
+                if (ElementNameIs(current, "member") || ElementNameIs(current, "doc"))
+                {
+                    // Allow  and  to be used interchangeably
+                    currentName = "*";
+                }
+
+                path = "/" + currentName + path;
+            }
+
+            return path;
+        }
+    }
+
+    private static bool ElementNameIs(XElement element, string name)
+        => string.IsNullOrEmpty(element.Name.NamespaceName) && DocumentationCommentXmlNames.ElementEquals(element.Name.LocalName, name);
+
+    private static TNode Copy(TNode node, bool copyAttributeAnnotations)
+        where TNode : XNode
+    {
+        XNode copy;
+
+        // Documents can't be added to containers, so our usual copy trick won't work.
+        if (node.NodeType == XmlNodeType.Document)
+        {
+            copy = new XDocument((XDocument)(object)node);
+        }
+        else
+        {
+            XContainer temp = new XElement("temp");
+            temp.Add(node);
+            copy = temp.LastNode!;
+            temp.RemoveNodes();
+        }
+
+        AnalyzerDebug.Assert(copy != node);
+        AnalyzerDebug.Assert(copy.Parent == null); // Otherwise, when we give it one, it will be copied.
+
+        // Copy annotations, the above doesn't preserve them.
+        // We need to preserve Location annotations as well as line position annotations.
+        CopyAnnotations(node, copy);
+
+        // We also need to preserve line position annotations for all attributes
+        // since we report errors with attribute locations.
+        if (copyAttributeAnnotations && node.NodeType == XmlNodeType.Element)
+        {
+            var sourceElement = (XElement)(object)node;
+            var targetElement = (XElement)copy;
+
+            var sourceAttributes = sourceElement.Attributes().GetEnumerator();
+            var targetAttributes = targetElement.Attributes().GetEnumerator();
+            while (sourceAttributes.MoveNext() && targetAttributes.MoveNext())
+            {
+                AnalyzerDebug.Assert(sourceAttributes.Current.Name == targetAttributes.Current.Name);
+                CopyAnnotations(sourceAttributes.Current, targetAttributes.Current);
+            }
+        }
+
+        return (TNode)copy;
+    }
+
+    private static void CopyAnnotations(XObject source, XObject target)
+    {
+        foreach (var annotation in source.Annotations())
+        {
+            target.AddAnnotation(annotation);
+        }
+    }
+
+    private static XNode[]? TrySelectNodes(XNode node, string xpath)
+    {
+        try
+        {
+            var xpathResult = (IEnumerable)System.Xml.XPath.Extensions.XPathEvaluate(node, xpath);
+
+            // Throws InvalidOperationException if the result of the XPath is an XDocument:
+            return xpathResult?.Cast().ToArray();
+        }
+        catch (InvalidOperationException)
+        {
+            return null;
+        }
+        catch (XPathException)
+        {
+            return null;
+        }
+    }
+}
diff --git a/src/OpenApi/gen/XmlComments/XmlComment.cs b/src/OpenApi/gen/XmlComments/XmlComment.cs
new file mode 100644
index 000000000000..e1ece052e315
--- /dev/null
+++ b/src/OpenApi/gen/XmlComments/XmlComment.cs
@@ -0,0 +1,218 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml;
+
+internal sealed partial class XmlComment
+{
+    public string? Summary { get; internal set; }
+    public string? Description { get; internal set; }
+    public string? Value { get; internal set; }
+    public string? Remarks { get; internal set; }
+    public string? Returns { get; internal set; }
+    public bool? Deprecated { get; internal set; }
+    public List? Examples { get; internal set; }
+    public List Parameters { get; internal set; } = [];
+    public List Responses { get; internal set; } = [];
+
+    private XmlComment(Compilation compilation, string xml)
+    {
+        // Treat  as 
+        if (xml.StartsWith("", StringComparison.InvariantCulture) && xml.EndsWith("", StringComparison.InvariantCulture))
+        {
+            xml = xml.Substring(5, xml.Length - 11);
+            xml = xml.Trim();
+        }
+
+        // Transform triple slash comment
+        var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
+
+        ResolveCrefLink(compilation, doc, $"//{DocumentationCommentXmlNames.SeeAlsoElementName}[@cref]");
+        ResolveCrefLink(compilation, doc, $"//{DocumentationCommentXmlNames.SeeElementName}[@cref]");
+        // Resolve  and  tags into bullets
+        ResolveListTags(doc);
+        // Resolve  tags into code blocks
+        ResolveCodeTags(doc, DocumentationCommentXmlNames.CodeElementName, "```");
+        // Resolve  and typeparamref tags into parameter names
+        ResolveParamRefTags(doc);
+        // Resolve  tags into underlying content
+        ResolveParaTags(doc);
+        // Resolve  tags into inline code blocks
+        ResolveCodeTags(doc, DocumentationCommentXmlNames.CElementName, "`");
+
+        var nav = doc.CreateNavigator();
+        Summary = GetSingleNodeValue(nav, "/member/summary");
+        Description = GetSingleNodeValue(nav, "/member/description");
+        Remarks = GetSingleNodeValue(nav, "/member/remarks");
+        Returns = GetSingleNodeValue(nav, "/member/returns");
+        Value = GetSingleNodeValue(nav, "/member/value");
+        Deprecated = GetSingleNodeValue(nav, "/member/deprecated") == "true";
+
+        Examples = [.. GetMultipleExampleNodes(nav, "/member/example")];
+        Parameters = XmlParameterComment.GetXmlParameterListComment(nav, "/member/param");
+        Responses = XmlResponseComment.GetXmlResponseCommentList(nav, "/member/response");
+    }
+
+    private static void ResolveListTags(XDocument document)
+    {
+        var listElements = document.Descendants(DocumentationCommentXmlNames.ListElementName).ToArray();
+        foreach (var element in listElements)
+        {
+            if (element is null)
+            {
+                continue;
+            }
+            var rawListType = element.Attribute(DocumentationCommentXmlNames.TypeAttributeName)?.Value;
+            var listPrefix = rawListType switch
+            {
+                "table" => "* ",
+                "number" => "1. ",
+                "bullet" => "* ",
+                _ => "* ",
+            };
+            var items = element.Elements(DocumentationCommentXmlNames.ItemElementName);
+            if (items == null)
+            {
+                continue;
+            }
+            var bulletPoints = items
+                .Select(item => listPrefix + item?.Value?.TrimEachLine() ?? string.Empty)
+                .ToList();
+
+            var bulletText = string.Join("\n", bulletPoints);
+            element.ReplaceWith(new XText(bulletText));
+        }
+    }
+
+    private static void ResolveCodeTags(XDocument document, string tagName, string codeBlockDelimiter)
+    {
+        var codeElements = document.Descendants(tagName).ToArray();
+        foreach (var element in codeElements)
+        {
+            if (element is null)
+            {
+                continue;
+            }
+            var codeText = element.Value.TrimEachLine();
+            element.ReplaceWith(new XText(codeBlockDelimiter + codeText + codeBlockDelimiter));
+        }
+    }
+
+    private static void ResolveParamRefTags(XDocument document)
+    {
+        var paramRefElements = document.Descendants(DocumentationCommentXmlNames.ParameterReferenceElementName).ToArray();
+        foreach (var element in paramRefElements)
+        {
+            if (element is null)
+            {
+                continue;
+            }
+            var paramName = element.Attribute(DocumentationCommentXmlNames.NameAttributeName)?.Value;
+            if (paramName is null)
+            {
+                continue;
+            }
+            element.ReplaceWith(new XText(paramName));
+        }
+
+        var typeParamRefElements = document.Descendants(DocumentationCommentXmlNames.TypeParameterReferenceElementName).ToArray();
+        foreach (var element in typeParamRefElements)
+        {
+            if (element is null)
+            {
+                continue;
+            }
+            var paramName = element.Attribute(DocumentationCommentXmlNames.NameAttributeName)?.Value;
+            if (paramName is null)
+            {
+                continue;
+            }
+            element.ReplaceWith(new XText(paramName));
+        }
+    }
+
+    private static void ResolveParaTags(XDocument document)
+    {
+        var paraElements = document.Descendants(DocumentationCommentXmlNames.ParaElementName).ToArray();
+        foreach (var element in paraElements)
+        {
+            if (element is null)
+            {
+                continue;
+            }
+            var paraText = element.Value.TrimEachLine();
+            element.ReplaceWith(new XText(paraText));
+        }
+    }
+
+    public static XmlComment? Parse(ISymbol symbol, Compilation compilation, string xmlText, CancellationToken cancellationToken)
+    {
+        // Avoid processing empty or malformed XML comments.
+        if (string.IsNullOrEmpty(xmlText) ||
+            xmlText.StartsWith("
     true
     true
+    true
+    $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated
+    true
   
 
   
@@ -31,4 +34,9 @@
     
   
 
+  
+  
+    
+  
+
 
diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
index a0ef68ed3021..83bf4a785c74 100644
--- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
+++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
@@ -46,4 +46,14 @@
     
   
 
+  
+  
+    
+    
+    
+  
+
 
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs
new file mode 100644
index 000000000000..f23fed7018c9
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/GenerateAdditionalXmlFilesForOpenApiTests.cs
@@ -0,0 +1,118 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Text.Json;
+using Microsoft.Extensions.CommandLineUtils;
+
+namespace Microsoft.AspNetCore.OpenApi.Build.Tests;
+
+public class GenerateAdditionalXmlFilesForOpenApiTests
+{
+    private static readonly TimeSpan _defaultProcessTimeout = TimeSpan.FromSeconds(45);
+
+    [Fact]
+    public void VerifiesTargetGeneratesXmlFiles()
+    {
+        var projectFile = CreateTestProject();
+        var startInfo = new ProcessStartInfo
+        {
+            FileName = DotNetMuxer.MuxerPathOrDefault(),
+            Arguments = $"build -t:Build -getItem:AdditionalFiles",
+            WorkingDirectory = Path.GetDirectoryName(projectFile),
+            RedirectStandardOutput = true,
+            RedirectStandardError = true,
+            UseShellExecute = false
+        };
+
+        using var process = Process.Start(startInfo);
+        process.WaitForExit(_defaultProcessTimeout);
+        Assert.Equal(0, process.ExitCode);
+
+        var output = process.StandardOutput.ReadToEnd();
+        var result = JsonSerializer.Deserialize(output);
+        var additionalFiles = result.Items.AdditionalFiles;
+        Assert.NotEmpty(additionalFiles);
+
+        // Captures ProjectReferences and PackageReferences in project.
+        var identities = additionalFiles.Select(x => x["Identity"]).ToArray();
+        Assert.Collection(identities,
+            x => Assert.EndsWith("ClassLibrary.xml", x)
+        );
+    }
+
+    private static string CreateTestProject()
+    {
+        var classLibTempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+        Directory.CreateDirectory(classLibTempPath);
+
+        // Create a class library project
+        var classLibProjectPath = Path.Combine(classLibTempPath, "ClassLibrary.csproj");
+        var classLibProjectContent = """
+
+  
+    net10.0
+    true
+  
+
+
+""";
+        File.WriteAllText(classLibProjectPath, classLibProjectContent);
+
+        // Create a class library source file
+        var classLibSourcePath = Path.Combine(classLibTempPath, "Class1.cs");
+        var classLibSourceContent = """
+/// 
+/// This is a class
+/// 
+public class Class1
+{
+}
+""";
+        File.WriteAllText(classLibSourcePath, classLibSourceContent);
+
+        var tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+        Directory.CreateDirectory(tempPath);
+
+        // Copy the targets file to the temp directory
+        var sourceTargetsPath = Path.Combine(AppContext.BaseDirectory, "Microsoft.AspNetCore.OpenApi.targets");
+        var targetTargetsPath = Path.Combine(tempPath, "Microsoft.AspNetCore.OpenApi.targets");
+        File.Copy(sourceTargetsPath, targetTargetsPath);
+
+        var projectPath = Path.Combine(tempPath, "TestProject.csproj");
+        var projectContent = $$"""
+
+    
+
+  
+    net10.0
+    Exe
+  
+
+  
+    
+    
+  
+
+""";
+        File.WriteAllText(projectPath, projectContent);
+
+        // Create a test source file
+        var sourcePath = Path.Combine(tempPath, "Program.cs");
+        var sourceContent = """
+using Microsoft.AspNetCore.Builder;
+
+var app = WebApplication.Create(args);
+
+app.MapGet("/", () => "Hello World!");
+
+app.Run();
+""";
+        File.WriteAllText(sourcePath, sourceContent);
+
+        return projectPath;
+    }
+
+    private record ItemsResult(AdditionalFilesResult Items);
+    private record AdditionalFilesResult(Dictionary[] AdditionalFiles);
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/Microsoft.AspNetCore.OpenApi.Build.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/Microsoft.AspNetCore.OpenApi.Build.Tests.csproj
new file mode 100644
index 000000000000..4aa6ddf207cf
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Build.Tests/Microsoft.AspNetCore.OpenApi.Build.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+  
+    $(DefaultNetCoreTargetFramework)
+    enable
+  
+
+  
+    
+  
+
+  
+    
+  
+
+  
+    
+  
+
+
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/AddOpenApiTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/AddOpenApiTests.cs
new file mode 100644
index 000000000000..3aebcdf825b3
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/AddOpenApiTests.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests;
+
+[UsesVerify]
+public class AddOpenApiTests
+{
+    [Fact]
+    public async Task CanInterceptAddOpenApi()
+    {
+        var source = """
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+
+// No parameters
+builder.Services.AddOpenApi();
+// Name parameter
+builder.Services.AddOpenApi("v2");
+var documentName = "v4";
+builder.Services.AddOpenApi(documentName); // Should not be intercepted
+// Configure options parameter
+builder.Services.AddOpenApi(options =>
+{
+    options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi2_0;
+});
+// Name and configure options parameters
+builder.Services.AddOpenApi("v2", options =>
+{
+    options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi2_0;
+});
+// Another name and configure options invocation that should be covered
+// by the same interceptor method as the previous one
+builder.Services.AddOpenApi("v3", options =>
+{
+    options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_0;
+});
+
+var app = builder.Build();
+
+app.MapPost("", () => "Hello world!");
+
+app.Run();
+""";
+
+        var generator = new XmlCommentGenerator();
+        await SnapshotTestHelper.Verify(source, generator, out var compilation);
+        Assert.Empty(compilation.GetDiagnostics().Where(d => d.Severity > DiagnosticSeverity.Warning));
+    }
+}
diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/CompletenessTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/CompletenessTests.cs
new file mode 100644
index 000000000000..b0baa35b59dd
--- /dev/null
+++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/CompletenessTests.cs
@@ -0,0 +1,369 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using System.Text.Json.Nodes;
+using Microsoft.OpenApi.Models;
+
+namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests;
+
+// Test scenarios derived from https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/xmldoc/examples
+
+[UsesVerify]
+public class CompletenessTests
+{
+    [Fact]
+    public async Task SupportsAllXmlTagsOnSchemas()
+    {
+        var source = """
+using System;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder();
+
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+app.MapPost("/example-class", (ExampleClass example) => { });
+app.MapPost("/person", (Person person) => { });
+app.MapPost("/derived-class", (DerivedClass child) => { });
+app.MapPost("/main-class", (MainClass main) => { });
+app.MapPost("/test-interface", (ITestInterface test) => { });
+app.MapPost("/implementing-class", (ImplementingClass impl) => { });
+app.MapPost("/inherit-only-returns", (InheritOnlyReturns returns) => { });
+app.MapPost("/inherit-all-but-remarks", (InheritAllButRemarks remarks) => { });
+app.MapPost("/generic-class", (GenericClass generic) => { });
+app.MapPost("/params-and-param-refs", (ParamsAndParamRefs refs) => { });
+
+
+app.Run();
+
+/// 
+/// Every class and member should have a one sentence
+/// summary describing its purpose.
+/// 
+/// 
+/// You can expand on that one sentence summary to
+/// provide more information for readers. In this case,
+/// the ExampleClass provides different C#
+/// elements to show how you would add documentation
+///comments for most elements in a typical class.
+/// 
+/// The remarks can add multiple paragraphs, so you can
+/// write detailed information for developers that use
+/// your work. You should add everything needed for
+/// readers to be successful. This class contains
+/// examples for the following:
+/// 
+/// 
+/// 
+/// Summary
+/// 
+/// This should provide a one sentence summary of the class or member.
+/// 
+/// 
+/// 
+/// Remarks
+/// 
+/// This is typically a more detailed description of the class or member
+/// 
+/// 
+/// 
+/// para
+/// 
+/// The para tag separates a section into multiple paragraphs
+/// 
+/// 
+/// 
+/// list
+/// 
+/// Provides a list of terms or elements
+/// 
+/// 
+/// 
+/// returns, param
+/// 
+/// Used to describe parameters and return values
+/// 
+/// 
+/// 
+/// value
+/// Used to describe properties
+/// 
+/// 
+/// exception
+/// 
+/// Used to describe exceptions that may be thrown
+/// 
+/// 
+/// 
+/// c, cref, see, seealso
+/// 
+/// These provide code style and links to other
+/// documentation elements
+/// 
+/// 
+/// 
+/// example, code
+/// 
+/// These are used for code examples
+/// 
+/// 
+/// 
+/// 
+/// The list above uses the "table" style. You could
+/// also use the "bullet" or "number" style. Neither
+/// would typically use the "term" element.
+/// 
+/// Note: paragraphs are double spaced. Use the *br* +/// tag for single spaced lines. +///
+///
+public class ExampleClass +{ + /// + /// The Label property represents a label + /// for this instance. + /// + /// + /// The is a + /// that you use for a label. + /// + /// Note that there isn't a way to provide a "cref" to + /// each accessor, only to the property itself. + /// + /// + public string? Label + { + get; + set; + } + + /// + /// Adds two integers and returns the result. + /// + /// + /// The sum of two integers. + /// + /// + /// The left operand of the addition. + /// + /// + /// The right operand of the addition. + /// + /// + /// + /// int c = Math.Add(4, 5); + /// if (c > 10) + /// { + /// Console.WriteLine(c); + /// } + /// + /// + /// + /// Thrown when one parameter is + /// MaxValue and the other is + /// greater than 0. + /// Note that here you can also use + /// + /// to point a web page instead. + /// + /// for a list of all + /// the tags in these examples. + /// + public static int Add(int left, int right) + { + if ((left == int.MaxValue && right > 0) || (right == int.MaxValue && left > 0)) + throw new System.OverflowException(); + + return left + right; + } +} + +/// +/// This is an example of a positional record. +/// +/// +/// There isn't a way to add XML comments for properties +/// created for positional records, yet. The language +/// design team is still considering what tags should +/// be supported, and where. Currently, you can use +/// the "param" tag to describe the parameters to the +/// primary constructor. +/// +/// +/// This tag will apply to the primary constructor parameter. +/// +/// +/// This tag will apply to the primary constructor parameter. +/// +public record Person(string FirstName, string LastName); + +/// +/// A summary about this class. +/// +/// +/// These remarks would explain more about this class. +/// In this example, these comments also explain the +/// general information about the derived class. +/// +public class MainClass +{ +} + +/// +public class DerivedClass : MainClass +{ +} + +/// +/// This interface would describe all the methods in +/// its contract. +/// +/// +/// While elided for brevity, each method or property +/// in this interface would contain docs that you want +/// to duplicate in each implementing class. +/// +public interface ITestInterface +{ + /// + /// This method is part of the test interface. + /// + /// + /// This content would be inherited by classes + /// that implement this interface when the + /// implementing class uses "inheritdoc" + /// + /// The value of + /// The argument to the method + int Method(int arg); +} + +/// +public class ImplementingClass : ITestInterface +{ + // doc comments are inherited here. + public int Method(int arg) => arg; +} + +/// +/// This class shows hows you can "inherit" the doc +/// comments from one method in another method. +/// +/// +/// You can inherit all comments, or only a specific tag, +/// represented by an xpath expression. +/// +public class InheritOnlyReturns +{ + /// + /// In this example, this summary is only visible for this method. + /// + /// A boolean + public static bool MyParentMethod(bool x) { return x; } + + /// + public static bool MyChildMethod() { return false; } +} + +/// +/// This class shows an example of sharing comments across methods. +/// +public class InheritAllButRemarks +{ + /// + /// In this example, this summary is visible on all the methods. + /// + /// + /// The remarks can be inherited by other methods + /// using the xpath expression. + /// + /// A boolean + public static bool MyParentMethod(bool x) { return x; } + + /// + public static bool MyChildMethod() { return false; } +} + +/// +/// This is a generic class. +/// +/// +/// This example shows how to specify the +/// type as a cref attribute. +/// In generic classes and methods, you'll often want to reference the +/// generic type, or the type parameter. +/// +class GenericClass +{ + // Fields and members. +} + +/// +/// This shows examples of typeparamref and typeparam tags +/// +public class ParamsAndParamRefs +{ + /// + /// The GetGenericValue method. + /// + /// + /// This sample shows how to specify the + /// method as a cref attribute. + /// The parameter and return value are both of an arbitrary type, + /// + /// + public static T GetGenericValue(T para) + { + return para; + } +} +"""; + var generator = new XmlCommentGenerator(); + await SnapshotTestHelper.Verify(source, generator, out var compilation); + await SnapshotTestHelper.VerifyOpenApi(compilation, document => + { + var path = document.Paths["/example-class"].Operations[OperationType.Post]; + var exampleClass = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("Every class and member should have a one sentence\nsummary describing its purpose.", exampleClass.Description, ignoreLineEndingDifferences: true); + Assert.Equal("The `Label` property represents a label\nfor this instance.", exampleClass.Properties["label"].Description, ignoreLineEndingDifferences: true); + + path = document.Paths["/person"].Operations[OperationType.Post]; + var person = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("This is an example of a positional record.", person.Description); + Assert.Equal("This tag will apply to the primary constructor parameter.", person.Properties["firstName"].Description); + Assert.Equal("This tag will apply to the primary constructor parameter.", person.Properties["lastName"].Description); + + path = document.Paths["/derived-class"].Operations[OperationType.Post]; + var derivedClass = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("A summary about this class.", derivedClass.Description); + + path = document.Paths["/main-class"].Operations[OperationType.Post]; + var mainClass = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("A summary about this class.", mainClass.Description); + + path = document.Paths["/implementing-class"].Operations[OperationType.Post]; + var implementingClass = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("This interface would describe all the methods in\nits contract.", implementingClass.Description, ignoreLineEndingDifferences: true); + + path = document.Paths["/inherit-only-returns"].Operations[OperationType.Post]; + var inheritOnlyReturns = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("This class shows hows you can \"inherit\" the doc\ncomments from one method in another method.", inheritOnlyReturns.Description, ignoreLineEndingDifferences: true); + + path = document.Paths["/inherit-all-but-remarks"].Operations[OperationType.Post]; + var inheritAllButRemarks = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("This class shows an example of sharing comments across methods.", inheritAllButRemarks.Description); + + path = document.Paths["/generic-class"].Operations[OperationType.Post]; + var genericClass = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("This is a generic class.", genericClass.Description); + + path = document.Paths["/params-and-param-refs"].Operations[OperationType.Post]; + var paramsAndParamRefs = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("This shows examples of typeparamref and typeparam tags", paramsAndParamRefs.Description); + }); + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests.csproj new file mode 100644 index 000000000000..ff3ca4db6229 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests.csproj @@ -0,0 +1,37 @@ + + + + $(DefaultNetCoreTargetFramework) + enable + false + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/ModuleInitializer.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/ModuleInitializer.cs new file mode 100644 index 000000000000..bf5defe4b9fc --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/ModuleInitializer.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests; + +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() => + VerifySourceGenerators.Initialize(); +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs new file mode 100644 index 000000000000..b88c0e34d2db --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.Controllers.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests; + +public partial class OperationTests +{ + [Fact] + public async Task SupportsXmlCommentsOnOperationsFromControllers() + { + var source = """ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(); + +builder.Services + .AddControllers() + .AddApplicationPart(typeof(TestController).Assembly) + .AddApplicationPart(typeof(Test2Controller).Assembly); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); + +[ApiController] +[Route("[controller]")] +public class TestController : ControllerBase +{ + /// + /// A summary of the action. + /// + /// + /// A description of the action. + /// + [HttpGet] + public string Get() + { + return "Hello, World!"; + } +} + +[ApiController] +[Route("[controller]")] +public class Test2Controller : ControllerBase +{ + /// The name of the person. + /// Returns the greeting. + [HttpGet] + public string Get(string name) + { + return $"Hello, {name}!"; + } + + /// The id associated with the request. + [HttpGet("HelloByInt")] + public string Get(int id) + { + return $"Hello, {id}!"; + } + + /// The todo to insert into the database. + [HttpPost] + public string Post(Todo todo) + { + return $"Hello, {todo.Title}!"; + } +} + +public partial class Program {} + +public record Todo(int Id, string Title, bool Completed); +"""; + var generator = new XmlCommentGenerator(); + await SnapshotTestHelper.Verify(source, generator, out var compilation); + await SnapshotTestHelper.VerifyOpenApi(compilation, document => + { + var path = document.Paths["/Test"].Operations[OperationType.Get]; + Assert.Equal("A summary of the action.", path.Summary); + Assert.Equal("A description of the action.", path.Description); + + var path2 = document.Paths["/Test2"].Operations[OperationType.Get]; + Assert.Equal("The name of the person.", path2.Parameters[0].Description); + Assert.Equal("Returns the greeting.", path2.Responses["200"].Description); + + var path2again = document.Paths["/Test2/HelloByInt"].Operations[OperationType.Get]; + Assert.Equal("The id associated with the request.", path2again.Parameters[0].Description); + + var path3 = document.Paths["/Test2"].Operations[OperationType.Post]; + Assert.Equal("The todo to insert into the database.", path3.RequestBody.Description); + }); + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs new file mode 100644 index 000000000000..76fd57471901 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/OperationTests.MinimalApis.cs @@ -0,0 +1,164 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests; + +[UsesVerify] +public partial class OperationTests +{ + [Fact] + public async Task SupportsXmlCommentsOnOperationsFromMinimalApis() + { + var source = """ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.MapGet("/1", RouteHandlerExtensionMethods.Get); +app.MapGet("/2", RouteHandlerExtensionMethods.Get2); +app.MapGet("/3", RouteHandlerExtensionMethods.Get3); +app.MapGet("/4", RouteHandlerExtensionMethods.Get4); +app.MapGet("/5", RouteHandlerExtensionMethods.Get5); +app.MapPost("/6", RouteHandlerExtensionMethods.Post6); +app.MapPut("/7", RouteHandlerExtensionMethods.Put7); + +app.Run(); + +public static class RouteHandlerExtensionMethods +{ + /// + /// A summary of the action. + /// + /// + /// A description of the action. + /// + public static string Get() + { + return "Hello, World!"; + } + + /// The name of the person. + /// Returns the greeting. + public static string Get2(string name) + { + return $"Hello, {name}!"; + } + + /// The name of the person. + public static string Get3(string name) + { + return $"Hello, {name}!"; + } + + /// Indicates that the value was not found. + public static NotFound Get4() + { + return TypedResults.NotFound("Not found!"); + } + + /// Indicates that the value is even. + /// Indicates that the value is less than 50. + /// Indicates that the value was not found. + public static Results, Ok, Created> Get5() + { + var randomNumber = Random.Shared.Next(); + if (randomNumber % 2 == 0) + { + return TypedResults.Ok("is even"); + } + else if (randomNumber < 50) + { + return TypedResults.Created("is less than 50"); + } + return TypedResults.NotFound("Not found!"); + } + + /// + /// Creates a new user. + /// + /// + /// Sample request: + /// POST /6 + /// { + /// "username": "johndoe", + /// "email": "john@example.com" + /// } + /// + /// The user information. + /// Successfully created the user. + /// If the user data is invalid. + public static IResult Post6(User user) + { + return TypedResults.Created($"/users/{user.Username}", user); + } + + /// + /// Updates an existing record. + /// + /// Legacy ID parameter - use uuid instead. + /// Unique identifier for the record. + /// Update successful. + /// Legacy response - will be removed. + public static IResult Put7(int? id, string uuid) + { + return TypedResults.NoContent(); + } +} + +public class User +{ + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} +"""; + var generator = new XmlCommentGenerator(); + await SnapshotTestHelper.Verify(source, generator, out var compilation); + await SnapshotTestHelper.VerifyOpenApi(compilation, document => + { + var path = document.Paths["/1"].Operations[OperationType.Get]; + Assert.Equal("A summary of the action.", path.Summary); + Assert.Equal("A description of the action.", path.Description); + + var path2 = document.Paths["/2"].Operations[OperationType.Get]; + Assert.Equal("The name of the person.", path2.Parameters[0].Description); + Assert.Equal("Returns the greeting.", path2.Responses["200"].Description); + + var path3 = document.Paths["/3"].Operations[OperationType.Get]; + Assert.Equal("The name of the person.", path3.Parameters[0].Description); + var example = Assert.IsAssignableFrom(path3.Parameters[0].Example); + Assert.Equal("\"Testy McTester\"", example.ToJsonString()); + + var path4 = document.Paths["/4"].Operations[OperationType.Get]; + var response = path4.Responses["404"]; + Assert.Equal("Indicates that the value was not found.", response.Description); + + var path5 = document.Paths["/5"].Operations[OperationType.Get]; + Assert.Equal("Indicates that the value was not found.", path5.Responses["404"].Description); + Assert.Equal("Indicates that the value is even.", path5.Responses["200"].Description); + Assert.Equal("Indicates that the value is less than 50.", path5.Responses["201"].Description); + + var path6 = document.Paths["/6"].Operations[OperationType.Post]; + Assert.Equal("Creates a new user.", path6.Summary); + Assert.Contains("Sample request:", path6.Description); + var userParam = path6.RequestBody.Content["application/json"]; + var userExample = Assert.IsAssignableFrom(userParam.Example); + Assert.Equal("johndoe", userExample["username"].GetValue()); + + var path7 = document.Paths["/7"].Operations[OperationType.Put]; + var idParam = path7.Parameters.First(p => p.Name == "id"); + Assert.True(idParam.Deprecated); + Assert.Equal("Legacy ID parameter - use uuid instead.", idParam.Description); + }); + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SchemaTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SchemaTests.cs new file mode 100644 index 000000000000..19760ed60d63 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SchemaTests.cs @@ -0,0 +1,223 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests; + +[UsesVerify] +public class SchemaTests +{ + [Fact] + public async Task SupportsXmlCommentsOnSchemas() + { + var source = """ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.MapPost("/todo", (Todo todo) => { }); +app.MapPost("/project", (Project project) => { }); +app.MapPost("/board", (ProjectBoard.BoardItem boardItem) => { }); +app.MapPost("/project-record", (ProjectRecord project) => { }); +app.MapPost("/todo-with-description", (TodoWithDescription todo) => { }); +app.MapPost("/type-with-examples", (TypeWithExamples typeWithExamples) => { }); +app.MapPost("/user", (User user) => { }); + +app.Run(); + +/// +/// This is a todo item. +/// +public class Todo +{ + public int Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } +} + +/// +/// The project that contains items. +/// +public record Project(string Name, string Description); + +public class ProjectBoard +{ + /// + /// An item on the board. + /// + public class BoardItem + { + public string Name { get; set; } + } +} + +/// +/// The project that contains items. +/// +/// The name of the project. +/// The description of the project. +public record ProjectRecord(string Name, string Description); + +public class TodoWithDescription +{ + /// + /// The identifier of the todo. + /// + public int Id { get; set; } + /// + /// The name of the todo. + /// + public string Name { get; set; } + /// + /// A description of the the todo. + /// + /// Another description of the todo. + public string Description { get; set; } +} + +public class TypeWithExamples +{ + /// true + public bool BooleanType { get; set; } + /// 42 + public int IntegerType { get; set; } + /// 1234567890123456789 + public long LongType { get; set; } + /// 3.14 + public double DoubleType { get; set; } + /// 3.14 + public float FloatType { get; set; } + /// 2022-01-01T00:00:00Z + public DateTime DateTimeType { get; set; } + /// 2022-01-01 + public DateOnly DateOnlyType { get; set; } + /// Hello, World! + public string StringType { get; set; } + /// 2d8f1eac-b5c6-4e29-8c62-4d9d75ef3d3d + public Guid GuidType { get; set; } + /// 12:30:45 + public TimeOnly TimeOnlyType { get; set; } + /// P3DT4H5M + public TimeSpan TimeSpanType { get; set; } + /// 255 + public byte ByteType { get; set; } + /// 3.14159265359 + public decimal DecimalType { get; set; } + /// https://example.com + public Uri UriType { get; set; } +} + +public interface IUser +{ + /// + /// The unique identifier for the user. + /// + int Id { get; set; } + + /// + /// The user's display name. + /// + string Name { get; set; } +} + +/// +public class User : IUser +{ + /// + public int Id { get; set; } + + /// + public string Name { get; set; } +} +"""; + var generator = new XmlCommentGenerator(); + await SnapshotTestHelper.Verify(source, generator, out var compilation); + await SnapshotTestHelper.VerifyOpenApi(compilation, document => + { + var path = document.Paths["/todo"].Operations[OperationType.Post]; + var todo = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("This is a todo item.", todo.Description); + + path = document.Paths["/project"].Operations[OperationType.Post]; + var project = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("The project that contains Todo items.", project.Description); + + path = document.Paths["/board"].Operations[OperationType.Post]; + var board = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("An item on the board.", board.Description); + + path = document.Paths["/project-record"].Operations[OperationType.Post]; + project = path.RequestBody.Content["application/json"].Schema; + + Assert.Equal("The name of the project.", project.Properties["name"].Description); + Assert.Equal("The description of the project.", project.Properties["description"].Description); + + path = document.Paths["/todo-with-description"].Operations[OperationType.Post]; + todo = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("The identifier of the todo.", todo.Properties["id"].Description); + Assert.Equal("The name of the todo.", todo.Properties["name"].Description); + Assert.Equal("Another description of the todo.", todo.Properties["description"].Description); + + path = document.Paths["/type-with-examples"].Operations[OperationType.Post]; + var typeWithExamples = path.RequestBody.Content["application/json"].Schema; + + var booleanTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["booleanType"].Example); + Assert.True(booleanTypeExample.GetValue()); + + var integerTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["integerType"].Example); + Assert.Equal(42, integerTypeExample.GetValue()); + + var longTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["longType"].Example); + Assert.Equal(1234567890123456789, longTypeExample.GetValue()); + + // Broken due to https://github.com/microsoft/OpenAPI.NET/issues/2137 + // var doubleTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["doubleType"].Example); + // Assert.Equal("3.14", doubleTypeExample.GetValue()); + + // var floatTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["floatType"].Example); + // Assert.Equal(3.14f, floatTypeExample.GetValue()); + + // var dateTimeTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["dateTimeType"].Example); + // Assert.Equal(DateTime.Parse("2022-01-01T00:00:00Z", CultureInfo.InvariantCulture), dateTimeTypeExample.GetValue()); + + // var dateOnlyTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["dateOnlyType"].Example); + // Assert.Equal(DateOnly.Parse("2022-01-01", CultureInfo.InvariantCulture), dateOnlyTypeExample.GetValue()); + + var stringTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["stringType"].Example); + Assert.Equal("Hello, World!", stringTypeExample.GetValue()); + + var guidTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["guidType"].Example); + Assert.Equal("2d8f1eac-b5c6-4e29-8c62-4d9d75ef3d3d", guidTypeExample.GetValue()); + + var byteTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["byteType"].Example); + Assert.Equal(255, byteTypeExample.GetValue()); + + // Broken due to https://github.com/microsoft/OpenAPI.NET/issues/2137 + // var timeOnlyTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["timeOnlyType"].Example); + // Assert.Equal(TimeOnly.Parse("12:30:45", CultureInfo.InvariantCulture), timeOnlyTypeExample.GetValue()); + + // var timeSpanTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["timeSpanType"].Example); + // Assert.Equal(TimeSpan.Parse("P3DT4H5M", CultureInfo.InvariantCulture), timeSpanTypeExample.GetValue()); + + // var decimalTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["decimalType"].Example); + // Assert.Equal(3.14159265359m, decimalTypeExample.GetValue()); + + var uriTypeExample = Assert.IsAssignableFrom(typeWithExamples.Properties["uriType"].Example); + Assert.Equal("https://example.com", uriTypeExample.GetValue()); + + path = document.Paths["/user"].Operations[OperationType.Post]; + var user = path.RequestBody.Content["application/json"].Schema; + Assert.Equal("The unique identifier for the user.", user.Properties["id"].Description); + Assert.Equal("The user's display name.", user.Properties["name"].Description); + }); + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs new file mode 100644 index 000000000000..c6fd1a38becc --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/SnapshotTestHelper.cs @@ -0,0 +1,513 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Loader; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests; + +[UsesVerify] +public static partial class SnapshotTestHelper +{ + [GeneratedRegex(@"\[global::System\.Runtime\.CompilerServices\.InterceptsLocationAttribute\([^)]*\)\]")] + private static partial Regex InterceptsLocationRegex(); + + private static readonly CSharpParseOptions ParseOptions = new CSharpParseOptions(LanguageVersion.Preview) + .WithFeatures([new KeyValuePair("InterceptorsNamespaces", "Microsoft.AspNetCore.OpenApi.Generated")]); + + public static Task Verify(string source, IIncrementalGenerator generator, out Compilation compilation) + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Concat( + [ + MetadataReference.CreateFromFile(typeof(Builder.WebApplicationBuilder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(OpenApiOptions).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Builder.EndpointRouteBuilderExtensions).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Builder.IApplicationBuilder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Mvc.ApiExplorer.IApiDescriptionProvider).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Mvc.ControllerBase).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MvcServiceCollectionExtensions).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MvcCoreMvcBuilderExtensions).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Http.TypedResults).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Text.Json.Nodes.JsonArray).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Uri).Assembly.Location), + ]); + var inputCompilation = CSharpCompilation.Create("OpenApiXmlCommentGeneratorSample", + [CSharpSyntaxTree.ParseText(source, options: ParseOptions, path: "Program.cs")], + references, + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + var driver = CSharpGeneratorDriver.Create(generators: [generator.AsSourceGenerator()], parseOptions: ParseOptions); + return Verifier + .Verify(driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out compilation, out var diagnostics)) + .ScrubLinesWithReplace(line => InterceptsLocationRegex().Replace(line, "[InterceptsLocation]")) + .UseDirectory(SkipOnHelixAttribute.OnHelix() + ? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "snapshots") + : "snapshots"); + } + + public static async Task VerifyOpenApi(Compilation compilation, Action verifyFunc) + { + var assemblyName = compilation.AssemblyName; + var symbolsName = Path.ChangeExtension(assemblyName, "pdb"); + + var output = new MemoryStream(); + var pdb = new MemoryStream(); + + var emitOptions = new EmitOptions( + debugInformationFormat: DebugInformationFormat.PortablePdb, + pdbFilePath: symbolsName, + outputNameOverride: $"TestProject-{Guid.NewGuid()}"); + + var embeddedTexts = new List(); + + foreach (var syntaxTree in compilation.SyntaxTrees) + { + var text = syntaxTree.GetText(); + var encoding = text.Encoding ?? Encoding.UTF8; + var buffer = encoding.GetBytes(text.ToString()); + var sourceText = SourceText.From(buffer, buffer.Length, encoding, canBeEmbedded: true); + + var syntaxRootNode = (CSharpSyntaxNode)syntaxTree.GetRoot(); + var newSyntaxTree = CSharpSyntaxTree.Create(syntaxRootNode, options: ParseOptions, encoding: encoding, path: syntaxTree.FilePath); + + compilation = compilation.ReplaceSyntaxTree(syntaxTree, newSyntaxTree); + + embeddedTexts.Add(EmbeddedText.FromSource(syntaxTree.FilePath, sourceText)); + } + + var result = compilation.Emit(output, pdb, options: emitOptions, embeddedTexts: embeddedTexts); + + Assert.Empty(result.Diagnostics.Where(d => d.Severity > DiagnosticSeverity.Warning)); + Assert.True(result.Success); + + output.Position = 0; + pdb.Position = 0; + + var assembly = AssemblyLoadContext.Default.LoadFromStream(output, pdb); + + void ConfigureHostBuilder(object hostBuilder) + { + ((IHostBuilder)hostBuilder).ConfigureServices((context, services) => + { + services.AddSingleton(); + services.AddSingleton(); + }); + } + + var waitForStartTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnEntryPointExit(Exception exception) + { + // If the entry point exited, we'll try to complete the wait + if (exception != null) + { + waitForStartTcs.TrySetException(exception); + } + else + { + waitForStartTcs.TrySetResult(0); + } + } + + var factory = HostFactoryResolver.ResolveHostFactory(assembly, + stopApplication: false, + configureHostBuilder: ConfigureHostBuilder, + entrypointCompleted: OnEntryPointExit); + + if (factory == null) + { + return; + } + + var services = ((IHost)factory([$"--{HostDefaults.ApplicationKey}={assemblyName}"])).Services; + + var applicationLifetime = services.GetRequiredService(); + using (var registration = applicationLifetime.ApplicationStarted.Register(() => waitForStartTcs.TrySetResult(0))) + { + waitForStartTcs.Task.Wait(); + var targetAssembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(assembly => assembly.GetName().Name == "Microsoft.AspNetCore.OpenApi"); + var serviceType = targetAssembly.GetType("Microsoft.Extensions.ApiDescriptions.IDocumentProvider", throwOnError: false); + + if (serviceType == null) + { + return; + } + + var service = services.GetService(serviceType) ?? throw new InvalidOperationException("Could not resolve IDocumntProvider service."); + using var stream = new MemoryStream(); + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + using var writer = new StreamWriter(stream, encoding, bufferSize: 1024, leaveOpen: true); + var targetMethod = serviceType.GetMethod("GenerateAsync", [typeof(string), typeof(TextWriter)]) ?? throw new InvalidOperationException("Could not resolve GenerateAsync method."); + targetMethod.Invoke(service, ["v1", writer]); + stream.Position = 0; + var (document, _) = await OpenApiDocument.LoadAsync(stream, "json"); + verifyFunc(document); + } + } + + private sealed class NoopHostLifetime : IHostLifetime + { + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class NoopServer : IServer + { + public IFeatureCollection Features { get; } = new FeatureCollection(); + public void Dispose() { } + public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class HostFactoryResolver + { + private const BindingFlags DeclaredOnlyLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + public const string BuildWebHost = nameof(BuildWebHost); + public const string CreateWebHostBuilder = nameof(CreateWebHostBuilder); + public const string CreateHostBuilder = nameof(CreateHostBuilder); + private const string TimeoutEnvironmentKey = "DOTNET_HOST_FACTORY_RESOLVER_DEFAULT_TIMEOUT_IN_SECONDS"; + + // The amount of time we wait for the diagnostic source events to fire + private static readonly TimeSpan s_defaultWaitTimeout = SetupDefaultTimeout(); + + private static TimeSpan SetupDefaultTimeout() + { + if (Debugger.IsAttached) + { + return Timeout.InfiniteTimeSpan; + } + + if (uint.TryParse(Environment.GetEnvironmentVariable(TimeoutEnvironmentKey), out uint timeoutInSeconds)) + { + return TimeSpan.FromSeconds((int)timeoutInSeconds); + } + + return TimeSpan.FromMinutes(5); + } + + public static Func ResolveWebHostFactory(Assembly assembly) + { + return ResolveFactory(assembly, BuildWebHost); + } + + public static Func ResolveWebHostBuilderFactory(Assembly assembly) + { + return ResolveFactory(assembly, CreateWebHostBuilder); + } + + public static Func ResolveHostBuilderFactory(Assembly assembly) + { + return ResolveFactory(assembly, CreateHostBuilder); + } + + // This helpers encapsulates all of the complex logic required to: + // 1. Execute the entry point of the specified assembly in a different thread. + // 2. Wait for the diagnostic source events to fire + // 3. Give the caller a chance to execute logic to mutate the IHostBuilder + // 4. Resolve the instance of the applications's IHost + // 5. Allow the caller to determine if the entry point has completed + public static Func ResolveHostFactory(Assembly assembly, + TimeSpan waitTimeout = default, + bool stopApplication = true, + Action configureHostBuilder = null, + Action entrypointCompleted = null) + { + if (assembly.EntryPoint is null) + { + return null; + } + + return args => new HostingListener(args, assembly.EntryPoint, waitTimeout == default ? s_defaultWaitTimeout : waitTimeout, stopApplication, configureHostBuilder, entrypointCompleted).CreateHost(); + } + + private static Func ResolveFactory(Assembly assembly, string name) + { + var programType = assembly.EntryPoint.DeclaringType; + if (programType == null) + { + return null; + } + + var factory = programType.GetMethod(name, DeclaredOnlyLookup); + if (!IsFactory(factory)) + { + return null; + } + + return args => (T)factory!.Invoke(null, [args])!; + } + + // TReturn Factory(string[] args); + private static bool IsFactory(MethodInfo factory) + { + return factory != null + && typeof(TReturn).IsAssignableFrom(factory.ReturnType) + && factory.GetParameters().Length == 1 + && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType); + } + + // Used by EF tooling without any Hosting references. Looses some return type safety checks. + public static Func ResolveServiceProviderFactory(Assembly assembly, TimeSpan waitTimeout = default) + { + // Prefer the older patterns by default for back compat. + var webHostFactory = ResolveWebHostFactory(assembly); + if (webHostFactory != null) + { + return args => + { + var webHost = webHostFactory(args); + return GetServiceProvider(webHost); + }; + } + + var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); + if (webHostBuilderFactory != null) + { + return args => + { + var webHostBuilder = webHostBuilderFactory(args); + var webHost = Build(webHostBuilder); + return GetServiceProvider(webHost); + }; + } + + var hostBuilderFactory = ResolveHostBuilderFactory(assembly); + if (hostBuilderFactory != null) + { + return args => + { + var hostBuilder = hostBuilderFactory(args); + var host = Build(hostBuilder); + return GetServiceProvider(host); + }; + } + + var hostFactory = ResolveHostFactory(assembly, waitTimeout: waitTimeout); + if (hostFactory != null) + { + return args => + { + static bool IsApplicationNameArg(string arg) + => arg.Equals("--applicationName", StringComparison.OrdinalIgnoreCase) || + arg.Equals("/applicationName", StringComparison.OrdinalIgnoreCase); + + if (!args.Any(arg => IsApplicationNameArg(arg)) && assembly.GetName().Name is string assemblyName) + { + args = [.. args, .. new[] { "--applicationName", assemblyName }]; + } + + var host = hostFactory(args); + return GetServiceProvider(host); + }; + } + + return null; + } + + private static object Build(object builder) + { + var buildMethod = builder.GetType().GetMethod("Build"); + return buildMethod.Invoke(builder, []); + } + + private static IServiceProvider GetServiceProvider(object host) + { + if (host == null) + { + return null; + } + var hostType = host.GetType(); + var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup); + return (IServiceProvider)servicesProperty.GetValue(host); + } + + private sealed class HostingListener : IObserver, IObserver> + { + private readonly string[] _args; + private readonly MethodInfo _entryPoint; + private readonly TimeSpan _waitTimeout; + private readonly bool _stopApplication; + + private readonly TaskCompletionSource _hostTcs = new(); + private IDisposable _disposable; + private readonly Action _configure; + private readonly Action _entrypointCompleted; + private static readonly AsyncLocal _currentListener = new(); + + public HostingListener(string[] args, MethodInfo entryPoint, TimeSpan waitTimeout, bool stopApplication, Action configure, Action entrypointCompleted) + { + _args = args; + _entryPoint = entryPoint; + _waitTimeout = waitTimeout; + _stopApplication = stopApplication; + _configure = configure; + _entrypointCompleted = entrypointCompleted; + } + + public object CreateHost() + { + using var subscription = DiagnosticListener.AllListeners.Subscribe(this); + + // Kick off the entry point on a new thread so we don't block the current one + // in case we need to timeout the execution + var thread = new Thread(() => + { + Exception exception = null; + + try + { + // Set the async local to the instance of the HostingListener so we can filter events that + // aren't scoped to this execution of the entry point. + _currentListener.Value = this; + + var parameters = _entryPoint.GetParameters(); + if (parameters.Length == 0) + { + _entryPoint.Invoke(null, []); + } + else + { + _entryPoint.Invoke(null, new object[] { _args }); + } + + // Try to set an exception if the entry point returns gracefully, this will force + // build to throw + _hostTcs.TrySetException(new InvalidOperationException("The entry point exited without ever building an IHost.")); + } + catch (TargetInvocationException tie) when (tie.InnerException.GetType().Name == "HostAbortedException") + { + // The host was stopped by our own logic + } + catch (TargetInvocationException tie) + { + exception = tie.InnerException ?? tie; + + // Another exception happened, propagate that to the caller + _hostTcs.TrySetException(exception); + } + catch (Exception ex) + { + exception = ex; + + // Another exception happened, propagate that to the caller + _hostTcs.TrySetException(ex); + } + finally + { + // Signal that the entry point is completed + _entrypointCompleted.Invoke(exception); + } + }) + { + // Make sure this doesn't hang the process + IsBackground = true + }; + + // Start the thread + thread.Start(); + + try + { + // Wait before throwing an exception + if (!_hostTcs.Task.Wait(_waitTimeout)) + { + throw new InvalidOperationException($"Timed out waiting for the entry point to build the IHost after {s_defaultWaitTimeout}. This timeout can be modified using the '{TimeoutEnvironmentKey}' environment variable."); + } + } + catch (AggregateException) when (_hostTcs.Task.IsCompleted) + { + // Lets this propagate out of the call to GetAwaiter().GetResult() + } + + Debug.Assert(_hostTcs.Task.IsCompleted); + + return _hostTcs.Task.GetAwaiter().GetResult(); + } + + public void OnCompleted() + { + _disposable.Dispose(); + } + + public void OnError(Exception error) + { + + } + + public void OnNext(DiagnosticListener value) + { + if (_currentListener.Value != this) + { + // Ignore events that aren't for this listener + return; + } + + if (value.Name == "Microsoft.Extensions.Hosting") + { + _disposable = value.Subscribe(this); + } + } + + public void OnNext(KeyValuePair value) + { + if (_currentListener.Value != this) + { + // Ignore events that aren't for this listener + return; + } + + if (value.Key == "HostBuilding") + { + _configure.Invoke(value.Value!); + } + + if (value.Key == "HostBuilt") + { + _hostTcs.TrySetResult(value.Value!); + + if (_stopApplication) + { + // Stop the host from running further + ThrowHostAborted(); + } + } + } + + // HostFactoryResolver is used by tools that explicitly don't want to reference Microsoft.Extensions.Hosting assemblies. + // So don't depend on the public HostAbortedException directly. Instead, load the exception type dynamically if it can + // be found. If it can't (possibly because the app is using an older version), throw a private exception with the same name. + private static void ThrowHostAborted() + { + var publicHostAbortedExceptionType = Type.GetType("Microsoft.Extensions.Hosting.HostAbortedException, Microsoft.Extensions.Hosting.Abstractions", throwOnError: false); + if (publicHostAbortedExceptionType != null) + { + throw (Exception)Activator.CreateInstance(publicHostAbortedExceptionType)!; + } + else + { + throw new HostAbortedException(); + } + } + + private sealed class HostAbortedException : Exception + { + } + } + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..3e1be752293a --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/AddOpenApiTests.CanInterceptAddOpenApi#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,383 @@ +//HintName: OpenApiXmlCommentSupport.generated.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable + +namespace System.Runtime.CompilerServices +{ + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.AspNetCore.OpenApi.Generated +{ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Linq; + using System.Reflection; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.AspNetCore.OpenApi; + using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.OpenApi.Models; + using Microsoft.OpenApi.Any; + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlComment( + string? Summary, + string? Description, + string? Remarks, + string? Returns, + string? Value, + bool Deprecated, + List? Examples, + List? Parameters, + List? Responses); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlResponseComment(string Code, string? Description, string? Example); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed record MemberKey( + Type? DeclaringType, + MemberType MemberKind, + string? Name, + Type? ReturnType, + Type[]? Parameters) : IEquatable + { + public bool Equals(MemberKey? other) + { + if (other is null) return false; + + // Check member kind + if (MemberKind != other.MemberKind) return false; + + // Check declaring type, handling generic types + if (!TypesEqual(DeclaringType, other.DeclaringType)) return false; + + // Check name + if (Name != other.Name) return false; + + // For methods, check return type and parameters + if (MemberKind == MemberType.Method) + { + if (!TypesEqual(ReturnType, other.ReturnType)) return false; + if (Parameters is null || other.Parameters is null) return false; + if (Parameters.Length != other.Parameters.Length) return false; + + for (int i = 0; i < Parameters.Length; i++) + { + if (!TypesEqual(Parameters[i], other.Parameters[i])) return false; + } + } + + return true; + } + + private static bool TypesEqual(Type? type1, Type? type2) + { + if (type1 == type2) return true; + if (type1 == null || type2 == null) return false; + + if (type1.IsGenericType && type2.IsGenericType) + { + return type1.GetGenericTypeDefinition() == type2.GetGenericTypeDefinition(); + } + + return type1 == type2; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(GetTypeHashCode(DeclaringType)); + hash.Add(MemberKind); + hash.Add(Name); + + if (MemberKind == MemberType.Method) + { + hash.Add(GetTypeHashCode(ReturnType)); + if (Parameters is not null) + { + foreach (var param in Parameters) + { + hash.Add(GetTypeHashCode(param)); + } + } + } + + return hash.ToHashCode(); + } + + private static int GetTypeHashCode(Type? type) + { + if (type == null) return 0; + return type.IsGenericType ? type.GetGenericTypeDefinition().GetHashCode() : type.GetHashCode(); + } + + public static MemberKey FromMethodInfo(MethodInfo method) + { + return new MemberKey( + method.DeclaringType, + MemberType.Method, + method.Name, + method.ReturnType.IsGenericParameter ? typeof(object) : method.ReturnType, + method.GetParameters().Select(p => p.ParameterType.IsGenericParameter ? typeof(object) : p.ParameterType).ToArray()); + } + + public static MemberKey FromPropertyInfo(PropertyInfo property) + { + return new MemberKey( + property.DeclaringType, + MemberType.Property, + property.Name, + null, + null); + } + + public static MemberKey FromTypeInfo(Type type) + { + return new MemberKey( + type, + MemberType.Type, + null, + null, + null); + } + } + + file enum MemberType + { + Type, + Property, + Method + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class XmlCommentCache + { + private static Dictionary? _cache; + public static Dictionary Cache => _cache ??= GenerateCacheEntries(); + + private static Dictionary GenerateCacheEntries() + { + var _cache = new Dictionary(); + + + return _cache; + } + + internal static bool TryGetXmlComment(Type? type, MethodInfo? methodInfo, [NotNullWhen(true)] out XmlComment? xmlComment) + { + if (methodInfo is null) + { + return Cache.TryGetValue(new MemberKey(type, MemberType.Property, null, null, null), out xmlComment); + } + + return Cache.TryGetValue(MemberKey.FromMethodInfo(methodInfo), out xmlComment); + } + + internal static bool TryGetXmlComment(Type? type, string? memberName, [NotNullWhen(true)] out XmlComment? xmlComment) + { + return Cache.TryGetValue(new MemberKey(type, memberName is null ? MemberType.Type : MemberType.Property, memberName, null, null), out xmlComment); + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor + ? controllerActionDescriptor.MethodInfo + : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + + if (methodInfo is null) + { + return Task.CompletedTask; + } + if (XmlCommentCache.TryGetXmlComment(methodInfo.DeclaringType, methodInfo, out var methodComment)) + { + if (methodComment.Summary is { } summary) + { + operation.Summary = summary; + } + if (methodComment.Description is { } description) + { + operation.Description = description; + } + if (methodComment.Remarks is { } remarks) + { + operation.Description = remarks; + } + if (methodComment.Parameters is { Count: > 0}) + { + foreach (var parameterComment in methodComment.Parameters) + { + var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name); + var operationParameter = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name); + if (operationParameter is not null) + { + operationParameter.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + operationParameter.Example = jsonString.Parse(); + } + operationParameter.Deprecated = parameterComment.Deprecated; + } + else + { + var requestBody = operation.RequestBody; + if (requestBody is not null) + { + requestBody.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + foreach (var mediaType in requestBody.Content.Values) + { + mediaType.Example = jsonString.Parse(); + } + } + } + } + } + } + if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 }) + { + foreach (var response in operation.Responses) + { + var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key); + if (responseComment is not null) + { + response.Value.Description = responseComment.Description; + } + } + } + } + + return Task.CompletedTask; + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo }) + { + if (XmlCommentCache.TryGetXmlComment(propertyInfo.DeclaringType, propertyInfo.Name, out var propertyComment)) + { + schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + } + if (XmlCommentCache.TryGetXmlComment(context.JsonTypeInfo.Type, (string?)null, out var typeComment)) + { + schema.Description = typeComment.Summary; + if (typeComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + return Task.CompletedTask; + } + } + + file static class JsonNodeExtensions + { + public static JsonNode? Parse(this string? json) + { + if (json is null) + { + return null; + } + + try + { + return JsonNode.Parse(json); + } + catch (JsonException) + { + try + { + // If parsing fails, try wrapping in quotes to make it a valid JSON string + return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\""); + } + catch (JsonException) + { + return null; + } + } + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static IServiceCollection AddOpenApi(this IServiceCollection services) + { + return services.AddOpenApi("v1", options => + { + options.AddSchemaTransformer(new XmlCommentSchemaTransformer()); + options.AddOperationTransformer(new XmlCommentOperationTransformer()); + }); + } + [InterceptsLocation] + public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName) + { + return services.AddOpenApi(documentName, options => + { + options.AddSchemaTransformer(new XmlCommentSchemaTransformer()); + options.AddOperationTransformer(new XmlCommentOperationTransformer()); + }); + } + + [InterceptsLocation] + public static IServiceCollection AddOpenApi(this IServiceCollection services, Action configureOptions) + { + return services.AddOpenApi("v1", options => + { + options.AddSchemaTransformer(new XmlCommentSchemaTransformer()); + options.AddOperationTransformer(new XmlCommentOperationTransformer()); + configureOptions(options); + }); + } + [InterceptsLocation] + [InterceptsLocation] + public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName, Action configureOptions) + { + // This overload is not intercepted. + return OpenApiServiceCollectionExtensions.AddOpenApi(services, documentName, options => + { + options.AddSchemaTransformer(new XmlCommentSchemaTransformer()); + options.AddOperationTransformer(new XmlCommentOperationTransformer()); + configureOptions(options); + }); + } + + } +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..14d8bbcacb55 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/CompletenessTests.SupportsAllXmlTagsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,448 @@ +//HintName: OpenApiXmlCommentSupport.generated.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable + +namespace System.Runtime.CompilerServices +{ + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.AspNetCore.OpenApi.Generated +{ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Linq; + using System.Reflection; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.AspNetCore.OpenApi; + using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.OpenApi.Models; + using Microsoft.OpenApi.Any; + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlComment( + string? Summary, + string? Description, + string? Remarks, + string? Returns, + string? Value, + bool Deprecated, + List? Examples, + List? Parameters, + List? Responses); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlResponseComment(string Code, string? Description, string? Example); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed record MemberKey( + Type? DeclaringType, + MemberType MemberKind, + string? Name, + Type? ReturnType, + Type[]? Parameters) : IEquatable + { + public bool Equals(MemberKey? other) + { + if (other is null) return false; + + // Check member kind + if (MemberKind != other.MemberKind) return false; + + // Check declaring type, handling generic types + if (!TypesEqual(DeclaringType, other.DeclaringType)) return false; + + // Check name + if (Name != other.Name) return false; + + // For methods, check return type and parameters + if (MemberKind == MemberType.Method) + { + if (!TypesEqual(ReturnType, other.ReturnType)) return false; + if (Parameters is null || other.Parameters is null) return false; + if (Parameters.Length != other.Parameters.Length) return false; + + for (int i = 0; i < Parameters.Length; i++) + { + if (!TypesEqual(Parameters[i], other.Parameters[i])) return false; + } + } + + return true; + } + + private static bool TypesEqual(Type? type1, Type? type2) + { + if (type1 == type2) return true; + if (type1 == null || type2 == null) return false; + + if (type1.IsGenericType && type2.IsGenericType) + { + return type1.GetGenericTypeDefinition() == type2.GetGenericTypeDefinition(); + } + + return type1 == type2; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(GetTypeHashCode(DeclaringType)); + hash.Add(MemberKind); + hash.Add(Name); + + if (MemberKind == MemberType.Method) + { + hash.Add(GetTypeHashCode(ReturnType)); + if (Parameters is not null) + { + foreach (var param in Parameters) + { + hash.Add(GetTypeHashCode(param)); + } + } + } + + return hash.ToHashCode(); + } + + private static int GetTypeHashCode(Type? type) + { + if (type == null) return 0; + return type.IsGenericType ? type.GetGenericTypeDefinition().GetHashCode() : type.GetHashCode(); + } + + public static MemberKey FromMethodInfo(MethodInfo method) + { + return new MemberKey( + method.DeclaringType, + MemberType.Method, + method.Name, + method.ReturnType.IsGenericParameter ? typeof(object) : method.ReturnType, + method.GetParameters().Select(p => p.ParameterType.IsGenericParameter ? typeof(object) : p.ParameterType).ToArray()); + } + + public static MemberKey FromPropertyInfo(PropertyInfo property) + { + return new MemberKey( + property.DeclaringType, + MemberType.Property, + property.Name, + null, + null); + } + + public static MemberKey FromTypeInfo(Type type) + { + return new MemberKey( + type, + MemberType.Type, + null, + null, + null); + } + } + + file enum MemberType + { + Type, + Property, + Method + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class XmlCommentCache + { + private static Dictionary? _cache; + public static Dictionary Cache => _cache ??= GenerateCacheEntries(); + + private static Dictionary GenerateCacheEntries() + { + var _cache = new Dictionary(); + + _cache.Add(new MemberKey(typeof(global::ExampleClass), MemberType.Type, null, null, []), new XmlComment(@"Every class and member should have a one sentence +summary describing its purpose.", null, @" You can expand on that one sentence summary to + provide more information for readers. In this case, + the `ExampleClass` provides different C# + elements to show how you would add documentation + comments for most elements in a typical class. + The remarks can add multiple paragraphs, so you can +write detailed information for developers that use +your work. You should add everything needed for +readers to be successful. This class contains +examples for the following: + * Summary + +This should provide a one sentence summary of the class or member. +* Remarks + +This is typically a more detailed description of the class or member +* para + +The para tag separates a section into multiple paragraphs +* list + +Provides a list of terms or elements +* returns, param + +Used to describe parameters and return values +* value +Used to describe properties +* exception + +Used to describe exceptions that may be thrown +* c, cref, see, seealso + +These provide code style and links to other +documentation elements +* example, code + +These are used for code examples + The list above uses the ""table"" style. You could +also use the ""bullet"" or ""number"" style. Neither +would typically use the ""term"" element. + +Note: paragraphs are double spaced. Use the *br* +tag for single spaced lines.", null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::Person), MemberType.Type, null, null, []), new XmlComment(@"This is an example of a positional record.", null, @"There isn't a way to add XML comments for properties +created for positional records, yet. The language +design team is still considering what tags should +be supported, and where. Currently, you can use +the ""param"" tag to describe the parameters to the +primary constructor.", null, null, false, null, [new XmlParameterComment(@"FirstName", @"This tag will apply to the primary constructor parameter.", null, false), new XmlParameterComment(@"LastName", @"This tag will apply to the primary constructor parameter.", null, false)], null)); + _cache.Add(new MemberKey(typeof(global::MainClass), MemberType.Type, null, null, []), new XmlComment(@"A summary about this class.", null, @"These remarks would explain more about this class. +In this example, these comments also explain the +general information about the derived class.", null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::DerivedClass), MemberType.Type, null, null, []), new XmlComment(@"A summary about this class.", null, @"These remarks would explain more about this class. +In this example, these comments also explain the +general information about the derived class.", null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ITestInterface), MemberType.Type, null, null, []), new XmlComment(@"This interface would describe all the methods in +its contract.", null, @"While elided for brevity, each method or property +in this interface would contain docs that you want +to duplicate in each implementing class.", null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ImplementingClass), MemberType.Type, null, null, []), new XmlComment(@"This interface would describe all the methods in +its contract.", null, @"While elided for brevity, each method or property +in this interface would contain docs that you want +to duplicate in each implementing class.", null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::InheritOnlyReturns), MemberType.Type, null, null, []), new XmlComment(@"This class shows hows you can ""inherit"" the doc +comments from one method in another method.", null, @"You can inherit all comments, or only a specific tag, +represented by an xpath expression.", null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::InheritAllButRemarks), MemberType.Type, null, null, []), new XmlComment(@"This class shows an example of sharing comments across methods.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::GenericClass<>), MemberType.Type, null, null, []), new XmlComment(@"This is a generic class.", null, @"This example shows how to specify the GenericClass<T> +type as a cref attribute. +In generic classes and methods, you'll often want to reference the +generic type, or the type parameter.", null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ParamsAndParamRefs), MemberType.Type, null, null, []), new XmlComment(@"This shows examples of typeparamref and typeparam tags", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ExampleClass), MemberType.Property, "Label", null, []), new XmlComment(null, null, @" The ExampleClass.Label is a + that you use for a label. + Note that there isn't a way to provide a ""cref"" to +each accessor, only to the property itself.", null, @"The `Label` property represents a label +for this instance.", false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::Person), MemberType.Property, "FirstName", null, []), new XmlComment(@"This tag will apply to the primary constructor parameter.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::Person), MemberType.Property, "LastName", null, []), new XmlComment(@"This tag will apply to the primary constructor parameter.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ExampleClass), MemberType.Method, "Add", typeof(global::System.Int32), [typeof(global::System.Int32), typeof(global::System.Int32)]), new XmlComment(@"Adds two integers and returns the result.", null, null, @"The sum of two integers.", null, false, [@" ```int c = Math.Add(4, 5); +if (c > 10) +{ + Console.WriteLine(c); +}```"], [new XmlParameterComment(@"left", @"The left operand of the addition.", null, false), new XmlParameterComment(@"right", @"The right operand of the addition.", null, false)], null)); + _cache.Add(new MemberKey(typeof(global::ITestInterface), MemberType.Method, "Method", typeof(global::System.Int32), [typeof(global::System.Int32)]), new XmlComment(@"This method is part of the test interface.", null, @"This content would be inherited by classes +that implement this interface when the +implementing class uses ""inheritdoc""", @"The value of arg", null, false, null, [new XmlParameterComment(@"arg", @"The argument to the method", null, false)], null)); + _cache.Add(new MemberKey(typeof(global::InheritOnlyReturns), MemberType.Method, "MyParentMethod", typeof(global::System.Boolean), [typeof(global::System.Boolean)]), new XmlComment(@"In this example, this summary is only visible for this method.", null, null, @"A boolean", null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::InheritOnlyReturns), MemberType.Method, "MyChildMethod", typeof(global::System.Boolean), []), new XmlComment(null, null, null, @"A boolean", null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::InheritAllButRemarks), MemberType.Method, "MyParentMethod", typeof(global::System.Boolean), [typeof(global::System.Boolean)]), new XmlComment(@"In this example, this summary is visible on all the methods.", null, @"The remarks can be inherited by other methods +using the xpath expression.", @"A boolean", null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::InheritAllButRemarks), MemberType.Method, "MyChildMethod", typeof(global::System.Boolean), []), new XmlComment(@"In this example, this summary is visible on all the methods.", null, null, @"A boolean", null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ParamsAndParamRefs), MemberType.Method, "GetGenericValue", typeof(object), [typeof(object)]), new XmlComment(@"The GetGenericValue method.", null, @"This sample shows how to specify the ParamsAndParamRefs.GetGenericValue<T>(T) +method as a cref attribute. +The parameter and return value are both of an arbitrary type, +T", null, null, false, null, null, null)); + + return _cache; + } + + internal static bool TryGetXmlComment(Type? type, MethodInfo? methodInfo, [NotNullWhen(true)] out XmlComment? xmlComment) + { + if (methodInfo is null) + { + return Cache.TryGetValue(new MemberKey(type, MemberType.Property, null, null, null), out xmlComment); + } + + return Cache.TryGetValue(MemberKey.FromMethodInfo(methodInfo), out xmlComment); + } + + internal static bool TryGetXmlComment(Type? type, string? memberName, [NotNullWhen(true)] out XmlComment? xmlComment) + { + return Cache.TryGetValue(new MemberKey(type, memberName is null ? MemberType.Type : MemberType.Property, memberName, null, null), out xmlComment); + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor + ? controllerActionDescriptor.MethodInfo + : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + + if (methodInfo is null) + { + return Task.CompletedTask; + } + if (XmlCommentCache.TryGetXmlComment(methodInfo.DeclaringType, methodInfo, out var methodComment)) + { + if (methodComment.Summary is { } summary) + { + operation.Summary = summary; + } + if (methodComment.Description is { } description) + { + operation.Description = description; + } + if (methodComment.Remarks is { } remarks) + { + operation.Description = remarks; + } + if (methodComment.Parameters is { Count: > 0}) + { + foreach (var parameterComment in methodComment.Parameters) + { + var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name); + var operationParameter = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name); + if (operationParameter is not null) + { + operationParameter.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + operationParameter.Example = jsonString.Parse(); + } + operationParameter.Deprecated = parameterComment.Deprecated; + } + else + { + var requestBody = operation.RequestBody; + if (requestBody is not null) + { + requestBody.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + foreach (var mediaType in requestBody.Content.Values) + { + mediaType.Example = jsonString.Parse(); + } + } + } + } + } + } + if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 }) + { + foreach (var response in operation.Responses) + { + var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key); + if (responseComment is not null) + { + response.Value.Description = responseComment.Description; + } + } + } + } + + return Task.CompletedTask; + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo }) + { + if (XmlCommentCache.TryGetXmlComment(propertyInfo.DeclaringType, propertyInfo.Name, out var propertyComment)) + { + schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + } + if (XmlCommentCache.TryGetXmlComment(context.JsonTypeInfo.Type, (string?)null, out var typeComment)) + { + schema.Description = typeComment.Summary; + if (typeComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + return Task.CompletedTask; + } + } + + file static class JsonNodeExtensions + { + public static JsonNode? Parse(this string? json) + { + if (json is null) + { + return null; + } + + try + { + return JsonNode.Parse(json); + } + catch (JsonException) + { + try + { + // If parsing fails, try wrapping in quotes to make it a valid JSON string + return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\""); + } + catch (JsonException) + { + return null; + } + } + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static IServiceCollection AddOpenApi(this IServiceCollection services) + { + return services.AddOpenApi("v1", options => + { + options.AddSchemaTransformer(new XmlCommentSchemaTransformer()); + options.AddOperationTransformer(new XmlCommentOperationTransformer()); + }); + } + + } +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..54e370279a60 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromControllers#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,355 @@ +//HintName: OpenApiXmlCommentSupport.generated.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable + +namespace System.Runtime.CompilerServices +{ + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.AspNetCore.OpenApi.Generated +{ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Linq; + using System.Reflection; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.AspNetCore.OpenApi; + using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.OpenApi.Models; + using Microsoft.OpenApi.Any; + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlComment( + string? Summary, + string? Description, + string? Remarks, + string? Returns, + string? Value, + bool Deprecated, + List? Examples, + List? Parameters, + List? Responses); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlResponseComment(string Code, string? Description, string? Example); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed record MemberKey( + Type? DeclaringType, + MemberType MemberKind, + string? Name, + Type? ReturnType, + Type[]? Parameters) : IEquatable + { + public bool Equals(MemberKey? other) + { + if (other is null) return false; + + // Check member kind + if (MemberKind != other.MemberKind) return false; + + // Check declaring type, handling generic types + if (!TypesEqual(DeclaringType, other.DeclaringType)) return false; + + // Check name + if (Name != other.Name) return false; + + // For methods, check return type and parameters + if (MemberKind == MemberType.Method) + { + if (!TypesEqual(ReturnType, other.ReturnType)) return false; + if (Parameters is null || other.Parameters is null) return false; + if (Parameters.Length != other.Parameters.Length) return false; + + for (int i = 0; i < Parameters.Length; i++) + { + if (!TypesEqual(Parameters[i], other.Parameters[i])) return false; + } + } + + return true; + } + + private static bool TypesEqual(Type? type1, Type? type2) + { + if (type1 == type2) return true; + if (type1 == null || type2 == null) return false; + + if (type1.IsGenericType && type2.IsGenericType) + { + return type1.GetGenericTypeDefinition() == type2.GetGenericTypeDefinition(); + } + + return type1 == type2; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(GetTypeHashCode(DeclaringType)); + hash.Add(MemberKind); + hash.Add(Name); + + if (MemberKind == MemberType.Method) + { + hash.Add(GetTypeHashCode(ReturnType)); + if (Parameters is not null) + { + foreach (var param in Parameters) + { + hash.Add(GetTypeHashCode(param)); + } + } + } + + return hash.ToHashCode(); + } + + private static int GetTypeHashCode(Type? type) + { + if (type == null) return 0; + return type.IsGenericType ? type.GetGenericTypeDefinition().GetHashCode() : type.GetHashCode(); + } + + public static MemberKey FromMethodInfo(MethodInfo method) + { + return new MemberKey( + method.DeclaringType, + MemberType.Method, + method.Name, + method.ReturnType.IsGenericParameter ? typeof(object) : method.ReturnType, + method.GetParameters().Select(p => p.ParameterType.IsGenericParameter ? typeof(object) : p.ParameterType).ToArray()); + } + + public static MemberKey FromPropertyInfo(PropertyInfo property) + { + return new MemberKey( + property.DeclaringType, + MemberType.Property, + property.Name, + null, + null); + } + + public static MemberKey FromTypeInfo(Type type) + { + return new MemberKey( + type, + MemberType.Type, + null, + null, + null); + } + } + + file enum MemberType + { + Type, + Property, + Method + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class XmlCommentCache + { + private static Dictionary? _cache; + public static Dictionary Cache => _cache ??= GenerateCacheEntries(); + + private static Dictionary GenerateCacheEntries() + { + var _cache = new Dictionary(); + + _cache.Add(new MemberKey(typeof(global::TestController), MemberType.Method, "Get", typeof(global::System.String), []), new XmlComment(@"A summary of the action.", @"A description of the action.", null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::Test2Controller), MemberType.Method, "Get", typeof(global::System.String), [typeof(global::System.String)]), new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", null, false)], [new XmlResponseComment(@"200", @"Returns the greeting.", @"")])); + _cache.Add(new MemberKey(typeof(global::Test2Controller), MemberType.Method, "Get", typeof(global::System.String), [typeof(global::System.Int32)]), new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"id", @"The id associated with the request.", null, false)], null)); + _cache.Add(new MemberKey(typeof(global::Test2Controller), MemberType.Method, "Post", typeof(global::System.String), [typeof(global::Todo)]), new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"todo", @"The todo to insert into the database.", null, false)], null)); + + return _cache; + } + + internal static bool TryGetXmlComment(Type? type, MethodInfo? methodInfo, [NotNullWhen(true)] out XmlComment? xmlComment) + { + if (methodInfo is null) + { + return Cache.TryGetValue(new MemberKey(type, MemberType.Property, null, null, null), out xmlComment); + } + + return Cache.TryGetValue(MemberKey.FromMethodInfo(methodInfo), out xmlComment); + } + + internal static bool TryGetXmlComment(Type? type, string? memberName, [NotNullWhen(true)] out XmlComment? xmlComment) + { + return Cache.TryGetValue(new MemberKey(type, memberName is null ? MemberType.Type : MemberType.Property, memberName, null, null), out xmlComment); + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor + ? controllerActionDescriptor.MethodInfo + : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + + if (methodInfo is null) + { + return Task.CompletedTask; + } + if (XmlCommentCache.TryGetXmlComment(methodInfo.DeclaringType, methodInfo, out var methodComment)) + { + if (methodComment.Summary is { } summary) + { + operation.Summary = summary; + } + if (methodComment.Description is { } description) + { + operation.Description = description; + } + if (methodComment.Remarks is { } remarks) + { + operation.Description = remarks; + } + if (methodComment.Parameters is { Count: > 0}) + { + foreach (var parameterComment in methodComment.Parameters) + { + var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name); + var operationParameter = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name); + if (operationParameter is not null) + { + operationParameter.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + operationParameter.Example = jsonString.Parse(); + } + operationParameter.Deprecated = parameterComment.Deprecated; + } + else + { + var requestBody = operation.RequestBody; + if (requestBody is not null) + { + requestBody.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + foreach (var mediaType in requestBody.Content.Values) + { + mediaType.Example = jsonString.Parse(); + } + } + } + } + } + } + if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 }) + { + foreach (var response in operation.Responses) + { + var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key); + if (responseComment is not null) + { + response.Value.Description = responseComment.Description; + } + } + } + } + + return Task.CompletedTask; + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo }) + { + if (XmlCommentCache.TryGetXmlComment(propertyInfo.DeclaringType, propertyInfo.Name, out var propertyComment)) + { + schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + } + if (XmlCommentCache.TryGetXmlComment(context.JsonTypeInfo.Type, (string?)null, out var typeComment)) + { + schema.Description = typeComment.Summary; + if (typeComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + return Task.CompletedTask; + } + } + + file static class JsonNodeExtensions + { + public static JsonNode? Parse(this string? json) + { + if (json is null) + { + return null; + } + + try + { + return JsonNode.Parse(json); + } + catch (JsonException) + { + try + { + // If parsing fails, try wrapping in quotes to make it a valid JSON string + return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\""); + } + catch (JsonException) + { + return null; + } + } + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static IServiceCollection AddOpenApi(this IServiceCollection services) + { + return services.AddOpenApi("v1", options => + { + options.AddSchemaTransformer(new XmlCommentSchemaTransformer()); + options.AddOperationTransformer(new XmlCommentOperationTransformer()); + }); + } + + } +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..0973c3d23999 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/OperationTests.SupportsXmlCommentsOnOperationsFromMinimalApis#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,363 @@ +//HintName: OpenApiXmlCommentSupport.generated.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable + +namespace System.Runtime.CompilerServices +{ + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.AspNetCore.OpenApi.Generated +{ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Linq; + using System.Reflection; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.AspNetCore.OpenApi; + using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.OpenApi.Models; + using Microsoft.OpenApi.Any; + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlComment( + string? Summary, + string? Description, + string? Remarks, + string? Returns, + string? Value, + bool Deprecated, + List? Examples, + List? Parameters, + List? Responses); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlResponseComment(string Code, string? Description, string? Example); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed record MemberKey( + Type? DeclaringType, + MemberType MemberKind, + string? Name, + Type? ReturnType, + Type[]? Parameters) : IEquatable + { + public bool Equals(MemberKey? other) + { + if (other is null) return false; + + // Check member kind + if (MemberKind != other.MemberKind) return false; + + // Check declaring type, handling generic types + if (!TypesEqual(DeclaringType, other.DeclaringType)) return false; + + // Check name + if (Name != other.Name) return false; + + // For methods, check return type and parameters + if (MemberKind == MemberType.Method) + { + if (!TypesEqual(ReturnType, other.ReturnType)) return false; + if (Parameters is null || other.Parameters is null) return false; + if (Parameters.Length != other.Parameters.Length) return false; + + for (int i = 0; i < Parameters.Length; i++) + { + if (!TypesEqual(Parameters[i], other.Parameters[i])) return false; + } + } + + return true; + } + + private static bool TypesEqual(Type? type1, Type? type2) + { + if (type1 == type2) return true; + if (type1 == null || type2 == null) return false; + + if (type1.IsGenericType && type2.IsGenericType) + { + return type1.GetGenericTypeDefinition() == type2.GetGenericTypeDefinition(); + } + + return type1 == type2; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(GetTypeHashCode(DeclaringType)); + hash.Add(MemberKind); + hash.Add(Name); + + if (MemberKind == MemberType.Method) + { + hash.Add(GetTypeHashCode(ReturnType)); + if (Parameters is not null) + { + foreach (var param in Parameters) + { + hash.Add(GetTypeHashCode(param)); + } + } + } + + return hash.ToHashCode(); + } + + private static int GetTypeHashCode(Type? type) + { + if (type == null) return 0; + return type.IsGenericType ? type.GetGenericTypeDefinition().GetHashCode() : type.GetHashCode(); + } + + public static MemberKey FromMethodInfo(MethodInfo method) + { + return new MemberKey( + method.DeclaringType, + MemberType.Method, + method.Name, + method.ReturnType.IsGenericParameter ? typeof(object) : method.ReturnType, + method.GetParameters().Select(p => p.ParameterType.IsGenericParameter ? typeof(object) : p.ParameterType).ToArray()); + } + + public static MemberKey FromPropertyInfo(PropertyInfo property) + { + return new MemberKey( + property.DeclaringType, + MemberType.Property, + property.Name, + null, + null); + } + + public static MemberKey FromTypeInfo(Type type) + { + return new MemberKey( + type, + MemberType.Type, + null, + null, + null); + } + } + + file enum MemberType + { + Type, + Property, + Method + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class XmlCommentCache + { + private static Dictionary? _cache; + public static Dictionary Cache => _cache ??= GenerateCacheEntries(); + + private static Dictionary GenerateCacheEntries() + { + var _cache = new Dictionary(); + + _cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get", typeof(global::System.String), []), new XmlComment(@"A summary of the action.", @"A description of the action.", null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get2", typeof(global::System.String), [typeof(global::System.String)]), new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", null, false)], [new XmlResponseComment(@"200", @"Returns the greeting.", @"")])); + _cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get3", typeof(global::System.String), [typeof(global::System.String)]), new XmlComment(null, null, null, null, null, false, null, [new XmlParameterComment(@"name", @"The name of the person.", @"Testy McTester", false)], null)); + _cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get4", typeof(global::Microsoft.AspNetCore.Http.HttpResults.NotFound), []), new XmlComment(null, null, null, null, null, false, null, null, [new XmlResponseComment(@"404", @"Indicates that the value was not found.", @"")])); + _cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Get5", typeof(global::Microsoft.AspNetCore.Http.HttpResults.Results, global::Microsoft.AspNetCore.Http.HttpResults.Ok, global::Microsoft.AspNetCore.Http.HttpResults.Created>), []), new XmlComment(null, null, null, null, null, false, null, null, [new XmlResponseComment(@"200", @"Indicates that the value is even.", @""), new XmlResponseComment(@"201", @"Indicates that the value is less than 50.", @""), new XmlResponseComment(@"404", @"Indicates that the value was not found.", @"")])); + _cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Post6", typeof(global::Microsoft.AspNetCore.Http.IResult), [typeof(global::User)]), new XmlComment(@"Creates a new user.", null, @"Sample request: + POST /6 + { + ""username"": ""johndoe"", + ""email"": ""john@example.com"" + }", null, null, false, null, [new XmlParameterComment(@"user", @"The user information.", @"{""username"": ""johndoe"", ""email"": ""john@example.com""}", false)], [new XmlResponseComment(@"201", @"Successfully created the user.", @""), new XmlResponseComment(@"400", @"If the user data is invalid.", @"")])); + _cache.Add(new MemberKey(typeof(global::RouteHandlerExtensionMethods), MemberType.Method, "Put7", typeof(global::Microsoft.AspNetCore.Http.IResult), [typeof(global::System.Int32?), typeof(global::System.String)]), new XmlComment(@"Updates an existing record.", null, null, null, null, false, null, [new XmlParameterComment(@"id", @"Legacy ID parameter - use uuid instead.", null, true), new XmlParameterComment(@"uuid", @"Unique identifier for the record.", null, false)], [new XmlResponseComment(@"204", @"Update successful.", @""), new XmlResponseComment(@"404", @"Legacy response - will be removed.", @"")])); + + return _cache; + } + + internal static bool TryGetXmlComment(Type? type, MethodInfo? methodInfo, [NotNullWhen(true)] out XmlComment? xmlComment) + { + if (methodInfo is null) + { + return Cache.TryGetValue(new MemberKey(type, MemberType.Property, null, null, null), out xmlComment); + } + + return Cache.TryGetValue(MemberKey.FromMethodInfo(methodInfo), out xmlComment); + } + + internal static bool TryGetXmlComment(Type? type, string? memberName, [NotNullWhen(true)] out XmlComment? xmlComment) + { + return Cache.TryGetValue(new MemberKey(type, memberName is null ? MemberType.Type : MemberType.Property, memberName, null, null), out xmlComment); + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor + ? controllerActionDescriptor.MethodInfo + : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + + if (methodInfo is null) + { + return Task.CompletedTask; + } + if (XmlCommentCache.TryGetXmlComment(methodInfo.DeclaringType, methodInfo, out var methodComment)) + { + if (methodComment.Summary is { } summary) + { + operation.Summary = summary; + } + if (methodComment.Description is { } description) + { + operation.Description = description; + } + if (methodComment.Remarks is { } remarks) + { + operation.Description = remarks; + } + if (methodComment.Parameters is { Count: > 0}) + { + foreach (var parameterComment in methodComment.Parameters) + { + var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name); + var operationParameter = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name); + if (operationParameter is not null) + { + operationParameter.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + operationParameter.Example = jsonString.Parse(); + } + operationParameter.Deprecated = parameterComment.Deprecated; + } + else + { + var requestBody = operation.RequestBody; + if (requestBody is not null) + { + requestBody.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + foreach (var mediaType in requestBody.Content.Values) + { + mediaType.Example = jsonString.Parse(); + } + } + } + } + } + } + if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 }) + { + foreach (var response in operation.Responses) + { + var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key); + if (responseComment is not null) + { + response.Value.Description = responseComment.Description; + } + } + } + } + + return Task.CompletedTask; + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo }) + { + if (XmlCommentCache.TryGetXmlComment(propertyInfo.DeclaringType, propertyInfo.Name, out var propertyComment)) + { + schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + } + if (XmlCommentCache.TryGetXmlComment(context.JsonTypeInfo.Type, (string?)null, out var typeComment)) + { + schema.Description = typeComment.Summary; + if (typeComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + return Task.CompletedTask; + } + } + + file static class JsonNodeExtensions + { + public static JsonNode? Parse(this string? json) + { + if (json is null) + { + return null; + } + + try + { + return JsonNode.Parse(json); + } + catch (JsonException) + { + try + { + // If parsing fails, try wrapping in quotes to make it a valid JSON string + return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\""); + } + catch (JsonException) + { + return null; + } + } + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static IServiceCollection AddOpenApi(this IServiceCollection services) + { + return services.AddOpenApi("v1", options => + { + options.AddSchemaTransformer(new XmlCommentSchemaTransformer()); + options.AddOperationTransformer(new XmlCommentOperationTransformer()); + }); + } + + } +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs new file mode 100644 index 000000000000..8abf7d226a17 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.SourceGenerators.Tests/snapshots/SchemaTests.SupportsXmlCommentsOnSchemas#OpenApiXmlCommentSupport.generated.verified.cs @@ -0,0 +1,379 @@ +//HintName: OpenApiXmlCommentSupport.generated.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable + +namespace System.Runtime.CompilerServices +{ + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.AspNetCore.OpenApi.Generated +{ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Linq; + using System.Reflection; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.AspNetCore.OpenApi; + using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.OpenApi.Models; + using Microsoft.OpenApi.Any; + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlComment( + string? Summary, + string? Description, + string? Remarks, + string? Returns, + string? Value, + bool Deprecated, + List? Examples, + List? Parameters, + List? Responses); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlParameterComment(string? Name, string? Description, string? Example, bool Deprecated); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file record XmlResponseComment(string Code, string? Description, string? Example); + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed record MemberKey( + Type? DeclaringType, + MemberType MemberKind, + string? Name, + Type? ReturnType, + Type[]? Parameters) : IEquatable + { + public bool Equals(MemberKey? other) + { + if (other is null) return false; + + // Check member kind + if (MemberKind != other.MemberKind) return false; + + // Check declaring type, handling generic types + if (!TypesEqual(DeclaringType, other.DeclaringType)) return false; + + // Check name + if (Name != other.Name) return false; + + // For methods, check return type and parameters + if (MemberKind == MemberType.Method) + { + if (!TypesEqual(ReturnType, other.ReturnType)) return false; + if (Parameters is null || other.Parameters is null) return false; + if (Parameters.Length != other.Parameters.Length) return false; + + for (int i = 0; i < Parameters.Length; i++) + { + if (!TypesEqual(Parameters[i], other.Parameters[i])) return false; + } + } + + return true; + } + + private static bool TypesEqual(Type? type1, Type? type2) + { + if (type1 == type2) return true; + if (type1 == null || type2 == null) return false; + + if (type1.IsGenericType && type2.IsGenericType) + { + return type1.GetGenericTypeDefinition() == type2.GetGenericTypeDefinition(); + } + + return type1 == type2; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(GetTypeHashCode(DeclaringType)); + hash.Add(MemberKind); + hash.Add(Name); + + if (MemberKind == MemberType.Method) + { + hash.Add(GetTypeHashCode(ReturnType)); + if (Parameters is not null) + { + foreach (var param in Parameters) + { + hash.Add(GetTypeHashCode(param)); + } + } + } + + return hash.ToHashCode(); + } + + private static int GetTypeHashCode(Type? type) + { + if (type == null) return 0; + return type.IsGenericType ? type.GetGenericTypeDefinition().GetHashCode() : type.GetHashCode(); + } + + public static MemberKey FromMethodInfo(MethodInfo method) + { + return new MemberKey( + method.DeclaringType, + MemberType.Method, + method.Name, + method.ReturnType.IsGenericParameter ? typeof(object) : method.ReturnType, + method.GetParameters().Select(p => p.ParameterType.IsGenericParameter ? typeof(object) : p.ParameterType).ToArray()); + } + + public static MemberKey FromPropertyInfo(PropertyInfo property) + { + return new MemberKey( + property.DeclaringType, + MemberType.Property, + property.Name, + null, + null); + } + + public static MemberKey FromTypeInfo(Type type) + { + return new MemberKey( + type, + MemberType.Type, + null, + null, + null); + } + } + + file enum MemberType + { + Type, + Property, + Method + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class XmlCommentCache + { + private static Dictionary? _cache; + public static Dictionary Cache => _cache ??= GenerateCacheEntries(); + + private static Dictionary GenerateCacheEntries() + { + var _cache = new Dictionary(); + + _cache.Add(new MemberKey(typeof(global::Todo), MemberType.Type, null, null, []), new XmlComment(@"This is a todo item.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::Project), MemberType.Type, null, null, []), new XmlComment(@"The project that contains Todo items.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ProjectBoard.BoardItem), MemberType.Type, null, null, []), new XmlComment(@"An item on the board.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ProjectRecord), MemberType.Type, null, null, []), new XmlComment(@"The project that contains Todo items.", null, null, null, null, false, null, [new XmlParameterComment(@"Name", @"The name of the project.", null, false), new XmlParameterComment(@"Description", @"The description of the project.", null, false)], null)); + _cache.Add(new MemberKey(typeof(global::User), MemberType.Type, null, null, []), new XmlComment(null, null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ProjectRecord), MemberType.Property, "Name", null, []), new XmlComment(@"The name of the project.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::ProjectRecord), MemberType.Property, "Description", null, []), new XmlComment(@"The description of the project.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::TodoWithDescription), MemberType.Property, "Id", null, []), new XmlComment(@"The identifier of the todo.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::TodoWithDescription), MemberType.Property, "Name", null, []), new XmlComment(null, null, null, null, @"The name of the todo.", false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::TodoWithDescription), MemberType.Property, "Description", null, []), new XmlComment(@"A description of the the todo.", null, null, null, @"Another description of the todo.", false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "BooleanType", null, []), new XmlComment(null, null, null, null, null, false, [@"true"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "IntegerType", null, []), new XmlComment(null, null, null, null, null, false, [@"42"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "LongType", null, []), new XmlComment(null, null, null, null, null, false, [@"1234567890123456789"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "DoubleType", null, []), new XmlComment(null, null, null, null, null, false, [@"3.14"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "FloatType", null, []), new XmlComment(null, null, null, null, null, false, [@"3.14"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "DateTimeType", null, []), new XmlComment(null, null, null, null, null, false, [@"2022-01-01T00:00:00Z"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "DateOnlyType", null, []), new XmlComment(null, null, null, null, null, false, [@"2022-01-01"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "StringType", null, []), new XmlComment(null, null, null, null, null, false, [@"Hello, World!"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "GuidType", null, []), new XmlComment(null, null, null, null, null, false, [@"2d8f1eac-b5c6-4e29-8c62-4d9d75ef3d3d"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "TimeOnlyType", null, []), new XmlComment(null, null, null, null, null, false, [@"12:30:45"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "TimeSpanType", null, []), new XmlComment(null, null, null, null, null, false, [@"P3DT4H5M"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "ByteType", null, []), new XmlComment(null, null, null, null, null, false, [@"255"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "DecimalType", null, []), new XmlComment(null, null, null, null, null, false, [@"3.14159265359"], null, null)); + _cache.Add(new MemberKey(typeof(global::TypeWithExamples), MemberType.Property, "UriType", null, []), new XmlComment(null, null, null, null, null, false, [@"https://example.com"], null, null)); + _cache.Add(new MemberKey(typeof(global::IUser), MemberType.Property, "Id", null, []), new XmlComment(@"The unique identifier for the user.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::IUser), MemberType.Property, "Name", null, []), new XmlComment(@"The user's display name.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::User), MemberType.Property, "Id", null, []), new XmlComment(@"The unique identifier for the user.", null, null, null, null, false, null, null, null)); + _cache.Add(new MemberKey(typeof(global::User), MemberType.Property, "Name", null, []), new XmlComment(@"The user's display name.", null, null, null, null, false, null, null, null)); + + return _cache; + } + + internal static bool TryGetXmlComment(Type? type, MethodInfo? methodInfo, [NotNullWhen(true)] out XmlComment? xmlComment) + { + if (methodInfo is null) + { + return Cache.TryGetValue(new MemberKey(type, MemberType.Property, null, null, null), out xmlComment); + } + + return Cache.TryGetValue(MemberKey.FromMethodInfo(methodInfo), out xmlComment); + } + + internal static bool TryGetXmlComment(Type? type, string? memberName, [NotNullWhen(true)] out XmlComment? xmlComment) + { + return Cache.TryGetValue(new MemberKey(type, memberName is null ? MemberType.Type : MemberType.Property, memberName, null, null), out xmlComment); + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor + ? controllerActionDescriptor.MethodInfo + : context.Description.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + + if (methodInfo is null) + { + return Task.CompletedTask; + } + if (XmlCommentCache.TryGetXmlComment(methodInfo.DeclaringType, methodInfo, out var methodComment)) + { + if (methodComment.Summary is { } summary) + { + operation.Summary = summary; + } + if (methodComment.Description is { } description) + { + operation.Description = description; + } + if (methodComment.Remarks is { } remarks) + { + operation.Description = remarks; + } + if (methodComment.Parameters is { Count: > 0}) + { + foreach (var parameterComment in methodComment.Parameters) + { + var parameterInfo = methodInfo.GetParameters().SingleOrDefault(info => info.Name == parameterComment.Name); + var operationParameter = operation.Parameters?.SingleOrDefault(parameter => parameter.Name == parameterComment.Name); + if (operationParameter is not null) + { + operationParameter.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + operationParameter.Example = jsonString.Parse(); + } + operationParameter.Deprecated = parameterComment.Deprecated; + } + else + { + var requestBody = operation.RequestBody; + if (requestBody is not null) + { + requestBody.Description = parameterComment.Description; + if (parameterComment.Example is { } jsonString) + { + foreach (var mediaType in requestBody.Content.Values) + { + mediaType.Example = jsonString.Parse(); + } + } + } + } + } + } + if (methodComment.Responses is { Count: > 0} && operation.Responses is { Count: > 0 }) + { + foreach (var response in operation.Responses) + { + var responseComment = methodComment.Responses.SingleOrDefault(xmlResponse => xmlResponse.Code == response.Key); + if (responseComment is not null) + { + response.Value.Description = responseComment.Description; + } + } + } + } + + return Task.CompletedTask; + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class XmlCommentSchemaTransformer : IOpenApiSchemaTransformer + { + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (context.JsonPropertyInfo is { AttributeProvider: PropertyInfo propertyInfo }) + { + if (XmlCommentCache.TryGetXmlComment(propertyInfo.DeclaringType, propertyInfo.Name, out var propertyComment)) + { + schema.Description = propertyComment.Value ?? propertyComment.Returns ?? propertyComment.Summary; + if (propertyComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + } + if (XmlCommentCache.TryGetXmlComment(context.JsonTypeInfo.Type, (string?)null, out var typeComment)) + { + schema.Description = typeComment.Summary; + if (typeComment.Examples?.FirstOrDefault() is { } jsonString) + { + schema.Example = jsonString.Parse(); + } + } + return Task.CompletedTask; + } + } + + file static class JsonNodeExtensions + { + public static JsonNode? Parse(this string? json) + { + if (json is null) + { + return null; + } + + try + { + return JsonNode.Parse(json); + } + catch (JsonException) + { + try + { + // If parsing fails, try wrapping in quotes to make it a valid JSON string + return JsonNode.Parse($"\"{json.Replace("\"", "\\\"")}\""); + } + catch (JsonException) + { + return null; + } + } + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.OpenApi.SourceGenerators, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static IServiceCollection AddOpenApi(this IServiceCollection services) + { + return services.AddOpenApi("v1", options => + { + options.AddSchemaTransformer(new XmlCommentSchemaTransformer()); + options.AddOperationTransformer(new XmlCommentOperationTransformer()); + }); + } + + } +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs index 3078ce4745a0..dae562746d3f 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs @@ -1,18 +1,20 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Globalization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; using Microsoft.OpenApi.Extensions; -using Microsoft.OpenApi.Models; -using Microsoft.OpenApi.Writers; +using System.Text.RegularExpressions; [UsesVerify] public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) : IClassFixture { + private static Regex DateTimeRegex() => new( + @"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}[+-]\d{2}:\d{2}", + RegexOptions.Compiled); + [Theory] [InlineData("v1", OpenApiSpecVersion.OpenApi3_0)] [InlineData("v2", OpenApiSpecVersion.OpenApi3_0)] @@ -20,12 +22,14 @@ public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) : [InlineData("responses", OpenApiSpecVersion.OpenApi3_0)] [InlineData("forms", OpenApiSpecVersion.OpenApi3_0)] [InlineData("schemas-by-ref", OpenApiSpecVersion.OpenApi3_0)] + [InlineData("xml", OpenApiSpecVersion.OpenApi3_0)] [InlineData("v1", OpenApiSpecVersion.OpenApi3_1)] [InlineData("v2", OpenApiSpecVersion.OpenApi3_1)] [InlineData("controllers", OpenApiSpecVersion.OpenApi3_1)] [InlineData("responses", OpenApiSpecVersion.OpenApi3_1)] [InlineData("forms", OpenApiSpecVersion.OpenApi3_1)] [InlineData("schemas-by-ref", OpenApiSpecVersion.OpenApi3_1)] + [InlineData("xml", OpenApiSpecVersion.OpenApi3_1)] public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion version) { var documentService = fixture.Services.GetRequiredKeyedService(documentName); @@ -38,7 +42,7 @@ public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion var outputDirectory = Path.Combine(baseSnapshotsDirectory, version.ToString()); await Verifier.Verify(json) .UseDirectory(outputDirectory) - .AutoVerify() + .ScrubLinesWithReplace(line => DateTimeRegex().Replace(line, "[datetime]")) .UseParameters(documentName); } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index f45a9ee9f055..e4bbaf44a54a 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt @@ -198,19 +198,24 @@ "properties": { "id": { "type": "integer", + "description": "The unique identifier of the to-do item.", "format": "int32" }, "title": { - "type": "string" + "type": "string", + "description": "The title of the to-do item." }, "completed": { - "type": "boolean" + "type": "boolean", + "description": "Indicates whether the to-do item is completed." }, "createdAt": { "type": "string", + "description": "The date and time when the to-do item was created.", "format": "date-time" } - } + }, + "description": "Represents a to-do item." } } }, diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index 16a6648e29f8..96a3be6747cf 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -163,19 +163,24 @@ "properties": { "id": { "type": "integer", + "description": "The unique identifier of the to-do item.", "format": "int32" }, "title": { - "type": "string" + "type": "string", + "description": "The title of the to-do item." }, "completed": { - "type": "boolean" + "type": "boolean", + "description": "Indicates whether the to-do item is completed." }, "createdAt": { "type": "string", + "description": "The date and time when the to-do item was created.", "format": "date-time" } - } + }, + "description": "Represents a to-do item." }, "Triangle": { "type": "object", diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt index e1044352e0db..98c81bc48fce 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt @@ -136,19 +136,24 @@ "properties": { "id": { "type": "integer", + "description": "The unique identifier of the to-do item.", "format": "int32" }, "title": { - "type": "string" + "type": "string", + "description": "The title of the to-do item." }, "completed": { - "type": "boolean" + "type": "boolean", + "description": "Indicates whether the to-do item is completed." }, "createdAt": { "type": "string", + "description": "The date and time when the to-do item was created.", "format": "date-time" } - } + }, + "description": "Represents a to-do item." }, "TodoWithDueDate": { "required": [ @@ -162,23 +167,29 @@ "properties": { "dueDate": { "type": "string", + "description": "The due date of the to-do item.", "format": "date-time" }, "id": { "type": "integer", + "description": "The unique identifier of the to-do item.", "format": "int32" }, "title": { - "type": "string" + "type": "string", + "description": "The title of the to-do item." }, "completed": { - "type": "boolean" + "type": "boolean", + "description": "Indicates whether the to-do item is completed." }, "createdAt": { "type": "string", + "description": "The date and time when the to-do item was created.", "format": "date-time" } - } + }, + "description": "Represents a to-do item with a due date." } }, "securitySchemes": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt new file mode 100644 index 000000000000..4417a72d5dfb --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt @@ -0,0 +1,397 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "Sample | xml", + "version": "1.0.0" + }, + "paths": { + "/xml/type-with-examples": { + "get": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeWithExamples" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeWithExamples" + } + } + } + } + } + } + }, + "/xml/todo": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoFomInterface" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/project": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/board": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BoardItem" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/project-record": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecord" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/todo-with-description": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDescription" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/Xml": { + "get": { + "tags": [ + "Xml" + ], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the person.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Returns the greeting.", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "text/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "Xml" + ], + "requestBody": { + "description": "The todo to insert into the database.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "text/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "BoardItem": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "description": "An item on the board." + }, + "Project": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "description": "The project that contains Todo items." + }, + "ProjectRecord": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the project." + }, + "description": { + "type": "string", + "description": "The description of the project." + } + }, + "description": "The project that contains Todo items." + }, + "Todo": { + "required": [ + "id", + "title", + "completed" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + } + } + }, + "TodoFomInterface": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The identifier of the todo.", + "format": "int32" + }, + "name": { + "type": "string", + "description": "The name of the todo." + }, + "description": { + "type": "string", + "description": "A description of the todo." + } + }, + "description": "This is a todo item." + }, + "TodoWithDescription": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The identifier of the todo, overridden.", + "format": "int32" + }, + "name": { + "type": "string", + "description": "The name of the todo, overridden." + }, + "description": { + "type": "string", + "description": "Another description of the todo." + } + } + }, + "TypeWithExamples": { + "type": "object", + "properties": { + "booleanType": { + "type": "boolean", + "example": true + }, + "integerType": { + "type": "integer", + "format": "int32", + "example": 42 + }, + "longType": { + "type": "integer", + "format": "int64", + "example": 1234567890123456789 + }, + "doubleType": { + "type": "number", + "format": "double", + "example": 3.14 + }, + "floatType": { + "type": "number", + "format": "float", + "example": 3.14 + }, + "dateTimeType": { + "type": "string", + "format": "date-time", + "example": "[datetime]" + }, + "dateOnlyType": { + "type": "string", + "format": "date", + "example": "[datetime]" + } + } + } + } + }, + "tags": [ + { + "name": "Sample" + }, + { + "name": "Xml" + } + ] +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index 190efab5598d..c68e4d17c64d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt @@ -198,19 +198,24 @@ "properties": { "id": { "type": "integer", + "description": "The unique identifier of the to-do item.", "format": "int32" }, "title": { - "type": "string" + "type": "string", + "description": "The title of the to-do item." }, "completed": { - "type": "boolean" + "type": "boolean", + "description": "Indicates whether the to-do item is completed." }, "createdAt": { "type": "string", + "description": "The date and time when the to-do item was created.", "format": "date-time" } - } + }, + "description": "Represents a to-do item." } } }, diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index 3ac78c75d5d3..45a4660aa78c 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -163,19 +163,24 @@ "properties": { "id": { "type": "integer", + "description": "The unique identifier of the to-do item.", "format": "int32" }, "title": { - "type": "string" + "type": "string", + "description": "The title of the to-do item." }, "completed": { - "type": "boolean" + "type": "boolean", + "description": "Indicates whether the to-do item is completed." }, "createdAt": { "type": "string", + "description": "The date and time when the to-do item was created.", "format": "date-time" } - } + }, + "description": "Represents a to-do item." }, "Triangle": { "type": "object", diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt index 4ddd84cfac73..abbe8732d74f 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt @@ -136,19 +136,24 @@ "properties": { "id": { "type": "integer", + "description": "The unique identifier of the to-do item.", "format": "int32" }, "title": { - "type": "string" + "type": "string", + "description": "The title of the to-do item." }, "completed": { - "type": "boolean" + "type": "boolean", + "description": "Indicates whether the to-do item is completed." }, "createdAt": { "type": "string", + "description": "The date and time when the to-do item was created.", "format": "date-time" } - } + }, + "description": "Represents a to-do item." }, "TodoWithDueDate": { "required": [ @@ -162,23 +167,29 @@ "properties": { "dueDate": { "type": "string", + "description": "The due date of the to-do item.", "format": "date-time" }, "id": { "type": "integer", + "description": "The unique identifier of the to-do item.", "format": "int32" }, "title": { - "type": "string" + "type": "string", + "description": "The title of the to-do item." }, "completed": { - "type": "boolean" + "type": "boolean", + "description": "Indicates whether the to-do item is completed." }, "createdAt": { "type": "string", + "description": "The date and time when the to-do item was created.", "format": "date-time" } - } + }, + "description": "Represents a to-do item with a due date." } }, "securitySchemes": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt new file mode 100644 index 000000000000..d90741d5d1fd --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=xml.verified.txt @@ -0,0 +1,397 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Sample | xml", + "version": "1.0.0" + }, + "paths": { + "/xml/type-with-examples": { + "get": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeWithExamples" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeWithExamples" + } + } + } + } + } + } + }, + "/xml/todo": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoFomInterface" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/project": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/board": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BoardItem" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/project-record": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecord" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/xml/todo-with-description": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoWithDescription" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/Xml": { + "get": { + "tags": [ + "Xml" + ], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "The name of the person.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Returns the greeting.", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "text/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "Xml" + ], + "requestBody": { + "description": "The todo to insert into the database.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + }, + "text/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "BoardItem": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "description": "An item on the board." + }, + "Project": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "description": "The project that contains Todo items." + }, + "ProjectRecord": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the project." + }, + "description": { + "type": "string", + "description": "The description of the project." + } + }, + "description": "The project that contains Todo items." + }, + "Todo": { + "required": [ + "id", + "title", + "completed" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + } + } + }, + "TodoFomInterface": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The identifier of the todo.", + "format": "int32" + }, + "name": { + "type": "string", + "description": "The name of the todo." + }, + "description": { + "type": "string", + "description": "A description of the todo." + } + }, + "description": "This is a todo item." + }, + "TodoWithDescription": { + "required": [ + "name", + "description" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The identifier of the todo, overridden.", + "format": "int32" + }, + "name": { + "type": "string", + "description": "The name of the todo, overridden." + }, + "description": { + "type": "string", + "description": "Another description of the todo." + } + } + }, + "TypeWithExamples": { + "type": "object", + "properties": { + "booleanType": { + "type": "boolean", + "example": true + }, + "integerType": { + "type": "integer", + "format": "int32", + "example": 42 + }, + "longType": { + "type": "integer", + "format": "int64", + "example": 1234567890123456789 + }, + "doubleType": { + "type": "number", + "format": "double", + "example": 3.14 + }, + "floatType": { + "type": "number", + "format": "float", + "example": 3.14 + }, + "dateTimeType": { + "type": "string", + "format": "date-time", + "example": "[datetime]" + }, + "dateOnlyType": { + "type": "string", + "format": "date", + "example": "[datetime]" + } + } + } + } + }, + "tags": [ + { + "name": "Sample" + }, + { + "name": "Xml" + } + ] +} \ No newline at end of file diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Microsoft.AspNetCore.OpenApi.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Microsoft.AspNetCore.OpenApi.Tests.csproj index 3dd050f9bded..25e04bc98a6f 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Microsoft.AspNetCore.OpenApi.Tests.csproj +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Microsoft.AspNetCore.OpenApi.Tests.csproj @@ -36,6 +36,8 @@ - + diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.cs index 0f9050ea995f..73e85f0458eb 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.cs @@ -10,52 +10,159 @@ using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; +/// +/// Represents a to-do item. +/// +/// The unique identifier of the to-do item. +/// The title of the to-do item. +/// Indicates whether the to-do item is completed. +/// The date and time when the to-do item was created. internal record Todo(int Id, string Title, bool Completed, DateTime CreatedAt); +/// +/// Represents a to-do item with a due date. +/// +/// The unique identifier of the to-do item. +/// The title of the to-do item. +/// Indicates whether the to-do item is completed. +/// The date and time when the to-do item was created. +/// The due date of the to-do item. internal record TodoWithDueDate(int Id, string Title, bool Completed, DateTime CreatedAt, DateTime DueDate) : Todo(Id, Title, Completed, CreatedAt); +/// +/// Represents an error. +/// +/// The error code. +/// The error message. internal record Error(int Code, string Message); +/// +/// Represents a resume upload. +/// +/// The name of the resume. +/// The description of the resume. +/// The resume file. internal record ResumeUpload(string Name, string Description, IFormFile Resume); +/// +/// Represents a result of an operation. +/// +/// The type of the value. +/// Indicates whether the operation was successful. +/// The value of the result. +/// The error associated with the result, if any. internal record Result(bool IsSuccessful, T Value, Error Error); +/// +/// Represents a vehicle. +/// internal class Vehicle { + /// + /// Gets or sets the number of wheels. + /// public int Wheels { get; set; } + + /// + /// Gets or sets the make of the vehicle. + /// public string Make { get; set; } = string.Empty; } +/// +/// Represents a car. +/// internal class Car : Vehicle { + /// + /// Gets or sets the number of doors. + /// public int Doors { get; set; } } +/// +/// Represents a boat. +/// internal class Boat : Vehicle { + /// + /// Gets or sets the length of the boat. + /// public double Length { get; set; } } +/// +/// Represents the status of an operation. +/// [JsonConverter(typeof(JsonStringEnumConverter))] internal enum Status { + /// + /// The operation is pending. + /// Pending, + + /// + /// The operation is approved. + /// Approved, + + /// + /// The operation is rejected. + /// Rejected } +/// +/// Represents a proposal. +/// internal class Proposal { + /// + /// Gets or sets the proposal element. + /// public required Proposal ProposalElement { get; set; } + + /// + /// Gets or sets the stream associated with the proposal. + /// public required Stream Stream { get; set; } } +/// +/// Represents a paginated collection of items. +/// +/// The type of items contained in the collection. Must be a reference type. +/// The current page index (zero-based). +/// The number of items per page. +/// The total number of items in the collection. +/// The total number of pages available. +/// The collection of items for the current page. internal class PaginatedItems(int pageIndex, int pageSize, long totalItems, int totalPages, IEnumerable items) where T : class { + /// + /// Gets or sets the current page index (zero-based). + /// public int PageIndex { get; set; } = pageIndex; + + /// + /// Gets or sets the number of items per page. + /// public int PageSize { get; set; } = pageSize; + + /// + /// Gets or sets the total number of items in the collection. + /// public long TotalItems { get; set; } = totalItems; + + /// + /// Gets or sets the total number of pages available. + /// public int TotalPages { get; set; } = totalPages; + + /// + /// Gets or sets the collection of items for the current page. + /// public IEnumerable Items { get; set; } = items; }