├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── dotnetcore.yml ├── .gitignore ├── DotNet.Cli.Build ├── DotNet.Cli.Build.csproj ├── Exe.cs ├── Project.cs └── Resources │ └── DotNetCliBuild.targets ├── GenerateAspNetCoreClient.Command ├── ClientModelBuilder.cs ├── Extensions │ ├── ObjectExtensions.cs │ ├── StringExtensions.cs │ └── TypeExtensions.cs ├── GenerateAspNetCoreClient.Command.csproj ├── GenerateClientCommand.cs ├── GroupInfo.cs ├── HostFactoryResolver.cs ├── Model │ ├── Client.cs │ ├── EndpointMethod.cs │ ├── Parameter.cs │ └── ParameterSource.cs ├── Properties │ └── Assembly.cs └── ServiceProviderResolver.cs ├── GenerateAspNetCoreClient.Options ├── GenerateAspNetCoreClient.Options.csproj └── GenerateClientOptions.cs ├── GenerateAspNetCoreClient.sln ├── GenerateAspNetCoreClient ├── CustomLoadContext.cs ├── GenerateAspNetCoreClient.csproj ├── Program.cs └── Properties │ ├── Assembly.cs │ └── launchSettings.json ├── LICENSE ├── README.md └── Tests ├── GenerateAspNetCoreClient.Tests ├── ApiDescriptionTestData.cs ├── ClientGenerationTests.GenerationTest_UseApiResponses │ └── IWeatherForecastApi.verified.cs ├── ClientGenerationTests.GenerationTest_UseApiResponses_UseCancellationTokens │ ├── ITestWebApiMinimalApiApi.verified.cs │ └── IWeatherForecastApi.verified.cs ├── ClientGenerationTests.GenerationTest_testProjectName=TestWebApi.Controllers │ ├── IAnotherTestApi.verified.cs │ └── IWeatherForecastApi.verified.cs ├── ClientGenerationTests.GenerationTest_testProjectName=TestWebApi.MinimalApi │ ├── ITestWebApiMinimalApiApi.verified.cs │ └── IWeatherForecastApi.verified.cs ├── ClientGenerationTests.GenerationTest_testProjectName=TestWebApi.Versioning │ └── IVersionApi.verified.cs ├── ClientGenerationTests.cs ├── ClientModelBuilderTests.cs ├── EndpointFilteringTests.cs └── GenerateAspNetCoreClient.Tests.csproj ├── OutputTest └── OutputProject.csproj ├── TestWebApi.Controllers.UseApiResponses ├── Controllers │ └── WeatherForecastController.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── TestWebApi.Controllers.UseApiResponses.csproj ├── appsettings.Development.json └── appsettings.json ├── TestWebApi.Controllers ├── .editorconfig ├── Controllers │ ├── AnotherTestController.cs │ └── WeatherForecastController.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Startup.cs ├── TestWebApi.Controllers.csproj ├── appsettings.Development.json └── appsettings.json ├── TestWebApi.Dtos ├── RecordModel.cs ├── SomeEnum.cs ├── SomeQueryModel.cs ├── TestWebApi.Dtos.csproj ├── WeatherForecast.cs └── WeatherForecastRecord.cs ├── TestWebApi.MinimalApi ├── Program.cs ├── Properties │ └── launchSettings.json ├── TestWebApi.MinimalApi.csproj ├── appsettings.Development.json └── appsettings.json └── TestWebApi.Versioning ├── Controllers └── VersionController.cs ├── Program.cs ├── Properties └── launchSettings.json ├── TestWebApi.Versioning.csproj ├── appsettings.Development.json └── appsettings.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | indent_style = space 4 | csharp_indent_case_contents = true 5 | csharp_indent_switch_labels = true 6 | csharp_new_line_before_catch = true 7 | csharp_new_line_before_else = true 8 | csharp_new_line_before_finally = true 9 | csharp_new_line_before_members_in_object_initializers = true 10 | csharp_new_line_before_open_brace = methods, lambdas, object_collection_array_initializers, control_blocks, types 11 | dotnet_sort_system_directives_first = true 12 | csharp_space_after_cast = false 13 | csharp_space_after_colon_in_inheritance_clause = true 14 | csharp_space_after_keywords_in_control_flow_statements = true 15 | csharp_space_before_colon_in_inheritance_clause = true 16 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 17 | csharp_space_between_method_call_name_and_opening_parenthesis = false 18 | csharp_space_between_method_call_parameter_list_parentheses = false 19 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 20 | csharp_space_between_method_declaration_parameter_list_parentheses = false 21 | csharp_preserve_single_line_blocks = true 22 | csharp_prefer_braces = true : silent 23 | csharp_style_expression_bodied_constructors = false : silent 24 | csharp_style_expression_bodied_methods = false : silent 25 | csharp_style_expression_bodied_properties = true : silent 26 | csharp_style_inlined_variable_declaration = true : suggestion 27 | dotnet_style_predefined_type_for_member_access = true : suggestion 28 | dotnet_style_object_initializer = true : suggestion 29 | csharp_style_var_elsewhere = true : silent 30 | csharp_style_var_for_built_in_types = true : silent 31 | csharp_style_var_when_type_is_apparent = true : suggestion 32 | dotnet_style_predefined_type_for_locals_parameters_members = true : suggestion 33 | csharp_style_pattern_local_over_anonymous_function = true : suggestion 34 | dotnet_style_require_accessibility_modifiers = for_non_interface_members : suggestion 35 | csharp_preferred_modifier_order = public,private,internal,protected,static,readonly,async,override : suggestion 36 | csharp_style_pattern_matching_over_as_with_null_check = false : suggestion 37 | dotnet_style_qualification_for_field = false : suggestion 38 | dotnet_style_qualification_for_method = false : suggestion 39 | dotnet_style_qualification_for_property = false : suggestion 40 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/dotnetcore.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Setup .NET Core 13 | uses: actions/setup-dotnet@v4 14 | with: 15 | dotnet-version: 9.0.x 16 | - name: Tests 17 | run: dotnet test 18 | - name: Pack 19 | run: dotnet pack GenerateAspNetCoreClient -c Release 20 | - name: Upload a Build Artifact 21 | uses: actions/upload-artifact@v4 22 | with: 23 | path: ./**/*.nupkg 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | [Aa][Rr][Mm]/ 24 | [Aa][Rr][Mm]64/ 25 | bld/ 26 | [Bb]in/ 27 | [Oo]bj/ 28 | [Ll]og/ 29 | 30 | # Visual Studio 2015/2017 cache/options directory 31 | .vs/ 32 | # Uncomment if you have tasks that create the project's static files in wwwroot 33 | #wwwroot/ 34 | 35 | # Visual Studio 2017 auto generated files 36 | Generated\ Files/ 37 | 38 | # MSTest test Results 39 | [Tt]est[Rr]esult*/ 40 | [Bb]uild[Ll]og.* 41 | 42 | # NUNIT 43 | *.VisualState.xml 44 | TestResult.xml 45 | 46 | # Build Results of an ATL Project 47 | [Dd]ebugPS/ 48 | [Rr]eleasePS/ 49 | dlldata.c 50 | 51 | # Benchmark Results 52 | BenchmarkDotNet.Artifacts/ 53 | 54 | # .NET Core 55 | project.lock.json 56 | project.fragment.lock.json 57 | artifacts/ 58 | 59 | # StyleCop 60 | StyleCopReport.xml 61 | 62 | # Files built by Visual Studio 63 | *_i.c 64 | *_p.c 65 | *_h.h 66 | *.ilk 67 | *.meta 68 | *.obj 69 | *.iobj 70 | *.pch 71 | *.pdb 72 | *.ipdb 73 | *.pgc 74 | *.pgd 75 | *.rsp 76 | *.sbr 77 | *.tlb 78 | *.tli 79 | *.tlh 80 | *.tmp 81 | *.tmp_proj 82 | *_wpftmp.csproj 83 | *.log 84 | *.vspscc 85 | *.vssscc 86 | .builds 87 | *.pidb 88 | *.svclog 89 | *.scc 90 | 91 | # Chutzpah Test files 92 | _Chutzpah* 93 | 94 | # Visual C++ cache files 95 | ipch/ 96 | *.aps 97 | *.ncb 98 | *.opendb 99 | *.opensdf 100 | *.sdf 101 | *.cachefile 102 | *.VC.db 103 | *.VC.VC.opendb 104 | 105 | # Visual Studio profiler 106 | *.psess 107 | *.vsp 108 | *.vspx 109 | *.sap 110 | 111 | # Visual Studio Trace Files 112 | *.e2e 113 | 114 | # TFS 2012 Local Workspace 115 | $tf/ 116 | 117 | # Guidance Automation Toolkit 118 | *.gpState 119 | 120 | # ReSharper is a .NET coding add-in 121 | _ReSharper*/ 122 | *.[Rr]e[Ss]harper 123 | *.DotSettings.user 124 | 125 | # JustCode is a .NET coding add-in 126 | .JustCode 127 | 128 | # TeamCity is a build add-in 129 | _TeamCity* 130 | 131 | # DotCover is a Code Coverage Tool 132 | *.dotCover 133 | 134 | # AxoCover is a Code Coverage Tool 135 | .axoCover/* 136 | !.axoCover/settings.json 137 | 138 | # Visual Studio code coverage results 139 | *.coverage 140 | *.coveragexml 141 | 142 | # NCrunch 143 | _NCrunch_* 144 | .*crunch*.local.xml 145 | nCrunchTemp_* 146 | 147 | # MightyMoose 148 | *.mm.* 149 | AutoTest.Net/ 150 | 151 | # Web workbench (sass) 152 | .sass-cache/ 153 | 154 | # Installshield output folder 155 | [Ee]xpress/ 156 | 157 | # DocProject is a documentation generator add-in 158 | DocProject/buildhelp/ 159 | DocProject/Help/*.HxT 160 | DocProject/Help/*.HxC 161 | DocProject/Help/*.hhc 162 | DocProject/Help/*.hhk 163 | DocProject/Help/*.hhp 164 | DocProject/Help/Html2 165 | DocProject/Help/html 166 | 167 | # Click-Once directory 168 | publish/ 169 | 170 | # Publish Web Output 171 | *.[Pp]ublish.xml 172 | *.azurePubxml 173 | # Note: Comment the next line if you want to checkin your web deploy settings, 174 | # but database connection strings (with potential passwords) will be unencrypted 175 | *.pubxml 176 | *.publishproj 177 | 178 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 179 | # checkin your Azure Web App publish settings, but sensitive information contained 180 | # in these scripts will be unencrypted 181 | PublishScripts/ 182 | 183 | # NuGet Packages 184 | *.nupkg 185 | # The packages folder can be ignored because of Package Restore 186 | **/[Pp]ackages/* 187 | # except build/, which is used as an MSBuild target. 188 | !**/[Pp]ackages/build/ 189 | # Uncomment if necessary however generally it will be regenerated when needed 190 | #!**/[Pp]ackages/repositories.config 191 | # NuGet v3's project.json files produces more ignorable files 192 | *.nuget.props 193 | *.nuget.targets 194 | 195 | # Microsoft Azure Build Output 196 | csx/ 197 | *.build.csdef 198 | 199 | # Microsoft Azure Emulator 200 | ecf/ 201 | rcf/ 202 | 203 | # Windows Store app package directories and files 204 | AppPackages/ 205 | BundleArtifacts/ 206 | Package.StoreAssociation.xml 207 | _pkginfo.txt 208 | *.appx 209 | 210 | # Visual Studio cache files 211 | # files ending in .cache can be ignored 212 | *.[Cc]ache 213 | # but keep track of directories ending in .cache 214 | !?*.[Cc]ache/ 215 | 216 | # Others 217 | ClientBin/ 218 | ~$* 219 | *~ 220 | *.dbmdl 221 | *.dbproj.schemaview 222 | *.jfm 223 | *.pfx 224 | *.publishsettings 225 | orleans.codegen.cs 226 | 227 | # Including strong name files can present a security risk 228 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 229 | #*.snk 230 | 231 | # Since there are multiple workflows, uncomment next line to ignore bower_components 232 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 233 | #bower_components/ 234 | 235 | # RIA/Silverlight projects 236 | Generated_Code/ 237 | 238 | # Backup & report files from converting an old project file 239 | # to a newer Visual Studio version. Backup files are not needed, 240 | # because we have git ;-) 241 | _UpgradeReport_Files/ 242 | Backup*/ 243 | UpgradeLog*.XML 244 | UpgradeLog*.htm 245 | ServiceFabricBackup/ 246 | *.rptproj.bak 247 | 248 | # SQL Server files 249 | *.mdf 250 | *.ldf 251 | *.ndf 252 | 253 | # Business Intelligence projects 254 | *.rdl.data 255 | *.bim.layout 256 | *.bim_*.settings 257 | *.rptproj.rsuser 258 | *- Backup*.rdl 259 | 260 | # Microsoft Fakes 261 | FakesAssemblies/ 262 | 263 | # GhostDoc plugin setting file 264 | *.GhostDoc.xml 265 | 266 | # Node.js Tools for Visual Studio 267 | .ntvs_analysis.dat 268 | node_modules/ 269 | 270 | # Visual Studio 6 build log 271 | *.plg 272 | 273 | # Visual Studio 6 workspace options file 274 | *.opt 275 | 276 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 277 | *.vbw 278 | 279 | # Visual Studio LightSwitch build output 280 | **/*.HTMLClient/GeneratedArtifacts 281 | **/*.DesktopClient/GeneratedArtifacts 282 | **/*.DesktopClient/ModelManifest.xml 283 | **/*.Server/GeneratedArtifacts 284 | **/*.Server/ModelManifest.xml 285 | _Pvt_Extensions 286 | 287 | # Paket dependency manager 288 | .paket/paket.exe 289 | paket-files/ 290 | 291 | # FAKE - F# Make 292 | .fake/ 293 | 294 | # JetBrains Rider 295 | .idea/ 296 | *.sln.iml 297 | 298 | # CodeRush personal settings 299 | .cr/personal 300 | 301 | # Python Tools for Visual Studio (PTVS) 302 | __pycache__/ 303 | *.pyc 304 | 305 | # Cake - Uncomment if you are using it 306 | # tools/** 307 | # !tools/packages.config 308 | 309 | # Tabs Studio 310 | *.tss 311 | 312 | # Telerik's JustMock configuration file 313 | *.jmconfig 314 | 315 | # BizTalk build output 316 | *.btp.cs 317 | *.btm.cs 318 | *.odx.cs 319 | *.xsd.cs 320 | 321 | # OpenCover UI analysis results 322 | OpenCover/ 323 | 324 | # Azure Stream Analytics local run output 325 | ASALocalRun/ 326 | 327 | # MSBuild Binary and Structured Log 328 | *.binlog 329 | 330 | # NVidia Nsight GPU debugger configuration file 331 | *.nvuser 332 | 333 | # MFractors (Xamarin productivity tool) working folder 334 | .mfractor/ 335 | 336 | # Local History for Visual Studio 337 | .localhistory/ 338 | 339 | # BeatPulse healthcheck temp database 340 | healthchecksdb 341 | 342 | /GenerateAspNetCoreClient/Properties/debug-paths.txt 343 | 344 | *.received.* -------------------------------------------------------------------------------- /DotNet.Cli.Build/DotNet.Cli.Build.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /DotNet.Cli.Build/Exe.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Text; 5 | 6 | namespace DotNet.Cli.Build 7 | { 8 | internal static class Exe 9 | { 10 | public static int Run( 11 | string executable, 12 | IReadOnlyList args, 13 | string workingDirectory = null, 14 | bool interceptOutput = false) 15 | { 16 | var arguments = ToArguments(args); 17 | 18 | Console.WriteLine(executable + " " + arguments); 19 | 20 | var startInfo = new ProcessStartInfo 21 | { 22 | FileName = executable, 23 | Arguments = arguments, 24 | UseShellExecute = false, 25 | RedirectStandardOutput = interceptOutput 26 | }; 27 | if (workingDirectory != null) 28 | { 29 | startInfo.WorkingDirectory = workingDirectory; 30 | } 31 | 32 | var process = Process.Start(startInfo); 33 | 34 | if (interceptOutput) 35 | { 36 | string line; 37 | while ((line = process.StandardOutput.ReadLine()) != null) 38 | { 39 | Console.WriteLine(line); 40 | } 41 | } 42 | 43 | process.WaitForExit(); 44 | 45 | return process.ExitCode; 46 | } 47 | 48 | private static string ToArguments(IReadOnlyList args) 49 | { 50 | var builder = new StringBuilder(); 51 | for (var i = 0; i < args.Count; i++) 52 | { 53 | if (i != 0) 54 | { 55 | builder.Append(" "); 56 | } 57 | 58 | if (args[i].IndexOf(' ') == -1) 59 | { 60 | builder.Append(args[i]); 61 | 62 | continue; 63 | } 64 | 65 | builder.Append("\""); 66 | 67 | var pendingBackslashes = 0; 68 | for (var j = 0; j < args[i].Length; j++) 69 | { 70 | switch (args[i][j]) 71 | { 72 | case '\"': 73 | if (pendingBackslashes != 0) 74 | { 75 | builder.Append('\\', pendingBackslashes * 2); 76 | pendingBackslashes = 0; 77 | } 78 | 79 | builder.Append("\\\""); 80 | break; 81 | 82 | case '\\': 83 | pendingBackslashes++; 84 | break; 85 | 86 | default: 87 | if (pendingBackslashes != 0) 88 | { 89 | if (pendingBackslashes == 1) 90 | { 91 | builder.Append("\\"); 92 | } 93 | else 94 | { 95 | builder.Append('\\', pendingBackslashes * 2); 96 | } 97 | 98 | pendingBackslashes = 0; 99 | } 100 | 101 | builder.Append(args[i][j]); 102 | break; 103 | } 104 | } 105 | 106 | if (pendingBackslashes != 0) 107 | { 108 | builder.Append('\\', pendingBackslashes * 2); 109 | } 110 | 111 | builder.Append("\""); 112 | } 113 | 114 | return builder.ToString(); 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /DotNet.Cli.Build/Project.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | 6 | namespace DotNet.Cli.Build 7 | { 8 | public class Project 9 | { 10 | private readonly string _file; 11 | private readonly string _framework; 12 | private readonly string _configuration; 13 | private readonly string _runtime; 14 | 15 | private Project(string file, string framework, string configuration, string runtime) 16 | { 17 | _file = file; 18 | _framework = framework; 19 | _configuration = configuration; 20 | _runtime = runtime; 21 | 22 | ProjectName = Path.GetFileName(file); 23 | } 24 | 25 | public string ProjectName { get; } 26 | 27 | public string AssemblyName { get; set; } 28 | public string Language { get; set; } 29 | public string OutputPath { get; set; } 30 | public string PublishDir { get; set; } 31 | public string PlatformTarget { get; set; } 32 | public string ProjectAssetsFile { get; set; } 33 | public string ProjectDir { get; set; } 34 | public string RootNamespace { get; set; } 35 | public string RuntimeFrameworkVersion { get; set; } 36 | public string TargetFileName { get; set; } 37 | public string TargetFrameworkMoniker { get; set; } 38 | 39 | public string PublishFilePath => Path.Combine(ProjectDir, PublishDir, TargetFileName); 40 | public string OutputFilePath => Path.Combine(ProjectDir, OutputPath, TargetFileName); 41 | 42 | public static Project FromPath( 43 | string path, 44 | string buildExtensionsDir = null, 45 | string framework = null, 46 | string configuration = null, 47 | string runtime = null) 48 | { 49 | var file = GetProjectFilePath(path); 50 | 51 | if (buildExtensionsDir == null) 52 | { 53 | buildExtensionsDir = Path.Combine(Path.GetDirectoryName(file), "obj"); 54 | } 55 | 56 | Directory.CreateDirectory(buildExtensionsDir); 57 | 58 | var dotnetCliTargetsPath = Path.Combine( 59 | buildExtensionsDir, 60 | Path.GetFileName(file) + ".DotNetCliBuild.targets"); 61 | using (var input = typeof(Project).Assembly.GetManifestResourceStream( 62 | "DotNet.Cli.Build.Resources.DotNetCliBuild.targets")) 63 | 64 | using (var output = File.OpenWrite(dotnetCliTargetsPath)) 65 | { 66 | input.CopyTo(output); 67 | } 68 | 69 | IDictionary metadata; 70 | var metadataFile = Path.GetTempFileName(); 71 | try 72 | { 73 | var propertyArg = "/property:DotNetCliBuildMetadataFile=" + metadataFile; 74 | if (framework != null) 75 | { 76 | propertyArg += ";TargetFramework=" + framework; 77 | } 78 | 79 | if (configuration != null) 80 | { 81 | propertyArg += ";Configuration=" + configuration; 82 | } 83 | 84 | if (runtime != null) 85 | { 86 | propertyArg += ";RuntimeIdentifier=" + runtime; 87 | } 88 | 89 | var args = new List 90 | { 91 | "msbuild", 92 | "/target:__GetProjectMetadata", 93 | propertyArg, 94 | "/verbosity:quiet", 95 | "/nologo" 96 | }; 97 | 98 | if (file != null) 99 | { 100 | args.Add(file); 101 | } 102 | 103 | var exitCode = Exe.Run("dotnet", args); 104 | if (exitCode != 0) 105 | { 106 | throw new Exception("Unable to retrieve project metadata."); 107 | } 108 | 109 | metadata = File.ReadLines(metadataFile) 110 | .Select(l => l.Split(new[] { ':' }, 2)) 111 | .ToDictionary(s => s[0], s => s[1].TrimStart()); 112 | } 113 | finally 114 | { 115 | File.Delete(metadataFile); 116 | } 117 | 118 | var platformTarget = metadata["PlatformTarget"]; 119 | if (platformTarget.Length == 0) 120 | { 121 | platformTarget = metadata["Platform"]; 122 | } 123 | 124 | return new Project(file, framework, configuration, runtime) 125 | { 126 | AssemblyName = metadata["AssemblyName"], 127 | Language = metadata["Language"], 128 | OutputPath = metadata["OutputPath"], 129 | PublishDir = metadata["PublishDir"], 130 | PlatformTarget = platformTarget, 131 | ProjectAssetsFile = metadata["ProjectAssetsFile"], 132 | ProjectDir = metadata["ProjectDir"], 133 | RootNamespace = metadata["RootNamespace"], 134 | RuntimeFrameworkVersion = metadata["RuntimeFrameworkVersion"], 135 | TargetFileName = metadata["TargetFileName"], 136 | TargetFrameworkMoniker = metadata["TargetFrameworkMoniker"] 137 | }; 138 | } 139 | 140 | public void Build() 141 | { 142 | var args = new List { "build" }; 143 | 144 | if (_file != null) 145 | { 146 | args.Add(_file); 147 | } 148 | 149 | if (_framework != null) 150 | { 151 | args.Add("--framework"); 152 | args.Add(_framework); 153 | } 154 | 155 | if (_configuration != null) 156 | { 157 | args.Add("--configuration"); 158 | args.Add(_configuration); 159 | } 160 | 161 | if (_runtime != null) 162 | { 163 | args.Add("--runtime"); 164 | args.Add(_runtime); 165 | } 166 | 167 | args.Add("/verbosity:quiet"); 168 | args.Add("/nologo"); 169 | 170 | var exitCode = Exe.Run("dotnet", args, interceptOutput: true); 171 | if (exitCode != 0) 172 | { 173 | throw new Exception("BuildFailed"); 174 | } 175 | } 176 | 177 | public void Publish() 178 | { 179 | var args = new List { "publish" }; 180 | 181 | if (_file != null) 182 | { 183 | args.Add(_file); 184 | } 185 | 186 | if (_framework != null) 187 | { 188 | args.Add("--framework"); 189 | args.Add(_framework); 190 | } 191 | 192 | if (_configuration != null) 193 | { 194 | args.Add("--configuration"); 195 | args.Add(_configuration); 196 | } 197 | 198 | if (_runtime != null) 199 | { 200 | args.Add("--runtime"); 201 | args.Add(_runtime); 202 | } 203 | 204 | args.Add("/verbosity:quiet"); 205 | args.Add("/nologo"); 206 | 207 | var exitCode = Exe.Run("dotnet", args, interceptOutput: true); 208 | if (exitCode != 0) 209 | { 210 | throw new Exception("PublishFailed"); 211 | } 212 | } 213 | 214 | private static string GetProjectFilePath(string path) 215 | { 216 | if (string.IsNullOrEmpty(path)) 217 | throw new ArgumentNullException(nameof(path)); 218 | 219 | if (File.Exists(path)) 220 | { 221 | // If path is file - return as is 222 | return path; 223 | } 224 | else if (Directory.Exists(path)) 225 | { 226 | // If path is directory - try finding csproj file 227 | var csprojFiles = Directory.GetFiles(path, "*.csproj"); 228 | 229 | if (csprojFiles.Length == 0) 230 | throw new ArgumentException("No project files found"); 231 | 232 | if (csprojFiles.Length > 1) 233 | throw new ArgumentException("Multiple project files found"); 234 | 235 | return csprojFiles[0]; 236 | } 237 | else 238 | { 239 | throw new ArgumentException("Specified path does not exist"); 240 | } 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /DotNet.Cli.Build/Resources/DotNetCliBuild.targets: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/ClientModelBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Reflection; 7 | using System.Threading; 8 | using GenerateAspNetCoreClient.Command.Extensions; 9 | using GenerateAspNetCoreClient.Command.Model; 10 | using GenerateAspNetCoreClient.Options; 11 | using Microsoft.AspNetCore.Http; 12 | using Microsoft.AspNetCore.Mvc; 13 | using Microsoft.AspNetCore.Mvc.ApiExplorer; 14 | using Microsoft.AspNetCore.Mvc.Controllers; 15 | using Microsoft.AspNetCore.Mvc.ModelBinding; 16 | using Microsoft.AspNetCore.Routing; 17 | using Namotion.Reflection; 18 | 19 | namespace GenerateAspNetCoreClient.Command 20 | { 21 | public class ClientModelBuilder 22 | { 23 | private readonly ApiDescriptionGroupCollection apiExplorer; 24 | private readonly GenerateClientOptions options; 25 | private readonly Assembly webProjectAssembly; 26 | 27 | public ClientModelBuilder( 28 | ApiDescriptionGroupCollection apiExplorer, 29 | GenerateClientOptions options, 30 | Assembly webProjectAssembly) 31 | { 32 | this.apiExplorer = apiExplorer; 33 | this.options = options; 34 | this.webProjectAssembly = webProjectAssembly; 35 | } 36 | 37 | public List GetClientCollection() 38 | { 39 | var apiDescriptions = apiExplorer.Items 40 | .SelectMany(i => i.Items) 41 | .ToList(); 42 | 43 | FilterDescriptions(apiDescriptions); 44 | 45 | var assemblyName = webProjectAssembly.GetName().Name; 46 | var apiGroupsDescriptions = apiDescriptions.GroupBy(i => GroupInfo.From(i, assemblyName)); 47 | 48 | string commonControllerNamespacePart = GetCommonNamespacesPart(apiGroupsDescriptions); 49 | 50 | var clients = apiGroupsDescriptions.Select(apis => 51 | GetClientModel( 52 | commonControllerNamespace: commonControllerNamespacePart, 53 | controllerInfo: apis.Key, 54 | apiDescriptions: apis.ToList()) 55 | ).ToList(); 56 | 57 | return clients; 58 | } 59 | 60 | private void FilterDescriptions(List apiDescriptions) 61 | { 62 | if (!string.IsNullOrEmpty(options.ExcludeTypes)) 63 | { 64 | apiDescriptions.RemoveAll(api => api.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor 65 | && controllerActionDescriptor.ControllerTypeInfo.FullName?.Contains(options.ExcludeTypes) == true); 66 | } 67 | 68 | if (!string.IsNullOrEmpty(options.IncludeTypes)) 69 | { 70 | apiDescriptions.RemoveAll(api => api.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor 71 | && controllerActionDescriptor.ControllerTypeInfo.FullName?.Contains(options.IncludeTypes) != true); 72 | } 73 | 74 | if (!string.IsNullOrEmpty(options.ExcludePaths)) 75 | { 76 | apiDescriptions.RemoveAll(api => ("/" + api.RelativePath).Contains(options.ExcludePaths)); 77 | } 78 | 79 | if (!string.IsNullOrEmpty(options.IncludePaths)) 80 | { 81 | apiDescriptions.RemoveAll(api => !("/" + api.RelativePath).Contains(options.IncludePaths)); 82 | } 83 | } 84 | 85 | internal Client GetClientModel( 86 | string commonControllerNamespace, 87 | GroupInfo controllerInfo, 88 | List apiDescriptions) 89 | { 90 | apiDescriptions = HandleDuplicates(apiDescriptions); 91 | 92 | var subPath = GetSubPath(controllerInfo, commonControllerNamespace); 93 | 94 | var groupNamePascalCase = controllerInfo.GroupName.ToPascalCase(); 95 | var name = options.TypeNamePattern 96 | .Replace("[controller]", groupNamePascalCase) 97 | .Replace("[group]", groupNamePascalCase); 98 | 99 | var clientNamespace = string.Join(".", new[] { options.Namespace }.Concat(subPath)); 100 | 101 | var methods = apiDescriptions.Select(GetEndpointMethod).ToList(); 102 | 103 | return new Client 104 | ( 105 | location: Path.Combine(subPath), 106 | @namespace: clientNamespace, 107 | accessModifier: options.AccessModifier, 108 | name: name, 109 | endpointMethods: methods 110 | ); 111 | } 112 | 113 | internal EndpointMethod GetEndpointMethod(ApiDescription apiDescription) 114 | { 115 | var responseType = GetResponseType(apiDescription); 116 | 117 | if (responseType == null) 118 | { 119 | Console.WriteLine($"Cannot find return type for " + apiDescription.ActionDescriptor.DisplayName); 120 | responseType = typeof(void); 121 | } 122 | 123 | return new EndpointMethod 124 | ( 125 | xmlDoc: GetXmlDoc(apiDescription), 126 | httpMethod: new HttpMethod(apiDescription.HttpMethod ?? HttpMethod.Get.Method), 127 | path: apiDescription.RelativePath?.TrimEnd('/') ?? "", 128 | responseType: responseType, 129 | name: GetActionName(apiDescription), 130 | parameters: GetParameters(apiDescription) 131 | ); 132 | } 133 | 134 | private string GetActionName(ApiDescription apiDescription) 135 | { 136 | if (apiDescription.ActionDescriptor.EndpointMetadata.OfType().FirstOrDefault()?.RouteName is string routeName) 137 | return routeName; 138 | 139 | if (apiDescription.ActionDescriptor is ControllerActionDescriptor { ActionName: string actionName }) 140 | return actionName; 141 | 142 | var method = apiDescription.HttpMethod ?? "GET"; 143 | return (method + " " + apiDescription.RelativePath).ToPascalCase(); 144 | } 145 | 146 | private List GetParameters(ApiDescription apiDescription) 147 | { 148 | var parametersList = new List(); 149 | 150 | for (int i = 0; i < apiDescription.ParameterDescriptions.Count; i++) 151 | { 152 | var parameterDescription = apiDescription.ParameterDescriptions[i]; 153 | var paramType = parameterDescription.ParameterDescriptor?.ParameterType; 154 | 155 | if (paramType == typeof(CancellationToken)) 156 | continue; 157 | 158 | // IFormFile 159 | if (paramType == typeof(IFormFile)) 160 | { 161 | var name = parameterDescription.ParameterDescriptor.Name; 162 | 163 | parametersList.Add(new Parameter( 164 | source: ParameterSource.File, 165 | type: typeof(Stream), 166 | name: parameterDescription.Name, 167 | parameterName: name.ToCamelCase(), 168 | defaultValueLiteral: null)); 169 | 170 | // Skip parameters that correspond to same file 171 | while (i + 1 < apiDescription.ParameterDescriptions.Count 172 | && apiDescription.ParameterDescriptions[i + 1].ParameterDescriptor?.ParameterType == typeof(IFormFile) 173 | && apiDescription.ParameterDescriptions[i + 1].ParameterDescriptor?.Name == name) 174 | { 175 | i++; 176 | } 177 | 178 | continue; 179 | } 180 | 181 | // IEnumerable 182 | if (paramType != null) 183 | { 184 | if (typeof(IEnumerable).IsAssignableFrom(paramType)) 185 | { 186 | var name = parameterDescription.ParameterDescriptor?.Name; 187 | 188 | parametersList.Add(new Parameter( 189 | source: ParameterSource.File, 190 | type: typeof(List), 191 | name: parameterDescription.Name, 192 | parameterName: name?.ToCamelCase() ?? "files", 193 | defaultValueLiteral: null)); 194 | 195 | continue; 196 | } 197 | } 198 | 199 | // Form 200 | // API explorer shows form as separate parameters. We want to have single model parameter. 201 | if (parameterDescription.Source == BindingSource.Form) 202 | { 203 | var name = parameterDescription.ParameterDescriptor?.Name ?? "form"; 204 | var formType = paramType ?? typeof(object); 205 | 206 | if (formType == typeof(IFormCollection)) 207 | { 208 | parametersList.Add(new Parameter( 209 | source: ParameterSource.Form, 210 | type: typeof(Dictionary), 211 | name: parameterDescription.Name, 212 | parameterName: name.ToCamelCase(), 213 | defaultValueLiteral: null)); 214 | 215 | continue; 216 | } 217 | else 218 | { 219 | var sameFormParameters = apiDescription.ParameterDescriptions.Skip(i - 1) 220 | .TakeWhile(d => d.ParameterDescriptor?.ParameterType == formType && d.ParameterDescriptor?.Name == name) 221 | .ToArray(); 222 | 223 | // If form model has file parameters - we have to put it as separate parameters. 224 | if (sameFormParameters.All(p => p.Source.Id != "FormFile")) 225 | { 226 | parametersList.Add(new Parameter( 227 | source: ParameterSource.Form, 228 | type: formType, 229 | name: parameterDescription.Name, 230 | parameterName: name.ToCamelCase(), 231 | defaultValueLiteral: "null")); 232 | 233 | if (sameFormParameters.Length > 0) 234 | i += sameFormParameters.Length - 1; 235 | 236 | continue; 237 | } 238 | } 239 | } 240 | 241 | if (options.UseQueryModels 242 | && parameterDescription.Source == BindingSource.Query 243 | && parameterDescription.ModelMetadata?.ContainerType != null 244 | && parameterDescription.ParameterDescriptor != null) 245 | { 246 | var name = parameterDescription.ParameterDescriptor.Name; 247 | var containerType = parameterDescription.ModelMetadata.ContainerType; 248 | 249 | parametersList.Add(new Parameter( 250 | source: ParameterSource.Query, 251 | type: containerType, 252 | name: parameterDescription.Name, 253 | parameterName: name.ToCamelCase(), 254 | defaultValueLiteral: "null")); 255 | 256 | // Skip parameters that correspond to same query model 257 | while (i + 1 < apiDescription.ParameterDescriptions.Count 258 | && apiDescription.ParameterDescriptions[i + 1].ModelMetadata?.ContainerType == containerType 259 | && apiDescription.ParameterDescriptions[i + 1].ParameterDescriptor?.Name == name) 260 | { 261 | i++; 262 | } 263 | 264 | continue; 265 | } 266 | 267 | if (parametersList.Any(p => p.Name.Equals(parameterDescription.Name, StringComparison.OrdinalIgnoreCase))) 268 | { 269 | Console.WriteLine($"Duplicate parameter '{parameterDescription.Name}' for '{apiDescription.ActionDescriptor.DisplayName}'"); 270 | continue; 271 | } 272 | 273 | var source = parameterDescription.Source.Id switch 274 | { 275 | "Body" => ParameterSource.Body, 276 | "Path" => ParameterSource.Path, 277 | "FormFile" => ParameterSource.File, 278 | "Query" => ParameterSource.Query, 279 | "Header" => ParameterSource.Header, 280 | "Form" => ParameterSource.Form, 281 | _ => ParameterSource.Query 282 | }; 283 | 284 | // Is it possible to have other static values, apart from headers? 285 | var isStaticValue = parameterDescription.Source == BindingSource.Header && parameterDescription.BindingInfo is null; 286 | 287 | var isQueryModel = source is ParameterSource.Query or ParameterSource.Form 288 | && parameterDescription.Type != parameterDescription.ParameterDescriptor?.ParameterType; 289 | 290 | // If query model - use parameterDescription.Name, as ParameterDescriptor.Name is name for the whole model, 291 | // not separate parameters. 292 | var parameterName = isQueryModel 293 | ? parameterDescription.Name.ToCamelCase() 294 | : (parameterDescription.ParameterDescriptor?.Name ?? parameterDescription.Name).ToCamelCase(); 295 | 296 | parameterName = new string(parameterName.Where(char.IsLetterOrDigit).ToArray()); 297 | 298 | Type type; 299 | if (parameterDescription.Source == BindingSource.FormFile) 300 | { 301 | type = typeof(IEnumerable).IsAssignableFrom(parameterDescription.Type) 302 | ? typeof(List) 303 | : typeof(Stream); 304 | } 305 | else 306 | { 307 | type = parameterDescription.ModelMetadata?.ModelType ?? parameterDescription.Type; 308 | } 309 | 310 | var defaultValue = GetDefaultValueLiteral(parameterDescription, type); 311 | 312 | if (defaultValue == "null") 313 | { 314 | type = type.ToNullable(); 315 | } 316 | 317 | parametersList.Add(new Parameter( 318 | source: source, 319 | type: type, 320 | name: parameterDescription.Name, 321 | parameterName: parameterName, 322 | defaultValueLiteral: defaultValue, 323 | isStaticValue: isStaticValue)); 324 | } 325 | 326 | if (options.AddCancellationTokenParameters) 327 | { 328 | parametersList.Add(new Parameter( 329 | source: ParameterSource.Other, 330 | type: typeof(CancellationToken), 331 | name: "cancellationToken", 332 | parameterName: "cancellationToken", 333 | defaultValueLiteral: "default")); 334 | } 335 | 336 | return parametersList; 337 | } 338 | 339 | private static string? GetXmlDoc(ApiDescription apiDescription) 340 | { 341 | var xmlElement = (apiDescription.ActionDescriptor as ControllerActionDescriptor)?.MethodInfo.GetXmlDocsElement(); 342 | 343 | if (xmlElement == null) 344 | return null; 345 | 346 | var xmlLines = xmlElement.Elements() 347 | .Select(e => e.ToString()) 348 | .SelectMany(s => s.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries)) 349 | .Select(line => line.Trim().Replace("cref=\"T:", "cref=\"")); 350 | 351 | var xmlDoc = string.Join(Environment.NewLine, xmlLines).Indent("/// "); 352 | 353 | return xmlDoc; 354 | } 355 | 356 | private static Type? GetResponseType(ApiDescription apiDescription) 357 | { 358 | var responseType = apiDescription.SupportedResponseTypes 359 | .OrderBy(r => r.StatusCode) 360 | .FirstOrDefault(r => r.StatusCode >= 200 && r.StatusCode < 300) 361 | ?.Type; 362 | 363 | if (responseType is null) 364 | { 365 | // Workaround for bug https://github.com/dotnet/aspnetcore/issues/30465 366 | var methodInfo = (apiDescription.ActionDescriptor as ControllerActionDescriptor)?.MethodInfo; 367 | var methodResponseType = methodInfo?.ReturnType?.UnwrapTask(); 368 | 369 | if (methodResponseType?.IsAssignableTo(typeof(FileResult)) == true) 370 | { 371 | responseType = typeof(Stream); 372 | } 373 | } 374 | 375 | return responseType; 376 | } 377 | 378 | private static string GetCommonNamespacesPart(IEnumerable> controllerApiDescriptions) 379 | { 380 | var namespaces = controllerApiDescriptions 381 | .Select(c => c.Key) 382 | .Select(c => c.Namespace ?? ""); 383 | 384 | return namespaces.GetCommonPart("."); 385 | } 386 | 387 | private static string? GetDefaultValueLiteral(ApiParameterDescription parameter, Type parameterType) 388 | { 389 | var defaultValue = parameter.DefaultValue; 390 | 391 | if (defaultValue != null && defaultValue is not DBNull) 392 | { 393 | // If defaultValue is not null - return it. 394 | return defaultValue.ToLiteral(); 395 | } 396 | 397 | var isRequired = parameter.IsRequired; 398 | isRequired |= parameter.ModelMetadata?.IsBindingRequired == true; 399 | 400 | if (!parameterType.IsValueType || parameterType.IsNullable()) 401 | { 402 | isRequired |= parameter.ModelMetadata?.IsRequired == true; 403 | } 404 | 405 | if (isRequired == false) 406 | { 407 | // If defaultValue is null, but value is not required - return it anyway. 408 | return "null"; 409 | } 410 | 411 | return null; 412 | } 413 | 414 | private static string[] GetSubPath(GroupInfo controllerActionDescriptor, string commonNamespace) 415 | { 416 | return (controllerActionDescriptor.Namespace ?? "") 417 | .Substring(commonNamespace.Length) 418 | .Split(".") 419 | .Select(nsPart => nsPart.Replace("Controllers", "")) 420 | .Where(nsPart => nsPart != "") 421 | .ToArray(); 422 | } 423 | 424 | private static List HandleDuplicates(List apiDescriptions) 425 | { 426 | var conflictingApisGroups = apiDescriptions 427 | .Where(api => api.ActionDescriptor is ControllerActionDescriptor) 428 | .GroupBy(api => ((ControllerActionDescriptor)api.ActionDescriptor).ActionName 429 | + string.Concat(api.ParameterDescriptions.Select(p => p.Type?.FullName ?? "-"))) 430 | .Where(g => g.Count() > 1); 431 | 432 | foreach (var conflictingApis in conflictingApisGroups) 433 | { 434 | // Take suffixes from path 435 | var commonPathPart = conflictingApis.Select(api => api.RelativePath ?? "").GetCommonPart("/"); 436 | 437 | foreach (var api in conflictingApis) 438 | { 439 | var suffix = api.RelativePath is null || api.RelativePath == commonPathPart 440 | ? "" 441 | : api.RelativePath[(commonPathPart.Length + 1)..].ToPascalCase(); 442 | 443 | ((ControllerActionDescriptor)api.ActionDescriptor).ActionName += suffix; 444 | } 445 | } 446 | 447 | return apiDescriptions; 448 | } 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/Extensions/ObjectExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace GenerateAspNetCoreClient.Command.Extensions 4 | { 5 | internal static class ObjectExtensions 6 | { 7 | public static string ToLiteral(this object? obj) 8 | { 9 | if (obj == null) 10 | return "null"; 11 | 12 | var type = obj.GetType(); 13 | 14 | return obj switch 15 | { 16 | string s => '"' + s + '"', 17 | char c => "'" + c + "'", 18 | bool b => b ? "true" : "false", 19 | _ when type.IsEnum && Enum.IsDefined(type, obj) => $"{type.Name}.{obj}", 20 | _ when type.IsPrimitive => obj.ToString() ?? "", 21 | _ => throw new NotSupportedException() 22 | }; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text.RegularExpressions; 5 | 6 | namespace GenerateAspNetCoreClient.Command.Extensions 7 | { 8 | internal static class StringExtensions 9 | { 10 | private static readonly Regex NonAlphaNumericRegex = new Regex(@"[^a-zA-Z0-9]+"); 11 | 12 | public static string ToCamelCase(this string @this) 13 | { 14 | if (@this.Length == 0) 15 | return @this; 16 | 17 | return char.ToLowerInvariant(@this[0]) + @this.Substring(1); 18 | } 19 | 20 | public static string ToPascalCase(this string @this) 21 | { 22 | if (@this.Length == 0) 23 | return @this; 24 | 25 | var parts = NonAlphaNumericRegex 26 | .Split(@this) 27 | .Select(PartToPascalCase); 28 | 29 | return string.Concat(parts); 30 | 31 | static string PartToPascalCase(string part) 32 | { 33 | if (part.Length == 0) 34 | return part; 35 | 36 | if (part.ToUpperInvariant() == part) 37 | { 38 | // If part is all uppercase - convert to lowercase all apart from first letter. 39 | return part[0] + part.Substring(1).ToLowerInvariant(); 40 | } 41 | else 42 | { 43 | // Otherwise - convert first letter to uppercase, everything else - as is. 44 | return char.ToUpperInvariant(part[0]) + part.Substring(1); 45 | } 46 | } 47 | } 48 | 49 | public static string Indent(this string @this, string indent) 50 | { 51 | var lines = @this.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); 52 | var indentedLines = lines.Select(line => line.Length == 0 ? line : indent + line); 53 | return string.Join(Environment.NewLine, indentedLines); 54 | } 55 | 56 | public static string GetCommonPart(this IEnumerable @this, string partSeparator) 57 | { 58 | string[][] partsArrays = @this 59 | .Select(ns => ns.Split(partSeparator)) 60 | .ToArray(); 61 | 62 | if (partsArrays.Length == 0) 63 | return ""; 64 | 65 | var anyParts = partsArrays[0]; 66 | var commonParts = anyParts.TakeWhile((part, i) => partsArrays.All(ns => ns.Length > i && ns[i] == part)); 67 | 68 | return string.Join(partSeparator, commonParts); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/Extensions/TypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace GenerateAspNetCoreClient.Command.Extensions 7 | { 8 | internal static class TypeExtensions 9 | { 10 | private static readonly Dictionary DefaultTypes = new Dictionary 11 | { 12 | [typeof(void)] = "void", 13 | [typeof(object)] = "object", 14 | [typeof(sbyte)] = "sbyte", 15 | [typeof(byte)] = "byte", 16 | [typeof(short)] = "short", 17 | [typeof(ushort)] = "ushort", 18 | [typeof(int)] = "int", 19 | [typeof(uint)] = "uint", 20 | [typeof(long)] = "long", 21 | [typeof(ulong)] = "ulong", 22 | [typeof(decimal)] = "decimal", 23 | [typeof(float)] = "float", 24 | [typeof(double)] = "double", 25 | [typeof(bool)] = "bool", 26 | [typeof(char)] = "char", 27 | [typeof(string)] = "string" 28 | }; 29 | 30 | public static bool IsBuiltInType(this Type @this) 31 | { 32 | if (DefaultTypes.ContainsKey(@this)) 33 | return true; 34 | 35 | if (@this.IsGenericType 36 | && @this.GetGenericTypeDefinition() == typeof(Nullable<>) 37 | && DefaultTypes.ContainsKey(@this.GetGenericArguments()[0])) 38 | { 39 | return true; 40 | } 41 | 42 | if (@this.IsArray && DefaultTypes.ContainsKey(@this.GetElementType()!)) 43 | return true; 44 | 45 | return false; 46 | } 47 | 48 | public static string GetName(this Type @this, HashSet ambiguousTypes) 49 | { 50 | if (@this.IsGenericType && @this.GetGenericTypeDefinition() == typeof(Nullable<>)) 51 | { 52 | var underlyingType = @this.GenericTypeArguments[0]; 53 | return underlyingType.GetName(ambiguousTypes) + "?"; 54 | } 55 | 56 | if (@this.IsArray) 57 | { 58 | var elementType = @this.GetElementType(); 59 | 60 | if (elementType != null) 61 | { 62 | return elementType.GetName(ambiguousTypes) + "[]"; 63 | } 64 | } 65 | 66 | if (!DefaultTypes.TryGetValue(@this, out var name)) 67 | name = @this.Name; 68 | 69 | if (ambiguousTypes.Contains(@this)) 70 | { 71 | name = @this.Namespace + "." + name; 72 | } 73 | 74 | if (@this.IsConstructedGenericType) 75 | { 76 | name = name.Substring(0, name.LastIndexOf('`')); 77 | 78 | var genericNames = @this.GetGenericArguments().Select(a => a.GetName(ambiguousTypes)); 79 | 80 | name += $"<{string.Join(", ", genericNames)}>"; 81 | } 82 | 83 | return name; 84 | } 85 | 86 | public static Type WrapInTask(this Type @this) 87 | { 88 | if (@this == typeof(void)) 89 | return typeof(Task); 90 | 91 | return typeof(Task<>).MakeGenericType(@this); 92 | } 93 | 94 | public static Type UnwrapTask(this Type @this) 95 | { 96 | return @this.IsGenericType && @this.GetGenericTypeDefinition() == typeof(Task<>) 97 | ? @this.GetGenericArguments()[0] 98 | : @this; 99 | } 100 | 101 | public static Type ToNullable(this Type @this) 102 | { 103 | if (!@this.IsValueType) 104 | return @this; 105 | 106 | if (IsNullable(@this)) 107 | return @this; 108 | 109 | return typeof(Nullable<>).MakeGenericType(@this); 110 | } 111 | 112 | public static bool IsNullable(this Type @this) 113 | { 114 | return @this.IsGenericType && @this.GetGenericTypeDefinition() == typeof(Nullable<>); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/GenerateAspNetCoreClient.Command.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/GenerateClientCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Reflection; 7 | using GenerateAspNetCoreClient.Command.Extensions; 8 | using GenerateAspNetCoreClient.Command.Model; 9 | using GenerateAspNetCoreClient.Options; 10 | using Microsoft.AspNetCore.Mvc.ApiExplorer; 11 | using Microsoft.Extensions.DependencyInjection; 12 | 13 | namespace GenerateAspNetCoreClient.Command 14 | { 15 | public class GenerateClientCommand 16 | { 17 | public static void Invoke(Assembly assembly, GenerateClientOptions options) 18 | { 19 | options.AdditionalNamespaces = ["System.Threading.Tasks", "Refit"]; 20 | var apiExplorer = GetApiExplorer(assembly, options.Environment); 21 | var clientModelBuilder = new ClientModelBuilder(apiExplorer, options, assembly); 22 | var clientCollection = clientModelBuilder.GetClientCollection(); 23 | 24 | var ambiguousTypes = GetAmbiguousTypes(clientCollection); 25 | 26 | foreach (var clientModel in clientCollection) 27 | { 28 | var clientText = CreateClient(clientModel, ambiguousTypes, options); 29 | 30 | var path = Path.Combine(options.OutPath, clientModel.Location); 31 | Directory.CreateDirectory(path); 32 | 33 | File.WriteAllText(Path.Combine(path, $"{clientModel.Name}.cs"), clientText); 34 | } 35 | } 36 | 37 | private static ApiDescriptionGroupCollection GetApiExplorer(Assembly assembly, string? environment) 38 | { 39 | using var _ = new RunSettings(Path.GetDirectoryName(assembly.Location)!, environment); 40 | 41 | var services = ServiceProviderResolver.GetServiceProvider(assembly); 42 | var apiExplorerProvider = services.GetRequiredService(); 43 | 44 | return apiExplorerProvider.ApiDescriptionGroups; 45 | } 46 | 47 | private static string CreateClient(Client clientModel, HashSet ambiguousTypes, GenerateClientOptions options) 48 | { 49 | IEnumerable endpointMethods = clientModel.EndpointMethods; 50 | endpointMethods = HandleEndpointDuplicates(endpointMethods, ambiguousTypes); 51 | endpointMethods = HandleSignatureDuplicates(endpointMethods, ambiguousTypes); 52 | var namespaces = GetImportedNamespaces(clientModel, ambiguousTypes, options); 53 | 54 | var methodDescriptions = endpointMethods.Select(endpointMethod => 55 | { 56 | var xmlDoc = endpointMethod.XmlDoc; 57 | 58 | if (!string.IsNullOrEmpty(xmlDoc)) 59 | xmlDoc += Environment.NewLine; 60 | 61 | var multipartAttribute = endpointMethod.IsMultipart 62 | ? "[Multipart]" + Environment.NewLine 63 | : ""; 64 | 65 | var staticHeaders = endpointMethod.Parameters.Where(p => p.Source == ParameterSource.Header && p.IsConstant).ToArray(); 66 | var staticHeadersAttribute = staticHeaders.Length > 0 67 | ? $"[Headers({string.Join(", ", staticHeaders.Select(h => $"\"{h.Name}: {h.DefaultValueLiteral!.Trim('"')}\""))})]" + Environment.NewLine 68 | : ""; 69 | 70 | var parameterStrings = endpointMethod.Parameters 71 | .Except(staticHeaders) 72 | .OrderBy(p => p.DefaultValueLiteral != null) 73 | .Select(p => 74 | { 75 | var attribute = p.Source switch 76 | { 77 | ParameterSource.Body => "[Body] ", 78 | ParameterSource.Form when !endpointMethod.IsMultipart => "[Body(BodySerializationMethod.UrlEncoded)] ", 79 | ParameterSource.Header => $"[Header(\"{p.Name}\")] ", 80 | ParameterSource.Query => GetQueryAttribute(p), 81 | _ => "" 82 | }; 83 | 84 | string type; 85 | if (p.Source == ParameterSource.File) 86 | { 87 | bool isEnumerable = p.Type != typeof(string) && typeof(IEnumerable).IsAssignableFrom(p.Type); 88 | type = isEnumerable ? "List" : "MultipartItem"; 89 | } 90 | else 91 | { 92 | type = p.Type.GetName(ambiguousTypes); 93 | } 94 | 95 | var defaultValue = p.DefaultValueLiteral == null ? "" : " = " + p.DefaultValueLiteral; 96 | return $"{attribute}{type} {p.ParameterName}{defaultValue}"; 97 | }) 98 | .ToArray(); 99 | 100 | var httpMethodAttribute = endpointMethod.HttpMethod.ToString().ToPascalCase(); 101 | var methodPathAttribute = $@"[{httpMethodAttribute}(""/{endpointMethod.Path}"")]"; 102 | 103 | var responseTypeName = GetResponseTypeName(endpointMethod.ResponseType, ambiguousTypes, options); 104 | 105 | return 106 | $@"{xmlDoc}{multipartAttribute}{staticHeadersAttribute}{methodPathAttribute} 107 | {responseTypeName} {endpointMethod.Name}({string.Join(", ", parameterStrings)});"; 108 | }).ToArray(); 109 | 110 | return 111 | $@"// 112 | 113 | {string.Join(Environment.NewLine, namespaces.Select(n => $"using {n};"))} 114 | 115 | namespace {clientModel.Namespace} 116 | {{ 117 | {clientModel.AccessModifier} partial interface {clientModel.Name} 118 | {{ 119 | {string.Join(Environment.NewLine + Environment.NewLine, methodDescriptions).Indent(" ")} 120 | }} 121 | }}"; 122 | } 123 | 124 | private static IEnumerable GetImportedNamespaces(Client clientModel, HashSet ambiguousTypes, GenerateClientOptions options) 125 | { 126 | var namespaces = GetNamespaces(clientModel.EndpointMethods, ambiguousTypes) 127 | .Concat(options.AdditionalNamespaces); 128 | 129 | namespaces = namespaces 130 | .OrderByDescending(ns => ns.StartsWith("System")) 131 | .ThenBy(ns => ns); 132 | 133 | return namespaces; 134 | } 135 | 136 | private static string GetResponseTypeName(Type responseType, HashSet ambiguousTypes, GenerateClientOptions options) 137 | { 138 | if (options.UseApiResponses) 139 | { 140 | return responseType == typeof(void) 141 | ? "Task" 142 | : $"Task>"; 143 | } 144 | else 145 | { 146 | return responseType.WrapInTask().GetName(ambiguousTypes); 147 | } 148 | } 149 | 150 | private static IEnumerable HandleSignatureDuplicates(IEnumerable endpointMethods, HashSet ambiguousTypes) 151 | { 152 | var dictionary = new Dictionary(); 153 | 154 | foreach (var endpointMethod in endpointMethods) 155 | { 156 | var parameterTypes = endpointMethod.Parameters.Where(p => !p.IsConstant).Select(p => p.Type.GetName(ambiguousTypes)); 157 | var signatureDescription = $"{endpointMethod.Name}({string.Join(",", parameterTypes)})"; 158 | 159 | if (dictionary.ContainsKey(signatureDescription)) 160 | Console.WriteLine("Duplicate API method " + signatureDescription); 161 | 162 | dictionary[signatureDescription] = endpointMethod; 163 | } 164 | 165 | return dictionary.Values; 166 | } 167 | 168 | private static IEnumerable HandleEndpointDuplicates(IEnumerable endpointMethods, HashSet ambiguousTypes) 169 | { 170 | var dictionary = new Dictionary(); 171 | 172 | foreach (var endpointMethod in endpointMethods) 173 | { 174 | var parameterDescriptions = endpointMethod.Parameters.Select(p => $"{p.Source} {p.Type.GetName(ambiguousTypes)} {p.Name} {(p.IsConstant ? ": " + p.DefaultValueLiteral : "")}"); 175 | var endpointDescription = $"{endpointMethod.HttpMethod} {endpointMethod.Path} ({string.Join(", ", parameterDescriptions)})"; 176 | 177 | if (dictionary.ContainsKey(endpointDescription)) 178 | Console.WriteLine("Duplicate API endpoint " + endpointDescription); 179 | 180 | dictionary[endpointDescription] = endpointMethod; 181 | } 182 | 183 | return dictionary.Values; 184 | } 185 | 186 | private static string GetQueryAttribute(Parameter parameter) 187 | { 188 | bool isKeyValuePairs = parameter.Type != typeof(string) 189 | && !parameter.Type.IsAssignableTo(typeof(IDictionary)) 190 | && parameter.Type.IsAssignableTo(typeof(IEnumerable)); 191 | 192 | if (parameter.Type != typeof(string) && !parameter.Type.IsValueType && !isKeyValuePairs) 193 | return "[Query] "; 194 | 195 | if (!string.Equals(parameter.Name, parameter.ParameterName, StringComparison.OrdinalIgnoreCase)) 196 | return $"[AliasAs(\"{parameter.Name}\")] "; 197 | 198 | return ""; 199 | } 200 | 201 | private static HashSet GetAmbiguousTypes(IEnumerable clients) 202 | { 203 | var allNamespaces = GetNamespaces(clients.SelectMany(c => c.EndpointMethods)); 204 | 205 | return AppDomain.CurrentDomain.GetAssemblies() 206 | .Where(a => !a.IsDynamic) 207 | .SelectMany(a => 208 | { 209 | try 210 | { 211 | return a.ExportedTypes; 212 | } 213 | catch 214 | { 215 | return []; 216 | } 217 | }) 218 | .Where(t => t.DeclaringType == null && allNamespaces.Contains(t.Namespace!)) 219 | .GroupBy(t => t.Name) 220 | .Where(g => g.Select(t => t.Namespace).Distinct().Count() > 1) 221 | .SelectMany(g => g) 222 | .ToHashSet(); 223 | } 224 | 225 | private static HashSet GetNamespaces(IEnumerable apiDescriptions, HashSet? ambiguousTypes = null) 226 | { 227 | var namespaces = new HashSet(); 228 | 229 | foreach (var apiDescription in apiDescriptions) 230 | { 231 | AddForType(apiDescription.ResponseType); 232 | 233 | foreach (var parameterDescription in apiDescription.Parameters) 234 | { 235 | AddForType(parameterDescription.Type); 236 | } 237 | } 238 | 239 | return namespaces; 240 | 241 | void AddForType(Type? type) 242 | { 243 | if (type != null && !type.IsBuiltInType() && ambiguousTypes?.Contains(type) != true) 244 | { 245 | if (type.Namespace != null) 246 | namespaces.Add(type.Namespace); 247 | 248 | if (type.IsGenericType) 249 | { 250 | foreach (var typeArg in type.GetGenericArguments()) 251 | AddForType(typeArg); 252 | } 253 | } 254 | } 255 | } 256 | 257 | private class RunSettings : IDisposable 258 | { 259 | private readonly string? environment; 260 | private readonly string? originalEnvironment; 261 | private readonly string originalCurrentDirectory; 262 | private readonly string? originalBaseDirectory; 263 | 264 | public RunSettings(string location, string? environment) 265 | { 266 | this.environment = environment; 267 | 268 | originalEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); 269 | originalCurrentDirectory = Directory.GetCurrentDirectory(); 270 | originalBaseDirectory = AppContext.GetData("APP_CONTEXT_BASE_DIRECTORY") as string; 271 | 272 | if (environment != null) 273 | Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", environment); 274 | 275 | // Update AppContext.BaseDirectory and Directory.CurrentDirectory, since they are often used for json files paths. 276 | SetAppContextBaseDirectory(location); 277 | Directory.SetCurrentDirectory(location); 278 | } 279 | 280 | public void Dispose() 281 | { 282 | if (environment != null) 283 | Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment); 284 | 285 | SetAppContextBaseDirectory(originalBaseDirectory); 286 | Directory.SetCurrentDirectory(originalCurrentDirectory); 287 | } 288 | 289 | private static void SetAppContextBaseDirectory(string? path) 290 | { 291 | var setDataMethod = typeof(AppContext).GetMethod("SetData"); 292 | 293 | if (setDataMethod != null) 294 | setDataMethod.Invoke(null, new[] { "APP_CONTEXT_BASE_DIRECTORY", path }); 295 | } 296 | } 297 | } 298 | } -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/GroupInfo.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ApiExplorer; 2 | using Microsoft.AspNetCore.Mvc.Controllers; 3 | 4 | namespace GenerateAspNetCoreClient.Command 5 | { 6 | internal struct GroupInfo 7 | { 8 | public string GroupName { get; set; } 9 | public string? Namespace { get; set; } 10 | 11 | public static GroupInfo From(ApiDescription apiDescription, string? defaultGroupName) 12 | { 13 | var controllerDescriptor = apiDescription.ActionDescriptor as ControllerActionDescriptor; 14 | 15 | return new GroupInfo 16 | { 17 | GroupName = controllerDescriptor?.ControllerName ?? apiDescription.GroupName ?? defaultGroupName ?? "", 18 | Namespace = controllerDescriptor?.ControllerTypeInfo?.Namespace 19 | }; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/HostFactoryResolver.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Diagnostics; 7 | using System.Linq; 8 | using System.Reflection; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | #nullable enable 13 | 14 | namespace GenerateAspNetCoreClient.Command 15 | { 16 | internal sealed class HostFactoryResolver 17 | { 18 | private const BindingFlags DeclaredOnlyLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; 19 | 20 | public const string BuildWebHost = nameof(BuildWebHost); 21 | public const string CreateWebHostBuilder = nameof(CreateWebHostBuilder); 22 | public const string CreateHostBuilder = nameof(CreateHostBuilder); 23 | private const string TimeoutEnvironmentKey = "DOTNET_HOST_FACTORY_RESOLVER_DEFAULT_TIMEOUT_IN_SECONDS"; 24 | 25 | // The amount of time we wait for the diagnostic source events to fire 26 | private static readonly TimeSpan s_defaultWaitTimeout = SetupDefaultTimout(); 27 | 28 | private static TimeSpan SetupDefaultTimout() 29 | { 30 | if (Debugger.IsAttached) 31 | { 32 | return Timeout.InfiniteTimeSpan; 33 | } 34 | 35 | if (uint.TryParse(Environment.GetEnvironmentVariable(TimeoutEnvironmentKey), out var timeoutInSeconds)) 36 | { 37 | return TimeSpan.FromSeconds((int)timeoutInSeconds); 38 | } 39 | 40 | return TimeSpan.FromMinutes(5); 41 | } 42 | 43 | public static Func? ResolveWebHostFactory(Assembly assembly) 44 | { 45 | return ResolveFactory(assembly, BuildWebHost); 46 | } 47 | 48 | public static Func? ResolveWebHostBuilderFactory(Assembly assembly) 49 | { 50 | return ResolveFactory(assembly, CreateWebHostBuilder); 51 | } 52 | 53 | public static Func? ResolveHostBuilderFactory(Assembly assembly) 54 | { 55 | return ResolveFactory(assembly, CreateHostBuilder); 56 | } 57 | 58 | // This helpers encapsulates all of the complex logic required to: 59 | // 1. Execute the entry point of the specified assembly in a different thread. 60 | // 2. Wait for the diagnostic source events to fire 61 | // 3. Give the caller a chance to execute logic to mutate the IHostBuilder 62 | // 4. Resolve the instance of the applications's IHost 63 | // 5. Allow the caller to determine if the entry point has completed 64 | public static Func? ResolveHostFactory(Assembly assembly, 65 | TimeSpan? waitTimeout = null, 66 | bool stopApplication = true, 67 | Action? configureHostBuilder = null, 68 | Action? entrypointCompleted = null) 69 | { 70 | if (assembly.EntryPoint is null) 71 | { 72 | return null; 73 | } 74 | 75 | try 76 | { 77 | // Attempt to load hosting and check the version to make sure the events 78 | // even have a chance of firing (they were added in .NET >= 6) 79 | var hostingAssembly = Assembly.Load("Microsoft.Extensions.Hosting"); 80 | if (hostingAssembly.GetName().Version is Version version && version.Major < 6) 81 | { 82 | return null; 83 | } 84 | 85 | // We're using a version >= 6 so the events can fire. If they don't fire 86 | // then it's because the application isn't using the hosting APIs 87 | } 88 | catch 89 | { 90 | // There was an error loading the extensions assembly, return null. 91 | return null; 92 | } 93 | 94 | return args => new HostingListener(args, assembly.EntryPoint, waitTimeout ?? s_defaultWaitTimeout, stopApplication, configureHostBuilder, entrypointCompleted).CreateHost(); 95 | } 96 | 97 | private static Func? ResolveFactory(Assembly assembly, string name) 98 | { 99 | var programType = assembly?.EntryPoint?.DeclaringType; 100 | if (programType == null) 101 | { 102 | return null; 103 | } 104 | 105 | var factory = programType.GetMethod(name, DeclaredOnlyLookup); 106 | if (!IsFactory(factory)) 107 | { 108 | return null; 109 | } 110 | 111 | return args => (T)factory!.Invoke(null, new object[] { args })!; 112 | } 113 | 114 | // TReturn Factory(string[] args); 115 | private static bool IsFactory(MethodInfo? factory) 116 | { 117 | return factory != null 118 | && typeof(TReturn).IsAssignableFrom(factory.ReturnType) 119 | && factory.GetParameters().Length == 1 120 | && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType); 121 | } 122 | 123 | // Used by EF tooling without any Hosting references. Looses some return type safety checks. 124 | public static Func? ResolveServiceProviderFactory(Assembly assembly, TimeSpan? waitTimeout = null) 125 | { 126 | // Prefer the older patterns by default for back compat. 127 | var webHostFactory = ResolveWebHostFactory(assembly); 128 | if (webHostFactory != null) 129 | { 130 | return args => 131 | { 132 | var webHost = webHostFactory(args); 133 | return GetServiceProvider(webHost); 134 | }; 135 | } 136 | 137 | var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); 138 | if (webHostBuilderFactory != null) 139 | { 140 | return args => 141 | { 142 | var webHostBuilder = webHostBuilderFactory(args); 143 | var webHost = Build(webHostBuilder); 144 | return GetServiceProvider(webHost); 145 | }; 146 | } 147 | 148 | var hostBuilderFactory = ResolveHostBuilderFactory(assembly); 149 | if (hostBuilderFactory != null) 150 | { 151 | return args => 152 | { 153 | var hostBuilder = hostBuilderFactory(args); 154 | var host = Build(hostBuilder); 155 | return GetServiceProvider(host); 156 | }; 157 | } 158 | 159 | var hostFactory = ResolveHostFactory(assembly, waitTimeout: waitTimeout); 160 | if (hostFactory != null) 161 | { 162 | return args => 163 | { 164 | static bool IsApplicationNameArg(string arg) 165 | => arg.Equals("--applicationName", StringComparison.OrdinalIgnoreCase) || 166 | arg.Equals("/applicationName", StringComparison.OrdinalIgnoreCase); 167 | 168 | args = args.Any(arg => IsApplicationNameArg(arg)) || assembly.FullName is null 169 | ? args 170 | : args.Concat(new[] { "--applicationName", assembly.FullName }).ToArray(); 171 | 172 | var host = hostFactory(args); 173 | return GetServiceProvider(host); 174 | }; 175 | } 176 | 177 | return null; 178 | } 179 | 180 | private static object? Build(object builder) 181 | { 182 | var buildMethod = builder.GetType().GetMethod("Build"); 183 | return buildMethod?.Invoke(builder, Array.Empty()); 184 | } 185 | 186 | private static IServiceProvider? GetServiceProvider(object? host) 187 | { 188 | if (host == null) 189 | { 190 | return null; 191 | } 192 | var hostType = host.GetType(); 193 | var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup); 194 | return (IServiceProvider?)servicesProperty?.GetValue(host); 195 | } 196 | 197 | private sealed class HostingListener : IObserver, IObserver> 198 | { 199 | private readonly string[] _args; 200 | private readonly MethodInfo _entryPoint; 201 | private readonly TimeSpan _waitTimeout; 202 | private readonly bool _stopApplication; 203 | 204 | private readonly TaskCompletionSource _hostTcs = new(); 205 | private IDisposable? _disposable; 206 | private readonly Action? _configure; 207 | private readonly Action? _entrypointCompleted; 208 | private static readonly AsyncLocal _currentListener = new(); 209 | 210 | public HostingListener(string[] args, MethodInfo entryPoint, TimeSpan waitTimeout, bool stopApplication, Action? configure, Action? entrypointCompleted) 211 | { 212 | _args = args; 213 | _entryPoint = entryPoint; 214 | _waitTimeout = waitTimeout; 215 | _stopApplication = stopApplication; 216 | _configure = configure; 217 | _entrypointCompleted = entrypointCompleted; 218 | } 219 | 220 | public object CreateHost() 221 | { 222 | using var subscription = DiagnosticListener.AllListeners.Subscribe(this); 223 | 224 | // Kick off the entry point on a new thread so we don't block the current one 225 | // in case we need to timeout the execution 226 | var thread = new Thread(() => 227 | { 228 | Exception? exception = null; 229 | 230 | try 231 | { 232 | // Set the async local to the instance of the HostingListener so we can filter events that 233 | // aren't scoped to this execution of the entry point. 234 | _currentListener.Value = this; 235 | 236 | var parameters = _entryPoint.GetParameters(); 237 | if (parameters.Length == 0) 238 | { 239 | _entryPoint.Invoke(null, Array.Empty()); 240 | } 241 | else 242 | { 243 | _entryPoint.Invoke(null, new object[] { _args }); 244 | } 245 | 246 | // Try to set an exception if the entry point returns gracefully, this will force 247 | // build to throw 248 | _hostTcs.TrySetException(new InvalidOperationException("The entry point exited without ever building an IHost.")); 249 | } 250 | catch (TargetInvocationException tie) when (tie.InnerException is StopTheHostException) 251 | { 252 | // The host was stopped by our own logic 253 | } 254 | catch (TargetInvocationException tie) 255 | { 256 | exception = tie.InnerException ?? tie; 257 | 258 | // Another exception happened, propagate that to the caller 259 | _hostTcs.TrySetException(exception); 260 | } 261 | catch (Exception ex) 262 | { 263 | exception = ex; 264 | 265 | // Another exception happened, propagate that to the caller 266 | _hostTcs.TrySetException(ex); 267 | } 268 | finally 269 | { 270 | // Signal that the entry point is completed 271 | _entrypointCompleted?.Invoke(exception); 272 | } 273 | }) 274 | { 275 | // Make sure this doesn't hang the process 276 | IsBackground = true 277 | }; 278 | 279 | // Start the thread 280 | thread.Start(); 281 | 282 | try 283 | { 284 | // Wait before throwing an exception 285 | if (!_hostTcs.Task.Wait(_waitTimeout)) 286 | { 287 | 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."); 288 | } 289 | } 290 | catch (AggregateException) when (_hostTcs.Task.IsCompleted) 291 | { 292 | // Lets this propagate out of the call to GetAwaiter().GetResult() 293 | } 294 | 295 | Debug.Assert(_hostTcs.Task.IsCompleted); 296 | 297 | return _hostTcs.Task.GetAwaiter().GetResult(); 298 | } 299 | 300 | public void OnCompleted() 301 | { 302 | _disposable?.Dispose(); 303 | } 304 | 305 | public void OnError(Exception error) 306 | { 307 | 308 | } 309 | 310 | public void OnNext(DiagnosticListener value) 311 | { 312 | if (_currentListener.Value != this) 313 | { 314 | // Ignore events that aren't for this listener 315 | return; 316 | } 317 | 318 | if (value.Name == "Microsoft.Extensions.Hosting") 319 | { 320 | _disposable = value.Subscribe(this); 321 | } 322 | } 323 | 324 | public void OnNext(KeyValuePair value) 325 | { 326 | if (_currentListener.Value != this) 327 | { 328 | // Ignore events that aren't for this listener 329 | return; 330 | } 331 | 332 | if (value.Key == "HostBuilding") 333 | { 334 | _configure?.Invoke(value.Value!); 335 | } 336 | 337 | if (value.Key == "HostBuilt") 338 | { 339 | _hostTcs.TrySetResult(value.Value!); 340 | 341 | if (_stopApplication) 342 | { 343 | // Stop the host from running further 344 | throw new StopTheHostException(); 345 | } 346 | } 347 | } 348 | 349 | private sealed class StopTheHostException : Exception 350 | { 351 | 352 | } 353 | } 354 | } 355 | } -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/Model/Client.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace GenerateAspNetCoreClient.Command.Model 4 | { 5 | public class Client 6 | { 7 | /// 8 | /// Relative location from target folder. 9 | /// 10 | public string Location { get; } 11 | public string Namespace { get; } 12 | public string AccessModifier { get; } 13 | public string Name { get; } 14 | public IReadOnlyList EndpointMethods { get; } 15 | 16 | public Client( 17 | string location, 18 | string @namespace, string accessModifier, 19 | string name, 20 | IReadOnlyList endpointMethods) 21 | { 22 | Location = location; 23 | Namespace = @namespace; 24 | AccessModifier = accessModifier; 25 | Name = name; 26 | EndpointMethods = endpointMethods; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/Model/EndpointMethod.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | 6 | namespace GenerateAspNetCoreClient.Command.Model 7 | { 8 | public class EndpointMethod 9 | { 10 | public string? XmlDoc { get; } 11 | public HttpMethod HttpMethod { get; } 12 | public string Path { get; } 13 | public Type ResponseType { get; } 14 | public string Name { get; } 15 | public IReadOnlyList Parameters { get; } 16 | 17 | public bool IsMultipart => Parameters.Any(p => p.Source == ParameterSource.File); 18 | 19 | public EndpointMethod(string? xmlDoc, HttpMethod httpMethod, string path, Type responseType, string name, IReadOnlyList parameters) 20 | { 21 | XmlDoc = xmlDoc; 22 | HttpMethod = httpMethod; 23 | Path = path; 24 | ResponseType = responseType; 25 | Name = name; 26 | Parameters = parameters; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/Model/Parameter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace GenerateAspNetCoreClient.Command.Model 4 | { 5 | public class Parameter 6 | { 7 | public ParameterSource Source { get; } 8 | 9 | public Type Type { get; } 10 | 11 | public string Name { get; } 12 | 13 | public string ParameterName { get; } 14 | 15 | public string? DefaultValueLiteral { get; } 16 | 17 | public bool IsConstant { get; } 18 | 19 | public Parameter(ParameterSource source, Type type, string name, string parameterName, string? defaultValueLiteral, bool isStaticValue = false) 20 | { 21 | Source = source; 22 | Type = type; 23 | Name = name; 24 | ParameterName = parameterName; 25 | DefaultValueLiteral = defaultValueLiteral; 26 | IsConstant = isStaticValue; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/Model/ParameterSource.cs: -------------------------------------------------------------------------------- 1 | namespace GenerateAspNetCoreClient.Command.Model 2 | { 3 | public enum ParameterSource 4 | { 5 | Path, 6 | Query, 7 | Body, 8 | File, 9 | Header, 10 | Form, 11 | Other, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/Properties/Assembly.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("GenerateAspNetCoreClient.Tests")] -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Command/ServiceProviderResolver.cs: -------------------------------------------------------------------------------- 1 | // Originally from https://github.com/RicoSuter/NSwag/blob/master/src/NSwag.Commands/HostFactoryResolver.cs. 2 | 3 | #nullable disable 4 | 5 | using System; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Microsoft.AspNetCore; 11 | using Microsoft.AspNetCore.Hosting; 12 | using Microsoft.AspNetCore.Hosting.Server; 13 | using Microsoft.AspNetCore.Http.Features; 14 | using Microsoft.Extensions.DependencyInjection; 15 | using Microsoft.Extensions.Hosting; 16 | 17 | namespace GenerateAspNetCoreClient.Command 18 | { 19 | // Represents an application that uses Microsoft.Extensions.Hosting and supports 20 | // the various entry point flavors. The final model *does not* have an explicit CreateHost entry point and thus inverts the typical flow where the 21 | // execute Main and we wait for events to fire in order to access the appropriate state. 22 | // This is what allows top level statements to work, but getting the IServiceProvider is slightly more complex. 23 | internal static class ServiceProviderResolver 24 | { 25 | public static IServiceProvider GetServiceProvider(Assembly assembly) 26 | { 27 | _ = assembly ?? throw new ArgumentNullException(nameof(assembly)); 28 | 29 | IServiceProvider serviceProvider = null; 30 | 31 | var entryPointType = assembly.EntryPoint?.DeclaringType; 32 | if (entryPointType != null) 33 | { 34 | var buildWebHostMethod = entryPointType.GetMethod("BuildWebHost"); 35 | var args = Array.Empty(); 36 | 37 | if (buildWebHostMethod != null) 38 | { 39 | var result = buildWebHostMethod.Invoke(null, new object[] { args }); 40 | serviceProvider = ((IWebHost)result).Services; 41 | } 42 | else 43 | { 44 | var createWebHostMethod = 45 | entryPointType?.GetRuntimeMethod("CreateWebHostBuilder", new[] { typeof(string[]) }) ?? 46 | entryPointType?.GetRuntimeMethod("CreateWebHostBuilder", Type.EmptyTypes); 47 | 48 | if (createWebHostMethod != null) 49 | { 50 | var webHostBuilder = (IWebHostBuilder)createWebHostMethod.Invoke( 51 | null, createWebHostMethod.GetParameters().Length > 0 ? new object[] { args } : Array.Empty()); 52 | serviceProvider = webHostBuilder.Build().Services; 53 | } 54 | else 55 | { 56 | var createHostMethod = 57 | entryPointType?.GetRuntimeMethod("CreateHostBuilder", new[] { typeof(string[]) }) ?? 58 | entryPointType?.GetRuntimeMethod("CreateHostBuilder", Type.EmptyTypes); 59 | 60 | if (createHostMethod != null) 61 | { 62 | var webHostBuilder = (IHostBuilder)createHostMethod.Invoke( 63 | null, createHostMethod.GetParameters().Length > 0 ? new object[] { args } : Array.Empty()); 64 | serviceProvider = webHostBuilder.Build().Services; 65 | } 66 | } 67 | } 68 | } 69 | 70 | if (serviceProvider == null) 71 | { 72 | serviceProvider = GetServiceProviderWithHostFactoryResolver(assembly); 73 | } 74 | 75 | if (serviceProvider == null) 76 | { 77 | var startupType = assembly.ExportedTypes.FirstOrDefault(t => t.Name == "Startup"); 78 | if (startupType != null) 79 | { 80 | serviceProvider = WebHost.CreateDefaultBuilder().UseStartup(startupType).Build().Services; 81 | } 82 | } 83 | 84 | if (serviceProvider != null) 85 | { 86 | return serviceProvider; 87 | } 88 | 89 | throw new InvalidOperationException($"Generator requires the assembly {assembly.GetName()} to have " + 90 | $"either an BuildWebHost or CreateWebHostBuilder/CreateHostBuilder method. " + 91 | $"See https://docs.microsoft.com/en-us/aspnet/core/fundamentals/hosting?tabs=aspnetcore2x " + 92 | $"for suggestions on ways to refactor your startup type."); 93 | } 94 | 95 | internal static IServiceProvider GetServiceProviderWithHostFactoryResolver(Assembly assembly) 96 | { 97 | #if NETCOREAPP2_1 || NETFRAMEWORK 98 | return null; 99 | #else 100 | // We're disabling the default server and the console host lifetime. This will disable: 101 | // 1. Listening on ports 102 | // 2. Logging to the console from the default host. 103 | // This is essentially what the test server does in order to get access to the application's 104 | // IServicerProvider *and* middleware pipeline. 105 | void ConfigureHostBuilder(object hostBuilder) 106 | { 107 | ((IHostBuilder)hostBuilder).ConfigureServices((context, services) => 108 | { 109 | services.AddSingleton(); 110 | services.AddSingleton(); 111 | }); 112 | } 113 | 114 | var waitForStartTcs = new TaskCompletionSource(); 115 | 116 | void OnEntryPointExit(Exception exception) 117 | { 118 | // If the entry point exited, we'll try to complete the wait 119 | if (exception != null) 120 | { 121 | waitForStartTcs.TrySetException(exception); 122 | } 123 | else 124 | { 125 | waitForStartTcs.TrySetResult(null); 126 | } 127 | } 128 | 129 | // If all of the existing techniques fail, then try to resolve the ResolveHostFactory 130 | var factory = HostFactoryResolver.ResolveHostFactory(assembly, 131 | stopApplication: false, 132 | configureHostBuilder: ConfigureHostBuilder, 133 | entrypointCompleted: OnEntryPointExit); 134 | 135 | // We're unable to resolve the factory. This could mean the application wasn't referencing the right 136 | // version of hosting. 137 | if (factory == null) 138 | { 139 | return null; 140 | } 141 | 142 | try 143 | { 144 | // Get the IServiceProvider from the host 145 | #if NET6_0_OR_GREATER 146 | var assemblyName = assembly.GetName()?.FullName ?? string.Empty; 147 | // We should set the application name to the startup assembly to avoid falling back to the entry assembly. 148 | var services = ((IHost)factory(new[] { $"--{HostDefaults.ApplicationKey}={assemblyName}" })).Services; 149 | #else 150 | var services = ((IHost)factory(Array.Empty())).Services; 151 | #endif 152 | 153 | // Wait for the application to start so that we know it's fully configured. This is important because 154 | // we need the middleware pipeline to be configured before we access the ISwaggerProvider in 155 | // in the IServiceProvider 156 | var applicationLifetime = services.GetRequiredService(); 157 | 158 | using (var registration = applicationLifetime.ApplicationStarted.Register(() => waitForStartTcs.TrySetResult(null))) 159 | { 160 | waitForStartTcs.Task.Wait(); 161 | return services; 162 | } 163 | } 164 | catch (InvalidOperationException) 165 | { 166 | // We're unable to resolve the host, swallow the exception and return null 167 | } 168 | 169 | return null; 170 | #endif 171 | } 172 | 173 | private class NoopHostLifetime : IHostLifetime 174 | { 175 | public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; 176 | public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; 177 | } 178 | 179 | private class NoopServer : IServer 180 | { 181 | public IFeatureCollection Features { get; } = new FeatureCollection(); 182 | public void Dispose() { } 183 | public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) => Task.CompletedTask; 184 | public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; 185 | } 186 | } 187 | } -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Options/GenerateAspNetCoreClient.Options.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.Options/GenerateClientOptions.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | 3 | namespace GenerateAspNetCoreClient.Options 4 | { 5 | public class GenerateClientOptions 6 | { 7 | [Value(0, HelpText = "Relative path for input web assembly.")] 8 | public string InputPath { get; set; } = System.Environment.CurrentDirectory; 9 | 10 | [Option('o', "out-path", Required = true, HelpText = "Relative out path for generated files.")] 11 | public string OutPath { get; set; } = ""; 12 | 13 | [Option('n', "namespace", Required = true, HelpText = "Namespace for generated client types.")] 14 | public string Namespace { get; set; } = "Client"; 15 | 16 | [Option("environment", Required = false, HelpText = "Required ASPNETCORE_ENVIRONMENT.")] 17 | public string? Environment { get; set; } 18 | 19 | [Option("type-name-pattern", Required = false, Default = "I[controller]Api", HelpText = "Pattern by which client types are named.")] 20 | public string TypeNamePattern { get; set; } = "I[controller]Api"; 21 | 22 | [Option("access-modifier", Required = false, Default = "public", HelpText = "Access modifier used for generated clients.")] 23 | public string AccessModifier { get; set; } = "public"; 24 | 25 | [Option("add-cancellation-token", Required = false, Default = false, HelpText = "Add CancellationToken parameters to all endpoints.")] 26 | public bool AddCancellationTokenParameters { get; set; } 27 | 28 | [Option("use-query-models", Required = false, Default = false, HelpText = "Use query container type parameter (as defined in the endpoint) instead of separate parameters.")] 29 | public bool UseQueryModels { get; set; } 30 | 31 | [Option("use-api-responses", Required = false, Default = false, HelpText = "Use Task> return types for endpoints.")] 32 | public bool UseApiResponses { get; set; } 33 | 34 | [Option("exclude-types", Required = false, HelpText = "Exclude all controller types with substring in full name (including namespace).")] 35 | public string? ExcludeTypes { get; set; } 36 | 37 | [Option("exclude-paths", Required = false, HelpText = "Exclude all endpoints with substring in relative path.")] 38 | public string? ExcludePaths { get; set; } 39 | 40 | [Option("include-types", Required = false, HelpText = "Include only controller types with substring in full name (including namespace).")] 41 | public string? IncludeTypes { get; set; } 42 | 43 | [Option("include-paths", Required = false, HelpText = "Include only endpoints with substring in relative path.")] 44 | public string? IncludePaths { get; set; } 45 | 46 | public string[] AdditionalNamespaces { get; set; } = []; 47 | } 48 | } -------------------------------------------------------------------------------- /GenerateAspNetCoreClient.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.2.32408.312 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{5B396B83-C3D1-422E-AF7A-5D99602B3C05}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OutputProject", "Tests\OutputTest\OutputProject.csproj", "{43DDF7F9-8F47-49EC-B919-923E06D86AB7}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotNet.Cli.Build", "DotNet.Cli.Build\DotNet.Cli.Build.csproj", "{C3DB14C0-A82D-4CF4-A91D-79941D94DB01}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenerateAspNetCoreClient", "GenerateAspNetCoreClient\GenerateAspNetCoreClient.csproj", "{75EE42AB-40D4-47FF-B8D6-ADD17E6D2F0F}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenerateAspNetCoreClient.Command", "GenerateAspNetCoreClient.Command\GenerateAspNetCoreClient.Command.csproj", "{7D888E94-98B4-4A56-90BC-91DE791539CA}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenerateAspNetCoreClient.Options", "GenerateAspNetCoreClient.Options\GenerateAspNetCoreClient.Options.csproj", "{11A0969A-9BE9-4E7A-9DC5-300CBEDC0A45}" 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenerateAspNetCoreClient.Tests", "Tests\GenerateAspNetCoreClient.Tests\GenerateAspNetCoreClient.Tests.csproj", "{2543B4D5-D0A3-40E9-978B-A872674CF825}" 19 | EndProject 20 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DD70E8A2-C0E4-463D-9A96-5E466F5ED10E}" 21 | ProjectSection(SolutionItems) = preProject 22 | .editorconfig = .editorconfig 23 | .github\workflows\dotnetcore.yml = .github\workflows\dotnetcore.yml 24 | README.md = README.md 25 | EndProjectSection 26 | EndProject 27 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestWebApi.MinimalApi", "Tests\TestWebApi.MinimalApi\TestWebApi.MinimalApi.csproj", "{46F0A4F8-2F10-4AA1-807F-CB06BB7820BB}" 28 | EndProject 29 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestWebApi.Controllers", "Tests\TestWebApi.Controllers\TestWebApi.Controllers.csproj", "{CE509800-53DC-4A9A-A992-A483293D5FFD}" 30 | EndProject 31 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestWebApi.Dtos", "Tests\TestWebApi.Dtos\TestWebApi.Dtos.csproj", "{C435A511-569D-4D46-9A33-3516F1114596}" 32 | EndProject 33 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestWebApi.Versioning", "Tests\TestWebApi.Versioning\TestWebApi.Versioning.csproj", "{72F457DA-3EDC-4EDA-A15C-CC3FDE507366}" 34 | EndProject 35 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestWebApi.Controllers.UseApiResponses", "Tests\TestWebApi.Controllers.UseApiResponses\TestWebApi.Controllers.UseApiResponses.csproj", "{A0FE1063-5602-42DC-ACD7-D17E98483E5A}" 36 | EndProject 37 | Global 38 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 39 | Debug|Any CPU = Debug|Any CPU 40 | Release|Any CPU = Release|Any CPU 41 | EndGlobalSection 42 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 43 | {43DDF7F9-8F47-49EC-B919-923E06D86AB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 44 | {43DDF7F9-8F47-49EC-B919-923E06D86AB7}.Debug|Any CPU.Build.0 = Debug|Any CPU 45 | {43DDF7F9-8F47-49EC-B919-923E06D86AB7}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {43DDF7F9-8F47-49EC-B919-923E06D86AB7}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {C3DB14C0-A82D-4CF4-A91D-79941D94DB01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {C3DB14C0-A82D-4CF4-A91D-79941D94DB01}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {C3DB14C0-A82D-4CF4-A91D-79941D94DB01}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {C3DB14C0-A82D-4CF4-A91D-79941D94DB01}.Release|Any CPU.Build.0 = Release|Any CPU 51 | {75EE42AB-40D4-47FF-B8D6-ADD17E6D2F0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 52 | {75EE42AB-40D4-47FF-B8D6-ADD17E6D2F0F}.Debug|Any CPU.Build.0 = Debug|Any CPU 53 | {75EE42AB-40D4-47FF-B8D6-ADD17E6D2F0F}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {75EE42AB-40D4-47FF-B8D6-ADD17E6D2F0F}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {7D888E94-98B4-4A56-90BC-91DE791539CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 56 | {7D888E94-98B4-4A56-90BC-91DE791539CA}.Debug|Any CPU.Build.0 = Debug|Any CPU 57 | {7D888E94-98B4-4A56-90BC-91DE791539CA}.Release|Any CPU.ActiveCfg = Release|Any CPU 58 | {7D888E94-98B4-4A56-90BC-91DE791539CA}.Release|Any CPU.Build.0 = Release|Any CPU 59 | {11A0969A-9BE9-4E7A-9DC5-300CBEDC0A45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 60 | {11A0969A-9BE9-4E7A-9DC5-300CBEDC0A45}.Debug|Any CPU.Build.0 = Debug|Any CPU 61 | {11A0969A-9BE9-4E7A-9DC5-300CBEDC0A45}.Release|Any CPU.ActiveCfg = Release|Any CPU 62 | {11A0969A-9BE9-4E7A-9DC5-300CBEDC0A45}.Release|Any CPU.Build.0 = Release|Any CPU 63 | {2543B4D5-D0A3-40E9-978B-A872674CF825}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 64 | {2543B4D5-D0A3-40E9-978B-A872674CF825}.Debug|Any CPU.Build.0 = Debug|Any CPU 65 | {2543B4D5-D0A3-40E9-978B-A872674CF825}.Release|Any CPU.ActiveCfg = Release|Any CPU 66 | {2543B4D5-D0A3-40E9-978B-A872674CF825}.Release|Any CPU.Build.0 = Release|Any CPU 67 | {46F0A4F8-2F10-4AA1-807F-CB06BB7820BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 68 | {46F0A4F8-2F10-4AA1-807F-CB06BB7820BB}.Debug|Any CPU.Build.0 = Debug|Any CPU 69 | {46F0A4F8-2F10-4AA1-807F-CB06BB7820BB}.Release|Any CPU.ActiveCfg = Release|Any CPU 70 | {46F0A4F8-2F10-4AA1-807F-CB06BB7820BB}.Release|Any CPU.Build.0 = Release|Any CPU 71 | {CE509800-53DC-4A9A-A992-A483293D5FFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 72 | {CE509800-53DC-4A9A-A992-A483293D5FFD}.Debug|Any CPU.Build.0 = Debug|Any CPU 73 | {CE509800-53DC-4A9A-A992-A483293D5FFD}.Release|Any CPU.ActiveCfg = Release|Any CPU 74 | {CE509800-53DC-4A9A-A992-A483293D5FFD}.Release|Any CPU.Build.0 = Release|Any CPU 75 | {C435A511-569D-4D46-9A33-3516F1114596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 76 | {C435A511-569D-4D46-9A33-3516F1114596}.Debug|Any CPU.Build.0 = Debug|Any CPU 77 | {C435A511-569D-4D46-9A33-3516F1114596}.Release|Any CPU.ActiveCfg = Release|Any CPU 78 | {C435A511-569D-4D46-9A33-3516F1114596}.Release|Any CPU.Build.0 = Release|Any CPU 79 | {72F457DA-3EDC-4EDA-A15C-CC3FDE507366}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 80 | {72F457DA-3EDC-4EDA-A15C-CC3FDE507366}.Debug|Any CPU.Build.0 = Debug|Any CPU 81 | {72F457DA-3EDC-4EDA-A15C-CC3FDE507366}.Release|Any CPU.ActiveCfg = Release|Any CPU 82 | {72F457DA-3EDC-4EDA-A15C-CC3FDE507366}.Release|Any CPU.Build.0 = Release|Any CPU 83 | {A0FE1063-5602-42DC-ACD7-D17E98483E5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 84 | {A0FE1063-5602-42DC-ACD7-D17E98483E5A}.Debug|Any CPU.Build.0 = Debug|Any CPU 85 | {A0FE1063-5602-42DC-ACD7-D17E98483E5A}.Release|Any CPU.ActiveCfg = Release|Any CPU 86 | {A0FE1063-5602-42DC-ACD7-D17E98483E5A}.Release|Any CPU.Build.0 = Release|Any CPU 87 | EndGlobalSection 88 | GlobalSection(SolutionProperties) = preSolution 89 | HideSolutionNode = FALSE 90 | EndGlobalSection 91 | GlobalSection(NestedProjects) = preSolution 92 | {43DDF7F9-8F47-49EC-B919-923E06D86AB7} = {5B396B83-C3D1-422E-AF7A-5D99602B3C05} 93 | {2543B4D5-D0A3-40E9-978B-A872674CF825} = {5B396B83-C3D1-422E-AF7A-5D99602B3C05} 94 | {46F0A4F8-2F10-4AA1-807F-CB06BB7820BB} = {5B396B83-C3D1-422E-AF7A-5D99602B3C05} 95 | {CE509800-53DC-4A9A-A992-A483293D5FFD} = {5B396B83-C3D1-422E-AF7A-5D99602B3C05} 96 | {C435A511-569D-4D46-9A33-3516F1114596} = {5B396B83-C3D1-422E-AF7A-5D99602B3C05} 97 | {72F457DA-3EDC-4EDA-A15C-CC3FDE507366} = {5B396B83-C3D1-422E-AF7A-5D99602B3C05} 98 | {A0FE1063-5602-42DC-ACD7-D17E98483E5A} = {5B396B83-C3D1-422E-AF7A-5D99602B3C05} 99 | EndGlobalSection 100 | GlobalSection(ExtensibilityGlobals) = postSolution 101 | SolutionGuid = {89A98ED1-2D66-4D68-A36D-39A8F4ADB399} 102 | EndGlobalSection 103 | EndGlobal 104 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient/CustomLoadContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Runtime.Loader; 7 | using System.Text.Json; 8 | 9 | namespace GenerateAspNetCoreClient 10 | { 11 | internal class CustomLoadContext : AssemblyLoadContext 12 | { 13 | private readonly AssemblyDependencyResolver dependencyResolver; 14 | private readonly List frameworkPaths; 15 | private readonly Assembly sharedAssemply; 16 | 17 | public CustomLoadContext(string assemblyPath, Assembly sharedAssembly) 18 | { 19 | dependencyResolver = new AssemblyDependencyResolver(assemblyPath); 20 | frameworkPaths = GetFrameworkPaths(assemblyPath); 21 | sharedAssemply = sharedAssembly; 22 | } 23 | 24 | public Assembly Load(AssemblyName assemblyName, bool fallbackToDefault) 25 | { 26 | if (assemblyName.FullName == sharedAssemply.FullName) 27 | return sharedAssemply; 28 | var path = assemblyName.Name.StartsWith("System.Private") ? null : ResolveAssemblyToPath(assemblyName); 29 | 30 | if (path == null && fallbackToDefault) 31 | { 32 | var defaultLoaded = AssemblyLoadContext.Default.LoadFromAssemblyName(assemblyName); 33 | 34 | if (defaultLoaded != null) 35 | path = defaultLoaded.Location; 36 | } 37 | 38 | return path != null ? LoadFromAssemblyPath(path) : null; 39 | } 40 | 41 | private string ResolveAssemblyToPath(AssemblyName assemblyName) 42 | { 43 | var path = dependencyResolver.ResolveAssemblyToPath(assemblyName); 44 | 45 | if (path != null) 46 | return path; 47 | 48 | foreach (var frameworkPath in frameworkPaths) 49 | { 50 | var frameworkAssemblyPath = Path.Combine(frameworkPath, assemblyName.Name + ".dll"); 51 | 52 | if (File.Exists(frameworkAssemblyPath)) 53 | return frameworkAssemblyPath; 54 | } 55 | 56 | return null; 57 | } 58 | 59 | protected override Assembly Load(AssemblyName assemblyName) 60 | { 61 | return Load(assemblyName, true); 62 | } 63 | 64 | protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) 65 | { 66 | var resolvedPath = dependencyResolver.ResolveUnmanagedDllToPath(unmanagedDllName); 67 | 68 | return resolvedPath == null 69 | ? IntPtr.Zero 70 | : LoadUnmanagedDllFromPath(resolvedPath); 71 | } 72 | 73 | private static List GetFrameworkPaths(string assemblyPath) 74 | { 75 | // Unfortunately, AssemblyDependencyResolver does not resolve framework references. 76 | // In case of matching TargetFramework for generator tool and web project we can simply fallback to 77 | // AssemblyLoadContext.Default, but that doesn't work for older ASP.NET versions (e.g. .NET Core 3.1). 78 | // Therefore we attempt to resolve framework assembly path manually - by parsing runtimeconfig.json, and finding 79 | // framework folder. 80 | // Any better ideas?... 81 | 82 | var paths = new List(); 83 | 84 | try 85 | { 86 | var runtimeConfigPath = Path.Combine(Path.GetDirectoryName(assemblyPath), 87 | Path.GetFileNameWithoutExtension(assemblyPath) + ".runtimeconfig.json"); 88 | 89 | if (!File.Exists(runtimeConfigPath)) 90 | return null; 91 | 92 | var runtimeConfig = JsonDocument.Parse(File.ReadAllText(runtimeConfigPath)); 93 | 94 | var runtimeOptionsNode = runtimeConfig.RootElement.GetProperty("runtimeOptions"); 95 | 96 | IEnumerable frameworkNodes = runtimeOptionsNode.TryGetProperty("frameworks", out var frameworksNode) 97 | ? frameworksNode.EnumerateArray() 98 | : new[] { runtimeOptionsNode.GetProperty("framework") }; 99 | 100 | foreach (var frameworkNode in frameworkNodes) 101 | { 102 | // e.g. name = Microsoft.AspNetCore.App, version = 3.1.0. 103 | var name = frameworkNode.GetProperty("name").GetString(); 104 | var version = frameworkNode.GetProperty("version").GetString(); 105 | var path = FindFrameworkVersionPath(name, version); 106 | 107 | if (path != null) 108 | paths.Add(path); 109 | } 110 | 111 | } 112 | catch (Exception e) 113 | { 114 | Console.WriteLine("Failed to find framework directory: " + e.ToString()); 115 | } 116 | 117 | return paths; 118 | 119 | static string FindFrameworkVersionPath(string name, string version) 120 | { 121 | var sharedDirectoryPath = 122 | Path.GetDirectoryName( 123 | Path.GetDirectoryName( 124 | Path.GetDirectoryName(typeof(object).Assembly.Location))); 125 | 126 | var frameworkVersionDirectories = new DirectoryInfo(Path.Combine(sharedDirectoryPath, name)).GetDirectories().Reverse(); 127 | 128 | // Attempt to find strict match first, but fallback to fuzzy match (e.g. 3.1.12 instead of 3.1.0). 129 | while (!string.IsNullOrEmpty(version)) 130 | { 131 | var versionDirectory = frameworkVersionDirectories.FirstOrDefault(d => d.Name.StartsWith(version)); 132 | 133 | if (versionDirectory != null) 134 | return versionDirectory.FullName; 135 | 136 | version = version[..(version[0..^1].LastIndexOf('.') + 1)]; 137 | } 138 | 139 | return null; 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient/GenerateAspNetCoreClient.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0 6 | 7 | true 8 | dotnet-generate-client 9 | ./nupkg 10 | 3.2 11 | https://github.com/Dreamescaper/GenerateAspNetCoreClient 12 | GenerateAspNetCoreClient.Refit 13 | DotNet tool to generate Refit HTTP client types from ASP.NET Core API controllers. 14 | README.md 15 | MIT 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | True 27 | 28 | 29 | 30 | True 31 | \ 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Runtime.Loader; 5 | using CommandLine; 6 | using DotNet.Cli.Build; 7 | using GenerateAspNetCoreClient.Command; 8 | using GenerateAspNetCoreClient.Options; 9 | 10 | namespace GenerateAspNetCoreClient 11 | { 12 | public class Program 13 | { 14 | public static void Main(string[] args) 15 | { 16 | Parser.Default 17 | .ParseArguments(args) 18 | .WithParsed(options => CreateClient(options)); 19 | } 20 | 21 | internal static void CreateClient(GenerateClientOptions options) 22 | { 23 | var assemblyPath = GetAssemblyPath(options.InputPath); 24 | var directory = Path.GetDirectoryName(assemblyPath); 25 | 26 | var sharedOptionsAssembly = typeof(GenerateClientOptions).Assembly; 27 | 28 | var context = new CustomLoadContext(assemblyPath, sharedOptionsAssembly); 29 | AssemblyLoadContext.Default.Resolving += (_, name) => context.Load(name, false); 30 | 31 | var webProjectAssembly = context.LoadFromAssemblyPath(assemblyPath); 32 | var commandAssembly = context.LoadFromAssemblyPath(typeof(GenerateClientCommand).Assembly.Location); 33 | 34 | commandAssembly.GetTypes().First(t => t.Name == "GenerateClientCommand") 35 | .GetMethod("Invoke") 36 | .Invoke(null, new object[] { webProjectAssembly, options }); 37 | } 38 | 39 | private static string GetAssemblyPath(string path) 40 | { 41 | if (Path.GetExtension(path).Equals(".dll", StringComparison.OrdinalIgnoreCase)) 42 | { 43 | // If path is .dll file - return straight away 44 | return Path.GetFullPath(path); 45 | } 46 | else 47 | { 48 | // Otherwise - publish the project and return built .dll 49 | var project = Project.FromPath(path); 50 | project.Build(); 51 | return project.OutputFilePath; 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /GenerateAspNetCoreClient/Properties/Assembly.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("GenerateAspNetCoreClient.Tests")] -------------------------------------------------------------------------------- /GenerateAspNetCoreClient/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "GenerateAspNetCoreClient": { 4 | "commandName": "Project", 5 | "commandLineArgs": "\"..\\..\\..\\..\\Tests\\TestWebApi.Controllers\\TestWebApi.Controllers.csproj\" -o \"..\\..\\..\\..\\Tests\\OutputTest\" -n \"TestClient\"" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Oleksandr Liakhevych 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Nuget](https://img.shields.io/nuget/v/GenerateAspNetCoreClient.Refit)](https://www.nuget.org/packages/GenerateAspNetCoreClient.Refit/) 2 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua) 3 | 4 | # GenerateAspNetCoreClient 5 | DotNet tool to generate [Refit](https://github.com/reactiveui/refit) HTTP client types from ASP.NET Core API controllers. 6 | 7 | ## Prerequisites 8 | Tool requires .NET 9 runtime installed, and it supports projects with ASP.NET Core 9 or 8. 9 | 10 | 11 | ## Usage 12 | Install dotnet tool from Nuget: 13 | 14 | `dotnet tool install GenerateAspNetCoreClient.Refit -g` 15 | 16 | Then execute the following in the directory with your Web project: 17 | 18 | `dotnet-generate-client MyApiProjectPath -o OutPath -n My.Client.Namespace` 19 | 20 | The tool will generate Refit interfaces based on the endpoints defined in your project. Please note that only .cs files are created, you still need to add the project file, with project references for models (if any needed), and [Refit](https://www.nuget.org/packages/Refit/) package reference. 21 | 22 | ## Examples 23 | Based on the following controller: 24 | ```csharp 25 | [ApiController] 26 | [Route("[controller]")] 27 | public class WeatherForecastController : ControllerBase 28 | { 29 | [HttpGet] 30 | public async Task>> Get() 31 | {... 32 | } 33 | 34 | [HttpGet("{id}")] 35 | public async Task Get(Guid id) 36 | {... 37 | } 38 | } 39 | ``` 40 | 41 | `IWeatherForecastApi.cs` file is created: 42 | ```csharp 43 | // 44 | 45 | using System; 46 | using System.Collections.Generic; 47 | using System.Threading.Tasks; 48 | using Refit; 49 | using TestWebApi.Models; 50 | 51 | namespace Test.Name.Space 52 | { 53 | public interface IWeatherForecastApi 54 | { 55 | [Get("/WeatherForecast")] 56 | Task> Get(); 57 | 58 | [Get("/WeatherForecast/{id}")] 59 | Task Get(Guid id); 60 | } 61 | } 62 | ``` 63 | 64 | ## Parameters 65 | ``` 66 | -o, --out-path Required. Relative out path for generated files. 67 | 68 | -n, --namespace Required. Namespace for generated client types. 69 | 70 | --environment ASPNETCORE_ENVIRONMENT to set during generation. 71 | 72 | --type-name-pattern (Default: I[controller]Api) Pattern by which client types are named. 73 | 74 | --access-modifier (Default: public) Access modifier used for generated clients. 75 | 76 | --add-cancellation-token (Default: false) Add CancellationToken parameters to all endpoints. 77 | 78 | --use-query-models (Default: false) Use query container type parameter (as defined in the endpoint) instead 79 | of separate parameters. 80 | 81 | --use-api-responses (Default: false) Use Task> return types for endpoints. 82 | 83 | --exclude-types Exclude all controller types with substring in full name (including namespace). 84 | 85 | --exclude-paths Exclude all endpoints with substring in relative path. 86 | 87 | --include-types Include only controller types with substring in full name (including namespace). 88 | 89 | --include-paths Include only endpoints with substring in relative path. 90 | ``` 91 | -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ApiDescriptionTestData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Reflection; 4 | using Microsoft.AspNetCore.Mvc.Abstractions; 5 | using Microsoft.AspNetCore.Mvc.ApiExplorer; 6 | using Microsoft.AspNetCore.Mvc.Controllers; 7 | using Microsoft.AspNetCore.Mvc.ModelBinding; 8 | using TestWebApi.Controllers; 9 | using TestWebApi.Models; 10 | 11 | namespace GenerateAspNetCoreClient.Tests 12 | { 13 | public class ApiDescriptionTestData 14 | { 15 | public static ApiParameterDescription CreateParameter(string name = "id", 16 | BindingSource bindingSource = null, Type type = null) 17 | { 18 | return new ApiParameterDescription 19 | { 20 | IsRequired = true, 21 | Name = name, 22 | ParameterDescriptor = new ParameterDescriptor 23 | { 24 | Name = name, 25 | ParameterType = type ?? typeof(Guid), 26 | }, 27 | Type = type ?? typeof(Guid), 28 | Source = bindingSource ?? BindingSource.Path 29 | }; 30 | } 31 | 32 | public static ApiDescription CreateApiDescription( 33 | string httpMethod = "GET", 34 | string actionName = "Get", 35 | string path = "WeatherForecast/{id}", 36 | IList apiParameters = null, 37 | Type responseType = null, 38 | Type controllerType = null) 39 | { 40 | controllerType ??= typeof(WeatherForecastController); 41 | 42 | var apiDescription = new ApiDescription 43 | { 44 | ActionDescriptor = new ControllerActionDescriptor 45 | { 46 | ActionName = actionName, 47 | ControllerName = controllerType.Name.Replace("Controller", ""), 48 | ControllerTypeInfo = controllerType.GetTypeInfo(), 49 | DisplayName = $"{controllerType.FullName}.{actionName} ({controllerType.Assembly.GetName().Name})", 50 | MethodInfo = typeof(WeatherForecastController).GetMethod("Get", Array.Empty()) 51 | }, 52 | HttpMethod = httpMethod, 53 | RelativePath = path, 54 | SupportedResponseTypes = 55 | { 56 | new ApiResponseType 57 | { 58 | StatusCode = 200, 59 | Type = responseType ?? typeof(WeatherForecastRecord) 60 | } 61 | } 62 | }; 63 | 64 | foreach (var parDescription in apiParameters ?? new[] { CreateParameter() }) 65 | { 66 | apiDescription.ParameterDescriptions.Add(parDescription); 67 | } 68 | 69 | return apiDescription; 70 | } 71 | 72 | public static ApiDescriptionGroupCollection CreateApiExplorer(ApiDescription[] apiDescriptions) 73 | { 74 | return new ApiDescriptionGroupCollection(new List 75 | { 76 | new ApiDescriptionGroup(null, apiDescriptions) 77 | }, 1); 78 | } 79 | 80 | public static ApiDescriptionGroupCollection CreateApiExplorer( 81 | string httpMethod = "GET", 82 | string actionName = "Get", 83 | string path = "WeatherForecast/{id}", 84 | IList apiParameters = null, 85 | Type responseType = null) 86 | { 87 | var apiDescription = CreateApiDescription(httpMethod, actionName, path, apiParameters, responseType); 88 | return CreateApiExplorer(new[] { apiDescription }); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.GenerationTest_UseApiResponses/IWeatherForecastApi.verified.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using System.Collections.Generic; 4 | using System.Threading.Tasks; 5 | using Refit; 6 | using TestWebApi.Models; 7 | 8 | namespace Test.Name.Space 9 | { 10 | public partial interface IWeatherForecastApi 11 | { 12 | [Get("/WeatherForecast")] 13 | Task>> Get(); 14 | 15 | [Post("/WeatherForecast/create")] 16 | Task Post([Body] WeatherForecast weatherForecast); 17 | } 18 | } -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.GenerationTest_UseApiResponses_UseCancellationTokens/ITestWebApiMinimalApiApi.verified.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Refit; 6 | using TestWebApi.Models; 7 | 8 | namespace Test.Name.Space 9 | { 10 | public partial interface ITestWebApiMinimalApiApi 11 | { 12 | [Post("/weather-forecast")] 13 | Task PostWeatherForecast([Body] WeatherForecast forecast, CancellationToken cancellationToken = default); 14 | } 15 | } -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.GenerationTest_UseApiResponses_UseCancellationTokens/IWeatherForecastApi.verified.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Refit; 6 | using TestWebApi.Models; 7 | 8 | namespace Test.Name.Space 9 | { 10 | public partial interface IWeatherForecastApi 11 | { 12 | [Get("/weather-forecast")] 13 | Task> GetWeatherForecast(CancellationToken cancellationToken = default); 14 | 15 | [Get("/weather-forecast/with-name")] 16 | Task> GetSomeWeather(int days, CancellationToken cancellationToken = default); 17 | } 18 | } -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.GenerationTest_testProjectName=TestWebApi.Controllers/IAnotherTestApi.verified.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using System; 4 | using System.Threading.Tasks; 5 | using Refit; 6 | using TestWebApi.Models; 7 | 8 | namespace Test.Name.Space 9 | { 10 | public partial interface IAnotherTestApi 11 | { 12 | [Get("/AnotherTest/with-query-model")] 13 | Task WithQueryModel(string param1 = null, Guid? param2 = null, int? param3 = null, SomeEnum? param4 = null); 14 | 15 | [Get("/AnotherTest/with-query-name")] 16 | Task WithQueryParameterName([AliasAs("currency")] string currencyName = null); 17 | 18 | [Get("/AnotherTest/with-query-name-array")] 19 | Task WithQueryArrayParameterName([AliasAs("currencies")] string[] currencyNames = null); 20 | 21 | [Get("/AnotherTest/with-query-enum")] 22 | Task WithQueryEnumParameter(SomeEnum? enumParam = null); 23 | } 24 | } -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.GenerationTest_testProjectName=TestWebApi.Controllers/IWeatherForecastApi.verified.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.IO; 6 | using System.Threading.Tasks; 7 | using Refit; 8 | using TestWebApi.Models; 9 | 10 | namespace Test.Name.Space 11 | { 12 | public partial interface IWeatherForecastApi 13 | { 14 | [Get("/WeatherForecast")] 15 | Task> Get(); 16 | 17 | [Get("/WeatherForecast/{id}")] 18 | Task Get(Guid id); 19 | 20 | [Post("/WeatherForecast/create")] 21 | Task Post([Body] WeatherForecast weatherForecast); 22 | 23 | [Multipart] 24 | [Post("/WeatherForecast/upload")] 25 | Task Upload(MultipartItem uploadedFile); 26 | 27 | [Multipart] 28 | [Post("/WeatherForecast/upload-multiple")] 29 | Task Upload(List uploadedFiles); 30 | 31 | [Get("/WeatherForecast/download")] 32 | Task Download(); 33 | 34 | [Post("/WeatherForecast/search")] 35 | Task Search(string name = "test"); 36 | 37 | [Post("/WeatherForecast/{id}/queryParams")] 38 | Task SomethingWithQueryParams(int id, string par2, int par1 = 2, string par3 = null, string par4 = "1"); 39 | 40 | [Patch("/WeatherForecast/headerParams")] 41 | Task WithHeaderParams([Header("x-header-name")] string headerParam = null); 42 | 43 | [Post("/WeatherForecast/form")] 44 | Task WithFormParam([Body(BodySerializationMethod.UrlEncoded)] SomeQueryModel formParam = null); 45 | 46 | [Post("/WeatherForecast/form-collection")] 47 | Task WithFormCollectionParam([Body(BodySerializationMethod.UrlEncoded)] Dictionary formParam, string queryParam = null); 48 | 49 | [Multipart] 50 | [Post("/WeatherForecast/form-with-file")] 51 | Task WithFormWithFileParam(string queryParam = null, MultipartItem formParam = null, string title = null); 52 | 53 | [Multipart] 54 | [Post("/WeatherForecast/form-with-files")] 55 | Task WithFormWithFilesParam(string queryParam = null, List formParam = null, string title = null); 56 | 57 | [Get("/WeatherForecast/record")] 58 | Task WithRecordModels(Guid? id = null, string name = null); 59 | } 60 | } -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.GenerationTest_testProjectName=TestWebApi.MinimalApi/ITestWebApiMinimalApiApi.verified.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using System.Threading.Tasks; 4 | using Refit; 5 | using TestWebApi.Models; 6 | 7 | namespace Test.Name.Space 8 | { 9 | public partial interface ITestWebApiMinimalApiApi 10 | { 11 | [Post("/weather-forecast")] 12 | Task PostWeatherForecast([Body] WeatherForecast forecast); 13 | } 14 | } -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.GenerationTest_testProjectName=TestWebApi.MinimalApi/IWeatherForecastApi.verified.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using System.Threading.Tasks; 4 | using Refit; 5 | using TestWebApi.Models; 6 | 7 | namespace Test.Name.Space 8 | { 9 | public partial interface IWeatherForecastApi 10 | { 11 | [Get("/weather-forecast")] 12 | Task GetWeatherForecast(); 13 | 14 | [Get("/weather-forecast/with-name")] 15 | Task GetSomeWeather(int days); 16 | } 17 | } -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.GenerationTest_testProjectName=TestWebApi.Versioning/IVersionApi.verified.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using System.Threading.Tasks; 4 | using Refit; 5 | 6 | namespace Test.Name.Space 7 | { 8 | public partial interface IVersionApi 9 | { 10 | [Headers("Api-Version: 2.0")] 11 | [Get("/api/Version")] 12 | Task Get(); 13 | 14 | [Headers("Api-Version: 3.0")] 15 | [Get("/api/Version")] 16 | Task Get3(); 17 | 18 | [Headers("Api-Version: 4.0")] 19 | [Get("/api/Version")] 20 | Task Get4(); 21 | } 22 | } -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientGenerationTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using DotNet.Cli.Build; 4 | using GenerateAspNetCoreClient.Options; 5 | using NUnit.Framework; 6 | using VerifyNUnit; 7 | 8 | namespace GenerateAspNetCoreClient.Tests 9 | { 10 | [NonParallelizable] 11 | public class ClientGenerationTests 12 | { 13 | private static readonly string _inputPath = Path.Combine("..", "..", "..", "..", "{0}", "{0}.csproj"); 14 | private static readonly string _outPath = Path.Combine("..", "..", "..", "..", "OutputTest", "Client"); 15 | private static readonly string _outProjectPath = Path.Combine("..", "..", "..", "..", "OutputTest", "OutputProject.csproj"); 16 | 17 | [TearDown] 18 | public void CleanOutput() 19 | { 20 | Directory.Delete(_outPath, true); 21 | } 22 | 23 | [TestCase("TestWebApi.Controllers")] 24 | [TestCase("TestWebApi.Versioning")] 25 | [TestCase("TestWebApi.MinimalApi")] 26 | public async Task GenerationTest(string testProjectName) 27 | { 28 | var options = new GenerateClientOptions 29 | { 30 | InputPath = string.Format(_inputPath, testProjectName), 31 | OutPath = _outPath, 32 | Namespace = "Test.Name.Space", 33 | }; 34 | 35 | Program.CreateClient(options); 36 | 37 | Assert.That(() => Project.FromPath(_outProjectPath).Build(), Throws.Nothing); 38 | await Verifier.VerifyDirectory(_outPath); 39 | } 40 | 41 | [Test] 42 | public async Task GenerationTest_UseApiResponses() 43 | { 44 | var options = new GenerateClientOptions 45 | { 46 | InputPath = string.Format(_inputPath, "TestWebApi.Controllers.UseApiResponses"), 47 | UseApiResponses = true, 48 | OutPath = _outPath, 49 | Namespace = "Test.Name.Space", 50 | }; 51 | 52 | Program.CreateClient(options); 53 | 54 | Assert.That(() => Project.FromPath(_outProjectPath).Build(), Throws.Nothing); 55 | await Verifier.VerifyDirectory(_outPath); 56 | } 57 | 58 | [Test] 59 | public async Task GenerationTest_UseApiResponses_UseCancellationTokens() 60 | { 61 | var options = new GenerateClientOptions 62 | { 63 | InputPath = string.Format(_inputPath,"TestWebApi.MinimalApi"), 64 | UseApiResponses = true, 65 | AddCancellationTokenParameters = true, 66 | OutPath = _outPath, 67 | Namespace = "Test.Name.Space", 68 | }; 69 | 70 | Program.CreateClient(options); 71 | 72 | Assert.That(() => Project.FromPath(_outProjectPath).Build(), Throws.Nothing); 73 | await Verifier.VerifyDirectory(_outPath); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/ClientModelBuilderTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading; 3 | using GenerateAspNetCoreClient.Command; 4 | using GenerateAspNetCoreClient.Command.Model; 5 | using GenerateAspNetCoreClient.Options; 6 | using NUnit.Framework; 7 | 8 | namespace GenerateAspNetCoreClient.Tests 9 | { 10 | public class ClientModelBuilderTests 11 | { 12 | [Test] 13 | public void AddCancellationTokenParameterIfRequested() 14 | { 15 | // Arrange 16 | var options = new GenerateClientOptions { AddCancellationTokenParameters = true }; 17 | var existingParameter = ApiDescriptionTestData.CreateParameter(); 18 | var apiExplorer = ApiDescriptionTestData.CreateApiExplorer(apiParameters: new[] { existingParameter }); 19 | var assembly = GetType().Assembly; 20 | var builder = new ClientModelBuilder(apiExplorer, options, assembly); 21 | 22 | // Act 23 | var client = builder.GetClientCollection()[0]; 24 | var parameters = client.EndpointMethods[0].Parameters; 25 | 26 | // Assert 27 | Assert.That(parameters.Count, Is.EqualTo(2), "(existing and cancellationToken)"); 28 | 29 | var cancellationTokenParameter = parameters.Last(); 30 | 31 | Assert.That(cancellationTokenParameter.Type, Is.EqualTo(typeof(CancellationToken))); 32 | Assert.That(cancellationTokenParameter.Name, Is.EqualTo("cancellationToken")); 33 | Assert.That(cancellationTokenParameter.DefaultValueLiteral, Is.EqualTo("default")); 34 | Assert.That(cancellationTokenParameter.Source, Is.EqualTo(ParameterSource.Other)); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/EndpointFilteringTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Reflection; 3 | using GenerateAspNetCoreClient.Command; 4 | using GenerateAspNetCoreClient.Options; 5 | using NUnit.Framework; 6 | 7 | namespace GenerateAspNetCoreClient.Tests 8 | { 9 | public class EndpointFilteringTests 10 | { 11 | private readonly Assembly assembly = typeof(EndpointFilteringTests).Assembly; 12 | 13 | [Test] 14 | public void ExcludeSpecifiedTypes() 15 | { 16 | // Arrange 17 | var options = new GenerateClientOptions { ExcludeTypes = "FilterName" }; 18 | 19 | var apiDescriptions = new[] 20 | { 21 | typeof(FilterNameType), 22 | typeof(OtherNameType), 23 | typeof(FilterNameNamespace.SomeType1), 24 | typeof(OtherNameNamespace.SomeType2) 25 | } 26 | .Select(type => ApiDescriptionTestData.CreateApiDescription(controllerType: type)) 27 | .ToArray(); 28 | 29 | var apiExplorer = ApiDescriptionTestData.CreateApiExplorer(apiDescriptions); 30 | 31 | // Act 32 | var clients = new ClientModelBuilder(apiExplorer, options, assembly).GetClientCollection(); 33 | 34 | // Assert 35 | Assert.That(clients.Select(c => c.Name), Is.EquivalentTo(new[] { "IOtherNameTypeApi", "ISomeType2Api" })); 36 | } 37 | 38 | [Test] 39 | public void IncludeSpecifiedTypes() 40 | { 41 | // Arrange 42 | var options = new GenerateClientOptions { IncludeTypes = "FilterName" }; 43 | 44 | var apiDescriptions = new[] 45 | { 46 | typeof(FilterNameType), 47 | typeof(OtherNameType), 48 | typeof(FilterNameNamespace.SomeType1), 49 | typeof(OtherNameNamespace.SomeType2) 50 | } 51 | .Select(type => ApiDescriptionTestData.CreateApiDescription(controllerType: type)) 52 | .ToArray(); 53 | 54 | var apiExplorer = ApiDescriptionTestData.CreateApiExplorer(apiDescriptions); 55 | 56 | // Act 57 | var clients = new ClientModelBuilder(apiExplorer, options, assembly).GetClientCollection(); 58 | 59 | // Assert 60 | Assert.That(clients.Select(c => c.Name), Is.EquivalentTo(new[] { "IFilterNameTypeApi", "ISomeType1Api" })); 61 | } 62 | 63 | [Test] 64 | public void ExcludeSpecifiedPaths() 65 | { 66 | // Arrange 67 | var options = new GenerateClientOptions { ExcludePaths = "filter-path" }; 68 | 69 | var apiDescriptions = new[] 70 | { 71 | "filter-path/items", 72 | "filter-path-items", 73 | "other-path", 74 | "other-path-items", 75 | } 76 | .Select(path => ApiDescriptionTestData.CreateApiDescription(path: path)) 77 | .ToArray(); 78 | 79 | var apiExplorer = ApiDescriptionTestData.CreateApiExplorer(apiDescriptions); 80 | 81 | // Act 82 | var clients = new ClientModelBuilder(apiExplorer, options, assembly).GetClientCollection(); 83 | 84 | // Assert 85 | Assert.That(clients.SelectMany(c => c.EndpointMethods).Select(e => e.Path), 86 | Is.EquivalentTo(new[] { "other-path", "other-path-items" })); 87 | } 88 | 89 | [Test] 90 | public void IncludeSpecifiedPaths() 91 | { 92 | // Arrange 93 | var options = new GenerateClientOptions { IncludePaths = "filter-path" }; 94 | 95 | var apiDescriptions = new[] 96 | { 97 | "filter-path/items", 98 | "filter-path-items", 99 | "other-path", 100 | "other-path-items", 101 | } 102 | .Select(path => ApiDescriptionTestData.CreateApiDescription(path: path)) 103 | .ToArray(); 104 | 105 | var apiExplorer = ApiDescriptionTestData.CreateApiExplorer(apiDescriptions); 106 | 107 | // Act 108 | var clients = new ClientModelBuilder(apiExplorer, options, assembly).GetClientCollection(); 109 | 110 | // Assert 111 | Assert.That(clients.SelectMany(c => c.EndpointMethods).Select(e => e.Path), 112 | Is.EquivalentTo(new[] { "filter-path/items", "filter-path-items", })); 113 | } 114 | } 115 | 116 | #region Test Classes 117 | 118 | internal class FilterNameType { } 119 | internal class OtherNameType { } 120 | 121 | namespace FilterNameNamespace 122 | { 123 | internal class SomeType1 { } 124 | } 125 | 126 | namespace OtherNameNamespace 127 | { 128 | internal class SomeType2 { } 129 | } 130 | 131 | #endregion 132 | } 133 | -------------------------------------------------------------------------------- /Tests/GenerateAspNetCoreClient.Tests/GenerateAspNetCoreClient.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | 9 | all 10 | runtime; build; native; contentfiles; analyzers; buildtransitive 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | all 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Tests/OutputTest/OutputProject.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers.UseApiResponses/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using TestWebApi.Models; 3 | 4 | namespace TestWebApi.Controllers.UseApiResponses.Controllers 5 | { 6 | [ApiController] 7 | [Route("[controller]")] 8 | public class WeatherForecastController : ControllerBase 9 | { 10 | private static readonly string[] Summaries = new[] 11 | { 12 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 13 | }; 14 | 15 | private readonly ILogger _logger; 16 | 17 | public WeatherForecastController(ILogger logger) 18 | { 19 | _logger = logger; 20 | } 21 | 22 | [HttpGet] 23 | public IEnumerable Get() 24 | { 25 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 26 | { 27 | Date = DateTime.Now.AddDays(index), 28 | TemperatureC = Random.Shared.Next(-20, 55), 29 | Summary = Summaries[Random.Shared.Next(Summaries.Length)] 30 | }) 31 | .ToArray(); 32 | } 33 | 34 | [HttpPost("create")] 35 | public async Task Post(WeatherForecast weatherForecast) 36 | { 37 | await Task.Delay(10); 38 | return Ok(); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers.UseApiResponses/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = WebApplication.CreateBuilder(args); 2 | 3 | // Add services to the container. 4 | 5 | builder.Services.AddControllers(); 6 | 7 | var app = builder.Build(); 8 | 9 | // Configure the HTTP request pipeline. 10 | 11 | app.UseAuthorization(); 12 | 13 | app.MapControllers(); 14 | 15 | app.Run(); 16 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers.UseApiResponses/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:63945", 8 | "sslPort": 0 9 | } 10 | }, 11 | "profiles": { 12 | "TestWebApi.Controllers.UseApiResponses": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "weatherforecast", 17 | "applicationUrl": "http://localhost:5174", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "IIS Express": { 23 | "commandName": "IISExpress", 24 | "launchBrowser": true, 25 | "launchUrl": "weatherforecast", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers.UseApiResponses/TestWebApi.Controllers.UseApiResponses.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers.UseApiResponses/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers.UseApiResponses/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{cs,vb}] 2 | 3 | # IDE0060: Remove unused parameter 4 | dotnet_code_quality_unused_parameters = all:none 5 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/Controllers/AnotherTestController.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Mvc; 3 | using TestWebApi.Models; 4 | 5 | namespace TestWebApi.Controllers 6 | { 7 | [ApiController] 8 | [Route("[controller]")] 9 | public class AnotherTestController : ControllerBase 10 | { 11 | [HttpGet("with-query-model")] 12 | public async Task WithQueryModel([FromQuery] SomeQueryModel queryModel) 13 | { 14 | await Task.Delay(1); 15 | return Ok(); 16 | } 17 | 18 | [HttpGet("with-query-name")] 19 | public ActionResult WithQueryParameterName([FromQuery(Name = "currency")] string currencyName) 20 | { 21 | return Ok(); 22 | } 23 | 24 | [HttpGet("with-query-name-array")] 25 | public ActionResult WithQueryArrayParameterName([FromQuery(Name = "currencies")] string[] currencyNames) 26 | { 27 | return Ok(); 28 | } 29 | 30 | [HttpGet("with-query-enum")] 31 | public ActionResult WithQueryEnumParameter([FromQuery] SomeEnum enumParam) 32 | { 33 | return Ok(); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.DataAnnotations; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.Mvc; 9 | using TestWebApi.Models; 10 | 11 | namespace TestWebApi.Controllers; 12 | 13 | [ApiController] 14 | [Route("[controller]")] 15 | public class WeatherForecastController : ControllerBase 16 | { 17 | private static readonly string[] Summaries = new[] 18 | { 19 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 20 | }; 21 | 22 | [HttpGet] 23 | public IEnumerable Get() 24 | { 25 | var rng = new Random(); 26 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 27 | { 28 | Date = DateTime.Now.AddDays(index), 29 | TemperatureC = rng.Next(-20, 55), 30 | Summary = Summaries[rng.Next(Summaries.Length)] 31 | }) 32 | .ToArray(); 33 | } 34 | 35 | /// 36 | /// Get weather forecast by Id. 37 | /// 38 | /// some id. 39 | /// cancellation Token. 40 | /// with matching id. 41 | [HttpGet("{id}")] 42 | public WeatherForecast Get(Guid id, CancellationToken cancellationToken) 43 | { 44 | var rng = new Random(); 45 | return new WeatherForecast 46 | { 47 | Date = DateTime.Now.AddDays(6), 48 | TemperatureC = rng.Next(-20, 55), 49 | Summary = Summaries[rng.Next(Summaries.Length)] 50 | }; 51 | } 52 | 53 | [HttpPost("create")] 54 | public Task> Post(WeatherForecast weatherForecast) 55 | { 56 | return null; 57 | } 58 | 59 | [HttpPost("upload")] 60 | public Task Upload(IFormFile uploadedFile) 61 | { 62 | return null; 63 | } 64 | 65 | [HttpPost("upload-multiple")] 66 | public Task Upload(List uploadedFiles) 67 | { 68 | return null; 69 | } 70 | 71 | [HttpGet("download")] 72 | public Task Download() 73 | { 74 | var bb = new byte[1]; 75 | return Task.FromResult(File(bb, "application/pdf", "weather.pdf")); 76 | } 77 | 78 | [HttpPost("search")] 79 | public Task Search(string name = "test") 80 | { 81 | return null; 82 | } 83 | 84 | [HttpPost("{id}/queryParams")] 85 | public Task SomethingWithQueryParams(int id, int par1 = 2, [Required] string par2 = null, string par3 = null, string par4 = "1") 86 | { 87 | return null; 88 | } 89 | 90 | [HttpPatch("headerParams")] 91 | public async Task WithHeaderParams([FromHeader(Name = "x-header-name")] string headerParam) 92 | { 93 | await Task.Delay(1); 94 | return Ok(); 95 | } 96 | 97 | [HttpPost("form")] 98 | public async Task WithFormParam([FromForm] SomeQueryModel formParam) 99 | { 100 | await Task.Delay(1); 101 | return Ok(); 102 | } 103 | 104 | [HttpPost("form-collection")] 105 | public async Task WithFormCollectionParam([FromQuery] string queryParam, [FromForm] IFormCollection formParam) 106 | { 107 | await Task.Delay(1); 108 | return Ok(); 109 | } 110 | 111 | [HttpPost("form-with-file")] 112 | public async Task WithFormWithFileParam([FromQuery] string queryParam, [FromForm] FormModelWithFile formParam) 113 | { 114 | await Task.Delay(1); 115 | return Ok(); 116 | } 117 | 118 | [HttpPost("form-with-files")] 119 | public async Task WithFormWithFilesParam([FromQuery] string queryParam, [FromForm] FormModelWithMultipleFiles formParam) 120 | { 121 | await Task.Delay(1); 122 | return Ok(); 123 | } 124 | 125 | [HttpGet("record")] 126 | public async Task> WithRecordModels([FromQuery] RecordModel record) 127 | { 128 | await Task.Delay(1); 129 | return Ok(record); 130 | } 131 | } 132 | 133 | 134 | public record FormModelWithFile(IFormFile File, string Title); 135 | 136 | public record FormModelWithMultipleFiles(IFormFile[] Files, string Title); -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace TestWebApi 5 | { 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IHostBuilder CreateHostBuilder(string[] args) => 14 | Host.CreateDefaultBuilder(args) 15 | .ConfigureWebHostDefaults(webBuilder => 16 | { 17 | webBuilder.UseStartup(); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:56622", 8 | "sslPort": 44351 9 | } 10 | }, 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "weatherforecast", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "ApiExplorerTest": { 21 | "commandName": "Project", 22 | "launchBrowser": true, 23 | "launchUrl": "weatherforecast", 24 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/Startup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | 7 | namespace TestWebApi 8 | { 9 | public class Startup 10 | { 11 | public Startup(IConfiguration configuration) 12 | { 13 | Configuration = configuration; 14 | } 15 | 16 | public IConfiguration Configuration { get; } 17 | 18 | // This method gets called by the runtime. Use this method to add services to the container. 19 | public void ConfigureServices(IServiceCollection services) 20 | { 21 | services.AddControllers(); 22 | } 23 | 24 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 25 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 26 | { 27 | if (env.IsDevelopment()) 28 | { 29 | app.UseDeveloperExceptionPage(); 30 | } 31 | 32 | app.UseHttpsRedirection(); 33 | 34 | app.UseRouting(); 35 | 36 | app.UseAuthorization(); 37 | 38 | app.UseEndpoints(endpoints => 39 | { 40 | endpoints.MapControllers(); 41 | }); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/TestWebApi.Controllers.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Controllers/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Dtos/RecordModel.cs: -------------------------------------------------------------------------------- 1 | namespace TestWebApi.Models; 2 | 3 | public record RecordModel(Guid Id, string Name); 4 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Dtos/SomeEnum.cs: -------------------------------------------------------------------------------- 1 | namespace TestWebApi.Models 2 | { 3 | public enum SomeEnum 4 | { 5 | Value1, 6 | Value2, 7 | Value3 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Dtos/SomeQueryModel.cs: -------------------------------------------------------------------------------- 1 | namespace TestWebApi.Models 2 | { 3 | public class SomeQueryModel 4 | { 5 | public string Param1 { get; set; } 6 | public Guid? Param2 { get; set; } 7 | public int Param3 { get; set; } 8 | public SomeEnum Param4 { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Dtos/TestWebApi.Dtos.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Dtos/WeatherForecast.cs: -------------------------------------------------------------------------------- 1 | namespace TestWebApi.Models 2 | { 3 | public class WeatherForecast 4 | { 5 | public DateTime Date { get; set; } 6 | 7 | public int TemperatureC { get; set; } 8 | 9 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 10 | 11 | public string Summary { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Dtos/WeatherForecastRecord.cs: -------------------------------------------------------------------------------- 1 | namespace TestWebApi.Models 2 | { 3 | public record WeatherForecastRecord(DateTime Date, int TemperatureC, string Summary) 4 | { 5 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Tests/TestWebApi.MinimalApi/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using TestWebApi.Models; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | builder.Services.AddEndpointsApiExplorer(); 7 | // Add services to the container. 8 | 9 | var app = builder.Build(); 10 | 11 | // Configure the HTTP request pipeline. 12 | 13 | var summaries = new[] 14 | { 15 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 16 | }; 17 | 18 | var group = app 19 | .MapGroup("weather-forecast") 20 | .WithGroupName("WeatherForecast"); 21 | 22 | group.MapGet("/", () => 23 | { 24 | var forecast = Enumerable.Range(1, 5).Select(index => 25 | new WeatherForecastRecord 26 | ( 27 | DateTime.Now.AddDays(index), 28 | Random.Shared.Next(-20, 55), 29 | summaries[Random.Shared.Next(summaries.Length)] 30 | )) 31 | .ToArray(); 32 | return forecast; 33 | }); 34 | 35 | group.MapGet("with-name", ([FromQuery] int days) => 36 | { 37 | return new WeatherForecastRecord 38 | ( 39 | DateTime.Now.AddDays(5), 40 | Random.Shared.Next(-20, 55), 41 | summaries[Random.Shared.Next(summaries.Length)] 42 | ); 43 | }).WithName("GetSomeWeather"); 44 | 45 | app.MapPost("/weather-forecast", (WeatherForecast forecast) => Results.Ok()); 46 | app.Run(); -------------------------------------------------------------------------------- /Tests/TestWebApi.MinimalApi/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:12646", 8 | "sslPort": 0 9 | } 10 | }, 11 | "profiles": { 12 | "TestWebApi.MinimalApi": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5054", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "IIS Express": { 23 | "commandName": "IISExpress", 24 | "launchBrowser": true, 25 | "launchUrl": "swagger", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/TestWebApi.MinimalApi/TestWebApi.MinimalApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | all 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Tests/TestWebApi.MinimalApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Tests/TestWebApi.MinimalApi/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Versioning/Controllers/VersionController.cs: -------------------------------------------------------------------------------- 1 | using Asp.Versioning; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace TestWebApi.Versioning.Controllers 5 | { 6 | [Route("api/[controller]")] 7 | [ApiController] 8 | [ApiVersion("1.0", Deprecated = true)] 9 | [ApiVersion("2.0", Deprecated = true)] 10 | [ApiVersion("1.5", Deprecated = true)] 11 | [ApiVersion("3.0")] 12 | [ApiVersion("4.0")] 13 | public class VersionController : ControllerBase 14 | { 15 | [HttpGet] 16 | public IActionResult Get() 17 | { 18 | return Ok("test2"); 19 | } 20 | 21 | [HttpGet] 22 | [MapToApiVersion("3.0")] 23 | public IActionResult Get3() 24 | { 25 | return Ok("test3"); 26 | } 27 | 28 | [HttpGet] 29 | [MapToApiVersion("4.0")] 30 | public IActionResult Get4([FromServices] ApiVersion apiVersion) 31 | { 32 | return Ok("test" + apiVersion.MajorVersion); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /Tests/TestWebApi.Versioning/Program.cs: -------------------------------------------------------------------------------- 1 | using Asp.Versioning; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | 5 | // Add services to the container. 6 | 7 | builder.Services.AddControllers(); 8 | 9 | builder.Services 10 | .AddApiVersioning(setup => 11 | { 12 | setup.DefaultApiVersion = new ApiVersion(1, 0); 13 | setup.AssumeDefaultVersionWhenUnspecified = true; 14 | setup.ReportApiVersions = true; 15 | setup.ApiVersionReader = new HeaderApiVersionReader("Api-Version"); 16 | }) 17 | .AddMvc() 18 | .AddApiExplorer(setup => 19 | { 20 | setup.GroupNameFormat = "'v'VVV"; 21 | }); 22 | 23 | var app = builder.Build(); 24 | 25 | // Configure the HTTP request pipeline. 26 | 27 | app.UseAuthorization(); 28 | 29 | app.MapControllers(); 30 | 31 | app.Run(); 32 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Versioning/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:12646", 8 | "sslPort": 0 9 | } 10 | }, 11 | "profiles": { 12 | "TestWebApi.Versioning": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "version", 17 | "applicationUrl": "http://localhost:5054", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "IIS Express": { 23 | "commandName": "IISExpress", 24 | "launchBrowser": true, 25 | "launchUrl": "swagger", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /Tests/TestWebApi.Versioning/TestWebApi.Versioning.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Versioning/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Tests/TestWebApi.Versioning/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | --------------------------------------------------------------------------------