Browse Source

OpenAPI ServiceReference Tool (#12810)

Add ServiceReference tool
Ryan Brandenburg 6 years ago
parent
commit
cfcffd8251
53 changed files with 3657 additions and 94 deletions
  1. 7 7
      .vscode/settings.json
  2. 1 0
      eng/Build.props
  3. 3 0
      eng/Dependencies.props
  4. 6 0
      eng/Signing.props
  5. 3 0
      eng/Versions.props
  6. 39 24
      src/Shared/Process/ProcessExtensions.cs
  7. 84 0
      src/Tools/Microsoft.dotnet-openapi/README.md
  8. 104 0
      src/Tools/Microsoft.dotnet-openapi/src/Application.cs
  9. 11 0
      src/Tools/Microsoft.dotnet-openapi/src/CodeGenerator.cs
  10. 34 0
      src/Tools/Microsoft.dotnet-openapi/src/Commands/AddCommand.cs
  11. 81 0
      src/Tools/Microsoft.dotnet-openapi/src/Commands/AddFileCommand.cs
  12. 57 0
      src/Tools/Microsoft.dotnet-openapi/src/Commands/AddProjectCommand.cs
  13. 59 0
      src/Tools/Microsoft.dotnet-openapi/src/Commands/AddURLCommand.cs
  14. 538 0
      src/Tools/Microsoft.dotnet-openapi/src/Commands/BaseCommand.cs
  15. 67 0
      src/Tools/Microsoft.dotnet-openapi/src/Commands/RefreshCommand.cs
  16. 78 0
      src/Tools/Microsoft.dotnet-openapi/src/Commands/RemoveCommand.cs
  17. 27 0
      src/Tools/Microsoft.dotnet-openapi/src/DebugMode.cs
  18. 72 0
      src/Tools/Microsoft.dotnet-openapi/src/HttpClientWrapper.cs
  19. 14 0
      src/Tools/Microsoft.dotnet-openapi/src/IHttpClientWrapper.cs
  20. 19 0
      src/Tools/Microsoft.dotnet-openapi/src/IHttpResponseMessageWrapper.cs
  21. 25 0
      src/Tools/Microsoft.dotnet-openapi/src/Internal/OpenapiDependencyAttribute.cs
  22. 36 0
      src/Tools/Microsoft.dotnet-openapi/src/Microsoft.dotnet-openapi.csproj
  23. 53 0
      src/Tools/Microsoft.dotnet-openapi/src/Program.cs
  24. 23 0
      src/Tools/Microsoft.dotnet-openapi/src/ProjectExtensions.cs
  25. 6 0
      src/Tools/Microsoft.dotnet-openapi/src/Properties/AssemblyInfo.cs
  26. 254 0
      src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddFileTests.cs
  27. 123 0
      src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddProjectTests.cs
  28. 493 0
      src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddURLTests.cs
  29. 48 0
      src/Tools/Microsoft.dotnet-openapi/test/OpenApiRefreshTests.cs
  30. 201 0
      src/Tools/Microsoft.dotnet-openapi/test/OpenApiRemoveTests.cs
  31. 183 0
      src/Tools/Microsoft.dotnet-openapi/test/OpenApiTestBase.cs
  32. 146 0
      src/Tools/Microsoft.dotnet-openapi/test/ProcessEx.cs
  33. 6 0
      src/Tools/Microsoft.dotnet-openapi/test/Properties/AssemblyInfo.cs
  34. 46 0
      src/Tools/Microsoft.dotnet-openapi/test/TestContent/Startup.cs.txt
  35. 514 0
      src/Tools/Microsoft.dotnet-openapi/test/TestContent/openapi.json.txt
  36. 51 0
      src/Tools/Microsoft.dotnet-openapi/test/dotnet-microsoft.openapi.Tests.csproj
  37. 5 0
      src/Tools/Microsoft.dotnet-openapi/test/xunit.runner.json
  38. 14 7
      src/Tools/README.md
  39. 1 0
      src/Tools/Shared/CommandLine/CommandLineApplicationExtensions.cs
  40. 1 1
      src/Tools/Shared/CommandLine/Ensure.cs
  41. 10 7
      src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs
  42. 16 6
      src/Tools/Shared/TestHelpers/TemporaryDirectory.cs
  43. 44 4
      src/Tools/Tools.sln
  44. 3 0
      src/Tools/build.cmd
  45. 7 0
      src/Tools/build.sh
  46. 4 4
      src/Tools/dotnet-dev-certs/src/Program.cs
  47. 7 5
      src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs
  48. 7 7
      src/Tools/dotnet-watch/src/Program.cs
  49. 2 2
      src/Tools/dotnet-watch/src/dotnet-watch.csproj
  50. 0 2
      src/Tools/dotnet-watch/test/AssertEx.cs
  51. 19 13
      src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs
  52. 4 4
      src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs
  53. 1 1
      src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj

+ 7 - 7
.vscode/settings.json

@@ -1,9 +1,9 @@
 {
-    "files.trimTrailingWhitespace": true,
-    "files.associations": {
-        "*.*proj": "xml",
-        "*.props": "xml",
-        "*.targets": "xml",
-        "*.tasks": "xml"
-    }
+  "files.trimTrailingWhitespace": true,
+  "files.associations": {
+    "*.*proj": "xml",
+    "*.props": "xml",
+    "*.targets": "xml",
+    "*.tasks": "xml"
+  }
 }

+ 1 - 0
eng/Build.props

@@ -28,6 +28,7 @@
     <!-- These projects are meant to be executed by tests. -->
     <ProjectToExclude Include="
                       $(RepoRoot)src\Tools\dotnet-watch\test\TestProjects\**\*.csproj;
+                      $(RepoRoot)src\Tools\Tests.Common\TestProjects\**\*.csproj;
                       $(RepoRoot)src\Razor\Razor.Design\test\testassets\**\*.*proj;
                       $(RepoRoot)src\submodules\**\*.*proj;
                       $(RepoRoot)src\Installers\**\*.*proj;

+ 3 - 0
eng/Dependencies.props

@@ -149,7 +149,9 @@ and are generated based on the last package release.
   </ItemGroup>
 
   <ItemGroup Label="MSBuild">
+    <LatestPackageReference Include="Microsoft.Build" Version="$(MicrosoftBuildPackageVersion)" />
     <LatestPackageReference Include="Microsoft.Build.Framework" Version="$(MicrosoftBuildFrameworkPackageVersion)" />
+    <LatestPackageReference Include="Microsoft.Build.Locator" Version="$(MicrosoftBuildLocatorPackageVersion)" />
     <LatestPackageReference Include="Microsoft.Build.Utilities.Core" Version="$(MicrosoftBuildUtilitiesCorePackageVersion)" />
   </ItemGroup>
 
@@ -172,6 +174,7 @@ and are generated based on the last package release.
     <LatestPackageReference Include="Moq" Version="$(MoqPackageVersion)" />
     <LatestPackageReference Include="Newtonsoft.Json" Version="$(NewtonsoftJsonPackageVersion)" />
     <LatestPackageReference Include="Newtonsoft.Json.Bson" Version="$(NewtonsoftJsonBsonPackageVersion)" />
+    <LatestPackageReference Include="NSwag.ApiDescription.Client" Version="$(NSwagApiDescriptionClientPackageVersion)" />
     <LatestPackageReference Include="Selenium.Support" Version="$(SeleniumSupportPackageVersion)" />
     <LatestPackageReference Include="Selenium.WebDriver" Version="$(SeleniumWebDriverPackageVersion)" />
     <LatestPackageReference Include="Selenium.WebDriver.ChromeDriver" Version="$(SeleniumWebDriverChromeDriverPackageVersion)" />

+ 6 - 0
eng/Signing.props

@@ -97,6 +97,12 @@
     <_DotNetFilesToExclude Include="$(RedistNetCorePath)dotnet.exe" CertificateName="None" />
     <FileSignInfo Include="@(_DotNetFilesToExclude->'%(FileName)%(Extension)'->Distinct())" CertificateName="None" />
 
+    <!--
+      We include the Microsoft.Build.Locator.dll assembly in our global tool 'Microsoft.dotnet-openapi'.
+      It is already signed by that team, so we don't need to sign it.
+    -->
+    <FileSignInfo Include="Microsoft.Build.Locator.dll" CertificateName="None" />
+
     <!--
       We include the Microsoft.Data.SqlClient.dll assembly in our global tool 'dotnet-sql-cache'.
       It is already signed by that team, so we don't need to sign it.

+ 3 - 0
eng/Versions.props

@@ -196,7 +196,9 @@
     <!-- Partner teams -->
     <MicrosoftAzureKeyVaultPackageVersion>2.3.2</MicrosoftAzureKeyVaultPackageVersion>
     <MicrosoftAzureStorageBlobPackageVersion>10.0.1</MicrosoftAzureStorageBlobPackageVersion>
+    <MicrosoftBuildPackageVersion>15.8.166</MicrosoftBuildPackageVersion>
     <MicrosoftBuildFrameworkPackageVersion>15.8.166</MicrosoftBuildFrameworkPackageVersion>
+    <MicrosoftBuildLocatorPackageVersion>1.2.6</MicrosoftBuildLocatorPackageVersion>
     <MicrosoftBuildUtilitiesCorePackageVersion>15.8.166</MicrosoftBuildUtilitiesCorePackageVersion>
     <MicrosoftCodeAnalysisCommonPackageVersion>3.0.0</MicrosoftCodeAnalysisCommonPackageVersion>
     <MicrosoftCodeAnalysisCSharpPackageVersion>3.0.0</MicrosoftCodeAnalysisCSharpPackageVersion>
@@ -232,6 +234,7 @@
     <MonoCecilPackageVersion>0.10.1</MonoCecilPackageVersion>
     <NewtonsoftJsonBsonPackageVersion>1.0.2</NewtonsoftJsonBsonPackageVersion>
     <NewtonsoftJsonPackageVersion>12.0.1</NewtonsoftJsonPackageVersion>
+    <NSwagApiDescriptionClientPackageVersion>13.0.4</NSwagApiDescriptionClientPackageVersion>
     <SeleniumSupportPackageVersion>3.12.1</SeleniumSupportPackageVersion>
     <SeleniumWebDriverMicrosoftDriverPackageVersion>17.17134.0</SeleniumWebDriverMicrosoftDriverPackageVersion>
     <SeleniumWebDriverChromeDriverPackageVersion>2.43.0</SeleniumWebDriverChromeDriverPackageVersion>

+ 39 - 24
src/Shared/Process/ProcessExtensions.cs

@@ -1,8 +1,9 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// Copyright (c) .NET Foundation. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
 using System.Collections.Generic;
+using System.ComponentModel;
 using System.Diagnostics;
 using System.IO;
 using System.Runtime.InteropServices;
@@ -41,42 +42,56 @@ namespace Microsoft.Extensions.Internal
 
         private static void GetAllChildIdsUnix(int parentId, ISet<int> children, TimeSpan timeout)
         {
-            RunProcessAndWaitForExit(
-                "pgrep",
-                $"-P {parentId}",
-                timeout,
-                out var stdout);
-
-            if (!string.IsNullOrEmpty(stdout))
+            try
             {
-                using (var reader = new StringReader(stdout))
+                RunProcessAndWaitForExit(
+                    "pgrep",
+                    $"-P {parentId}",
+                    timeout,
+                    out var stdout);
+
+                if (!string.IsNullOrEmpty(stdout))
                 {
-                    while (true)
+                    using (var reader = new StringReader(stdout))
                     {
-                        var text = reader.ReadLine();
-                        if (text == null)
+                        while (true)
                         {
-                            return;
-                        }
+                            var text = reader.ReadLine();
+                            if (text == null)
+                            {
+                                return;
+                            }
 
-                        if (int.TryParse(text, out var id))
-                        {
-                            children.Add(id);
-                            // Recursively get the children
-                            GetAllChildIdsUnix(id, children, timeout);
+                            if (int.TryParse(text, out var id))
+                            {
+                                children.Add(id);
+                                // Recursively get the children
+                                GetAllChildIdsUnix(id, children, timeout);
+                            }
                         }
                     }
                 }
             }
+            catch (Win32Exception ex) when (ex.Message.Contains("No such file or directory"))
+            {
+                // This probably means that pgrep isn't installed. Nothing to be done?
+            }
         }
 
         private static void KillProcessUnix(int processId, TimeSpan timeout)
         {
-            RunProcessAndWaitForExit(
-                "kill",
-                $"-TERM {processId}",
-                timeout,
-                out var stdout);
+            try
+            {
+                RunProcessAndWaitForExit(
+                    "kill",
+                    $"-TERM {processId}",
+                    timeout,
+                    out var stdout);
+            }
+            catch (Win32Exception ex) when (ex.Message.Contains("No such file or directory"))
+            {
+                // This probably means that the process is already dead
+            }
         }
 
         private static void RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string stdout)

+ 84 - 0
src/Tools/Microsoft.dotnet-openapi/README.md

@@ -0,0 +1,84 @@
+# Microsoft.dotnet-openapi
+
+`Microsoft.dotnet-openapi` is a tool for managing OpenAPI references within your project.
+
+## Commands
+
+### Add Commands
+
+<!-- TODO: Restore after https://github.com/aspnet/AspNetCore/issues/12738
+ #### Add Project
+
+##### Options
+
+| Short option | Long option | Description | Example |
+|-------|------|-------|---------|
+| -v|--verbose | Show verbose output. |dotnet openapi add project *-v* ../Ref/ProjRef.csproj |
+| -p|--project | The project to operate on. |dotnet openapi add project *--project .\Ref.csproj* ../Ref/ProjRef.csproj |
+
+##### Arguments
+
+|  Argument  | Description | Example |
+|-------------|-------------|---------|
+| source-file | The source to create a reference from. Must be a project file. |dotnet openapi add project *../Ref/ProjRef.csproj* | -->
+
+#### Add File
+
+##### Options
+
+| Short option| Long option| Description | Example |
+|-------|------|-------|---------|
+| -v|--verbose | Show verbose output. |dotnet openapi add file *-v* .\OpenAPI.json |
+| -p|--updateProject | The project to operate on. |dotnet openapi add file *--updateProject .\Ref.csproj* .\OpenAPI.json |
+
+##### Arguments
+
+|  Argument  | Description | Example |
+|-------------|-------------|---------|
+| source-file | The source to create a reference from. Must be an OpenAPI file. |dotnet openapi add file *.\OpenAPI.json* |
+
+#### Add URL
+
+##### Options
+
+| Short option| Long option| Description | Example |
+|-------|------|-------------|---------|
+| -v|--verbose | Show verbose output. |dotnet openapi add url *-v* <http://contoso.com/openapi.json> |
+| -p|--updateProject | The project to operate on. |dotnet openapi add url *--updateProject .\Ref.csproj* <http://contoso.com/openapi.json> |
+| -o|--output-file | Where to place the local copy of the OpenAPI file. |dotnet openapi add url <https://contoso.com/openapi.json> *--output-file myclient.json* |
+
+##### Arguments
+
+|  Argument  | Description | Example |
+|-------------|-------------|---------|
+| source-file | The source to create a reference from. Must be a URL. |dotnet openapi add url <https://contoso.com/openapi.json> |
+
+### Remove
+
+##### Options
+
+| Short option| Long option| Description| Example |
+|-------|------|------------|---------|
+| -v|--verbose | Show verbose output. |dotnet openapi remove *-v*|
+| -p|--updateProject | The project to operate on. |dotnet openapi remove *--updateProject .\Ref.csproj* .\OpenAPI.json |
+
+#### Arguments
+
+|  Argument  | Description| Example |
+| ------------|------------|---------|
+| source-file | The source to remove the reference to. |dotnet openapi remove *.\OpenAPI.json* |
+
+### Refresh
+
+#### Options
+
+| Short option| Long option| Description | Example |
+|-------|------|-------------|---------|
+| -v|--verbose | Show verbose output. | dotnet openapi refresh *-v* <https://contoso.com/openapi.json> |
+| -p|--updateProject | The project to operate on. | dotnet openapi refresh *--updateProject .\Ref.csproj* <https://contoso.com/openapi.json> |
+
+#### Arguments
+
+|  Argument  | Description | Example |
+| ------------|-------------|---------|
+| source-file | The URL to refresh the reference from. | dotnet openapi refresh *<https://contoso.com/openapi.json>* |

+ 104 - 0
src/Tools/Microsoft.dotnet-openapi/src/Application.cs

@@ -0,0 +1,104 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Reflection;
+using Microsoft.Build.Locator;
+using Microsoft.DotNet.Openapi.Tools;
+using Microsoft.DotNet.OpenApi.Commands;
+using Microsoft.Extensions.CommandLineUtils;
+
+namespace Microsoft.DotNet.OpenApi
+{
+    internal class Application : CommandLineApplication
+    {
+        static Application()
+        {
+            MSBuildLocator.RegisterDefaults();
+        }
+
+        public Application(
+            string workingDirectory,
+            IHttpClientWrapper httpClient,
+            TextWriter output = null,
+            TextWriter error = null)
+        {
+            Out = output ?? Out;
+            Error = error ?? Error;
+
+            WorkingDirectory = workingDirectory;
+
+            Name = "openapi";
+            FullName = "OpenApi reference management tool";
+            Description = "OpenApi reference management operations.";
+            ShortVersionGetter = GetInformationalVersion;
+
+            Help = HelpOption("-?|-h|--help");
+            Help.Inherited = true;
+
+            Invoke = () =>
+            {
+                ShowHelp();
+                return 0;
+            };
+
+            Commands.Add(new AddCommand(this, httpClient));
+            Commands.Add(new RemoveCommand(this, httpClient));
+            Commands.Add(new RefreshCommand(this, httpClient));
+        }
+
+        public string WorkingDirectory { get; }
+
+        public CommandOption Help { get; }
+
+        public new int Execute(params string[] args)
+        {
+            try
+            {
+                return base.Execute(args);
+            }
+            catch (AggregateException ex) when (ex.InnerException != null)
+            {
+                foreach (var innerException in ex.InnerExceptions)
+                {
+                    Error.WriteLine(ex.InnerException.Message);
+                }
+                return 1;
+            }
+
+            catch (ArgumentException ex)
+            {
+                // Don't show a call stack when we have unneeded arguments, just print the error message.
+                // The code that throws this exception will print help, so no need to do it here.
+                Error.WriteLine(ex.Message);
+                return 1;
+            }
+            catch (CommandParsingException ex)
+            {
+                // Don't show a call stack when we have unneeded arguments, just print the error message.
+                // The code that throws this exception will print help, so no need to do it here.
+                Error.WriteLine(ex.Message);
+                return 1;
+            }
+            catch (OperationCanceledException)
+            {
+                // This is a cancellation, not a failure.
+                Error.WriteLine("Cancelled");
+                return 1;
+            }
+            catch (Exception ex)
+            {
+                Error.WriteLine(ex);
+                return 1;
+            }
+        }
+
+        private string GetInformationalVersion()
+        {
+            var assembly = typeof(Application).GetTypeInfo().Assembly;
+            var attribute = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
+            return attribute.InformationalVersion;
+        }
+    }
+}

+ 11 - 0
src/Tools/Microsoft.dotnet-openapi/src/CodeGenerator.cs

@@ -0,0 +1,11 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.DotNet.OpenApi
+{
+    public enum CodeGenerator
+    {
+        NSwagCSharp,
+        NSwagTypeScript
+    }
+}

+ 34 - 0
src/Tools/Microsoft.dotnet-openapi/src/Commands/AddCommand.cs

@@ -0,0 +1,34 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Threading.Tasks;
+using Microsoft.DotNet.Openapi.Tools;
+
+namespace Microsoft.DotNet.OpenApi.Commands
+{
+    internal class AddCommand : BaseCommand
+    {
+        private const string CommandName = "add";
+
+        public AddCommand(Application parent, IHttpClientWrapper httpClient)
+            : base(parent, CommandName, httpClient)
+        {
+            Commands.Add(new AddFileCommand(this, httpClient));
+            //TODO: Add AddprojectComand here: https://github.com/aspnet/AspNetCore/issues/12738
+            Commands.Add(new AddURLCommand(this, httpClient));
+        }
+
+        internal new Application Parent => (Application)base.Parent;
+
+        protected override Task<int> ExecuteCoreAsync()
+        {
+            ShowHelp();
+            return Task.FromResult(0);
+        }
+
+        protected override bool ValidateArguments()
+        {
+            return true;
+        }
+    }
+}

+ 81 - 0
src/Tools/Microsoft.dotnet-openapi/src/Commands/AddFileCommand.cs

@@ -0,0 +1,81 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.DotNet.Openapi.Tools;
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.DotNet.OpenApi.Commands
+{
+    internal class AddFileCommand : BaseCommand
+    {
+        private const string CommandName = "file";
+
+        private const string SourceFileArgName = "source-file";
+
+        public AddFileCommand(AddCommand parent, IHttpClientWrapper httpClient)
+            : base(parent, CommandName, httpClient)
+        {
+            _codeGeneratorOption = Option("-c|--code-generator", "The code generator to use. Defaults to 'NSwagCSharp'.", CommandOptionType.SingleValue);
+            _sourceFileArg = Argument(SourceFileArgName, $"The OpenAPI file to add. This must be a path to local OpenAPI file(s)", multipleValues: true);
+        }
+
+        internal readonly CommandArgument _sourceFileArg;
+        internal readonly CommandOption _codeGeneratorOption;
+
+        private readonly string[] ApprovedExtensions = new[] { ".json", ".yaml", ".yml" };
+
+        protected override async Task<int> ExecuteCoreAsync()
+        {
+            var projectFilePath = ResolveProjectFile(ProjectFileOption);
+
+            Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceFileArgName);
+            var codeGenerator = GetCodeGenerator(_codeGeneratorOption);
+
+            foreach (var sourceFile in _sourceFileArg.Values)
+            {
+                if (!ApprovedExtensions.Any(e => sourceFile.EndsWith(e)))
+                {
+                    await Warning.WriteLineAsync($"The extension for the given file '{sourceFile}' should have been one of: {string.Join(",", ApprovedExtensions)}.");
+                    await Warning.WriteLineAsync($"The reference has been added, but may fail at build-time if the format is not correct.");
+                }
+                await AddOpenAPIReference(OpenApiReference, projectFilePath, sourceFile, codeGenerator);
+            }
+
+            return 0;
+        }
+
+        private bool IsLocalFile(string file)
+        {
+            return File.Exists(GetFullPath(file));
+        }
+
+        protected override bool ValidateArguments()
+        {
+            ValidateCodeGenerator(_codeGeneratorOption);
+
+            try
+            {
+                Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceFileArgName);
+            }
+            catch(ArgumentException ex)
+            {
+                Error.Write(ex.Message);
+                return false;
+            }
+
+            foreach (var sourceFile in _sourceFileArg.Values)
+            {
+                if (!IsLocalFile(sourceFile))
+                {
+                    Error.Write($"{SourceFileArgName} of '{sourceFile}' could not be found.");
+                }
+            }
+            return true;
+        }
+    }
+}

+ 57 - 0
src/Tools/Microsoft.dotnet-openapi/src/Commands/AddProjectCommand.cs

@@ -0,0 +1,57 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.DotNet.Openapi.Tools;
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.DotNet.OpenApi.Commands
+{
+    internal class AddProjectCommand : BaseCommand
+    {
+        private const string CommandName = "project";
+
+        private const string SourceProjectArgName = "source-project";
+
+        public AddProjectCommand(BaseCommand parent, IHttpClientWrapper httpClient)
+            : base(parent, CommandName, httpClient)
+        {
+            _codeGeneratorOption = Option("-c|--code-generator", "The code generator to use. Defaults to 'NSwagCSharp'.", CommandOptionType.SingleValue);
+            _sourceProjectArg = Argument(SourceProjectArgName, $"The OpenAPI project to add. This must be the path to project file(s) containing OpenAPI endpoints", multipleValues: true);
+        }
+
+        internal readonly CommandArgument _sourceProjectArg;
+        internal readonly CommandOption _codeGeneratorOption;
+
+        protected override async Task<int> ExecuteCoreAsync()
+        {
+            var projectFilePath = ResolveProjectFile(ProjectFileOption);
+
+            var codeGenerator = GetCodeGenerator(_codeGeneratorOption);
+
+            foreach (var sourceFile in _sourceProjectArg.Values)
+            {
+                await AddOpenAPIReference(OpenApiProjectReference, projectFilePath, sourceFile, codeGenerator);
+            }
+
+            return 0;
+        }
+
+        protected override bool ValidateArguments()
+        {
+            ValidateCodeGenerator(_codeGeneratorOption);
+            foreach (var sourceFile in _sourceProjectArg.Values)
+            {
+                if (!IsProjectFile(sourceFile))
+                {
+                    throw new ArgumentException($"{SourceProjectArgName} of '{sourceFile}' was not valid. Valid values must be project file(s)");
+                }
+            }
+
+            Ensure.NotNullOrEmpty(_sourceProjectArg.Value, SourceProjectArgName);
+            return true;
+        }
+    }
+}

+ 59 - 0
src/Tools/Microsoft.dotnet-openapi/src/Commands/AddURLCommand.cs

@@ -0,0 +1,59 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.DotNet.Openapi.Tools;
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.DotNet.OpenApi.Commands
+{
+    internal class AddURLCommand : BaseCommand
+    {
+        private const string CommandName = "url";
+
+        private const string OutputFileName = "--output-file";
+        private const string SourceUrlArgName = "source-URL";
+
+        public AddURLCommand(AddCommand parent, IHttpClientWrapper httpClient)
+            : base(parent, CommandName, httpClient)
+        {
+            _codeGeneratorOption = Option("-c|--code-generator", "The code generator to use. Defaults to 'NSwagCSharp'.", CommandOptionType.SingleValue);
+            _outputFileOption = Option(OutputFileName, "The destination to download the remote OpenAPI file to.", CommandOptionType.SingleValue);
+            _sourceFileArg = Argument(SourceUrlArgName, $"The OpenAPI file to add. This must be a URL to a remote OpenAPI file.", multipleValues: true);
+        }
+
+        internal readonly CommandOption _outputFileOption;
+
+        internal readonly CommandArgument _sourceFileArg;
+        internal readonly CommandOption _codeGeneratorOption;
+
+        protected override async Task<int> ExecuteCoreAsync()
+        {
+            var projectFilePath = ResolveProjectFile(ProjectFileOption);
+
+            var sourceFile = Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceUrlArgName);
+            var codeGenerator = GetCodeGenerator(_codeGeneratorOption);
+
+            // We have to download the file from that URL, save it to a local file, then create a OpenApiReference
+            var outputFile = await DownloadGivenOption(sourceFile, _outputFileOption);
+
+            await AddOpenAPIReference(OpenApiReference, projectFilePath, outputFile, codeGenerator, sourceFile);
+
+            return 0;
+        }
+
+        protected override bool ValidateArguments()
+        {
+            ValidateCodeGenerator(_codeGeneratorOption);
+            var sourceFile = Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceUrlArgName);
+            if (!IsUrl(sourceFile))
+            {
+                Error.Write($"{SourceUrlArgName} was not valid. Valid values are URLs");
+                return false;
+            }
+            return true;
+        }
+    }
+}

+ 538 - 0
src/Tools/Microsoft.dotnet-openapi/src/Commands/BaseCommand.cs

@@ -0,0 +1,538 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Build.Evaluation;
+using Microsoft.DotNet.Openapi.Tools;
+using Microsoft.DotNet.Openapi.Tools.Internal;
+using Microsoft.Extensions.CommandLineUtils;
+
+namespace Microsoft.DotNet.OpenApi.Commands
+{
+    internal abstract class BaseCommand : CommandLineApplication
+    {
+        protected string WorkingDirectory;
+
+        protected readonly IHttpClientWrapper _httpClient;
+
+        public const string OpenApiReference = "OpenApiReference";
+        public const string OpenApiProjectReference = "OpenApiProjectReference";
+        protected const string SourceUrlAttrName = "SourceUrl";
+
+        public const string ContentDispositionHeaderName = "Content-Disposition";
+        private const string CodeGeneratorAttrName = "CodeGenerator";
+        private const string DefaultExtension = ".json";
+
+        internal const string PackageVersionUrl = "https://go.microsoft.com/fwlink/?linkid=2099561";
+
+        public BaseCommand(CommandLineApplication parent, string name, IHttpClientWrapper httpClient)
+        {
+            Parent = parent;
+            Name = name;
+            Out = parent.Out ?? Out;
+            Error = parent.Error ?? Error;
+            _httpClient = httpClient;
+
+            ProjectFileOption = Option("-p|--updateProject", "The project file update.", CommandOptionType.SingleValue);
+
+            if (Parent is Application)
+            {
+                WorkingDirectory = ((Application)Parent).WorkingDirectory;
+            }
+            else
+            {
+                WorkingDirectory = ((Application)Parent.Parent).WorkingDirectory;
+            }
+
+            OnExecute(ExecuteAsync);
+        }
+
+        public CommandOption ProjectFileOption { get; }
+
+        public TextWriter Warning
+        {
+            get { return Out; }
+        }
+
+        protected abstract Task<int> ExecuteCoreAsync();
+
+        protected abstract bool ValidateArguments();
+
+        private async Task<int> ExecuteAsync()
+        {
+            if (GetApplication().Help.HasValue())
+            {
+                ShowHelp();
+                return 0;
+            }
+
+            if (!ValidateArguments())
+            {
+                ShowHelp();
+                return 1;
+            }
+
+            return await ExecuteCoreAsync();
+        }
+
+        private Application GetApplication()
+        {
+            var parent = Parent;
+            while(!(parent is Application))
+            {
+                parent = parent.Parent;
+            }
+            return (Application)parent;
+        }
+
+        internal FileInfo ResolveProjectFile(CommandOption projectOption)
+        {
+            string project;
+            if (projectOption.HasValue())
+            {
+                project = projectOption.Value();
+                project = GetFullPath(project);
+                if (!File.Exists(project))
+                {
+                    throw new ArgumentException($"The project '{project}' does not exist.");
+                }
+            }
+            else
+            {
+                var projects = Directory.GetFiles(WorkingDirectory, "*.csproj", SearchOption.TopDirectoryOnly);
+                if (projects.Length == 0)
+                {
+                    throw new ArgumentException("No project files were found in the current directory. Either move to a new directory or provide the project explicitly");
+                }
+                if (projects.Length > 1)
+                {
+                    throw new ArgumentException("More than one project was found in this directory, either remove a duplicate or explicitly provide the project.");
+                }
+
+                project = projects[0];
+            }
+
+            return new FileInfo(project);
+        }
+
+        protected Project LoadProject(FileInfo projectFile)
+        {
+            var project = ProjectCollection.GlobalProjectCollection.LoadProject(
+                projectFile.FullName,
+                globalProperties: null,
+                toolsVersion: null);
+            project.ReevaluateIfNecessary();
+            return project;
+        }
+
+        internal bool IsProjectFile(string file)
+        {
+            return File.Exists(Path.GetFullPath(file)) && file.EndsWith(".csproj");
+        }
+
+        internal bool IsUrl(string file)
+        {
+            return Uri.TryCreate(file, UriKind.Absolute, out var _) && file.StartsWith("http");
+        }
+
+        internal async Task AddOpenAPIReference(
+            string tagName,
+            FileInfo projectFile,
+            string sourceFile,
+            CodeGenerator? codeGenerator,
+            string sourceUrl = null)
+        {
+            // EnsurePackagesInProjectAsync MUST happen before LoadProject, because otherwise the global state set by ProjectCollection doesn't pick up the nuget edits, and we end up losing them.
+            await EnsurePackagesInProjectAsync(projectFile, codeGenerator);
+            var project = LoadProject(projectFile);
+            var items = project.GetItems(tagName);
+            var fileItems = items.Where(i => string.Equals(GetFullPath(i.EvaluatedInclude), GetFullPath(sourceFile), StringComparison.Ordinal));
+
+            if (fileItems.Count() > 0)
+            {
+                Warning.Write($"One or more references to {sourceFile} already exist in '{project.FullPath}'. Duplicate references could lead to unexpected behavior.");
+                return;
+            }
+
+            if (sourceUrl != null)
+            {
+                if (items.Any(i => string.Equals(i.GetMetadataValue(SourceUrlAttrName), sourceUrl)))
+                {
+                    Warning.Write($"A reference to '{sourceUrl}' already exists in '{project.FullPath}'.");
+                    return;
+                }
+            }
+
+            var metadata = new Dictionary<string, string>();
+
+            if (!string.IsNullOrEmpty(sourceUrl))
+            {
+                metadata[SourceUrlAttrName] = sourceUrl;
+            }
+
+            if (codeGenerator != null)
+            {
+                metadata[CodeGeneratorAttrName] = codeGenerator.ToString();
+            }
+
+            project.AddElementWithAttributes(tagName, sourceFile, metadata);
+            project.Save();
+        }
+
+        private async Task EnsurePackagesInProjectAsync(FileInfo projectFile, CodeGenerator? codeGenerator)
+        {
+            var urlPackages = await LoadPackageVersionsFromURLAsync();
+            var attributePackages = GetServicePackages(codeGenerator);
+
+            foreach (var kvp in attributePackages)
+            {
+                var packageId = kvp.Key;
+                var version = urlPackages != null && urlPackages.ContainsKey(packageId) ? urlPackages[packageId] : kvp.Value;
+
+                var args = new[] {
+                    "add",
+                    "package",
+                    packageId,
+                    "--version",
+                    version,
+                    "--no-restore"
+                };
+
+                var muxer = DotNetMuxer.MuxerPathOrDefault();
+                if (string.IsNullOrEmpty(muxer))
+                {
+                    throw new ArgumentException($"dotnet was not found on the path.");
+                }
+
+                var startInfo = new ProcessStartInfo
+                {
+                    FileName = muxer,
+                    Arguments = string.Join(" ", args),
+                    WorkingDirectory = projectFile.Directory.FullName,
+                    RedirectStandardError = true,
+                    RedirectStandardOutput = true,
+                };
+
+                var process = Process.Start(startInfo);
+
+                var timeout = 20;
+                if (!process.WaitForExit(timeout * 1000))
+                {
+                    throw new ArgumentException($"Adding package `{packageId}` to `{projectFile.Directory}` took longer than {timeout} seconds.");
+                }
+
+                if (process.ExitCode != 0)
+                {
+                    Out.Write(process.StandardOutput.ReadToEnd());
+                    Error.Write(process.StandardError.ReadToEnd());
+                    throw new ArgumentException($"Could not add package `{packageId}` to `{projectFile.Directory}`");
+                }
+            }
+        }
+
+        internal async Task DownloadToFileAsync(string url, string destinationPath, bool overwrite)
+        {
+            using var response = await _httpClient.GetResponseAsync(url);
+            await WriteToFileAsync(await response.Stream, destinationPath, overwrite);
+        }
+
+        internal async Task<string> DownloadGivenOption(string url, CommandOption fileOption)
+        {
+            using var response = await _httpClient.GetResponseAsync(url);
+
+            if (response.IsSuccessCode())
+            {
+                string destinationPath;
+                if (fileOption.HasValue())
+                {
+                    destinationPath = fileOption.Value();
+                }
+                else
+                {
+                    var fileName = GetFileNameFromResponse(response, url);
+                    var fullPath = GetFullPath(fileName);
+                    var directory = Path.GetDirectoryName(fullPath);
+                    destinationPath = GetUniqueFileName(directory, Path.GetFileNameWithoutExtension(fileName), Path.GetExtension(fileName));
+                }
+                await WriteToFileAsync(await response.Stream, GetFullPath(destinationPath), overwrite: false);
+
+                return destinationPath;
+            }
+            else
+            {
+                throw new ArgumentException($"The given url returned '{response.StatusCode}', indicating failure. The url might be wrong, or there might be a networking issue.");
+            }
+        }
+
+        private string GetUniqueFileName(string directory, string fileName, string extension)
+        {
+            var uniqueName = fileName;
+
+            var filePath = Path.Combine(directory, fileName + extension);
+            var exists = true;
+            var count = 0;
+
+            do
+            {
+                if (!File.Exists(filePath))
+                {
+                    exists = false;
+                }
+                else
+                {
+                    count++;
+                    uniqueName = fileName + count;
+                    filePath = Path.Combine(directory, uniqueName + extension);
+                }
+            }
+            while (exists);
+
+            return uniqueName + extension;
+        }
+
+        private string GetFileNameFromResponse(IHttpResponseMessageWrapper response, string url)
+        {
+            var contentDisposition = response.ContentDisposition();
+            string result;
+            if (contentDisposition != null && contentDisposition.FileName != null)
+            {
+                var fileName = Path.GetFileName(contentDisposition.FileName);
+                if (!Path.HasExtension(fileName))
+                {
+                    fileName += DefaultExtension;
+                }
+
+                result = fileName;
+            }
+            else
+            {
+                var uri = new Uri(url);
+                if (uri.Segments.Count() > 0 && uri.Segments.Last() != "/")
+                {
+                    var lastSegment = uri.Segments.Last();
+                    if (!Path.HasExtension(lastSegment))
+                    {
+                        lastSegment += DefaultExtension;
+                    }
+
+                    result = lastSegment;
+                }
+                else
+                {
+                    var parts = uri.Host.Split('.');
+
+                    // There's no segment, use the domain name.
+                    string domain;
+                    switch (parts.Length)
+                    {
+                        case 1:
+                        case 2:
+                            // It's localhost if 1, no www if 2
+                            domain = parts.First();
+                            break;
+                        case 3:
+                            domain = parts[1];
+                            break;
+                        default:
+                            throw new NotImplementedException("We don't handle the case that the Host has more than three segments");
+                    }
+
+                    result = domain + DefaultExtension;
+                }
+            }
+
+            return result;
+        }
+
+        internal CodeGenerator? GetCodeGenerator(CommandOption codeGeneratorOption)
+        {
+            CodeGenerator? codeGenerator;
+            if (codeGeneratorOption.HasValue())
+            {
+                codeGenerator = Enum.Parse<CodeGenerator>(codeGeneratorOption.Value());
+            }
+            else
+            {
+                codeGenerator = null;
+            }
+
+            return codeGenerator;
+        }
+
+        internal void ValidateCodeGenerator(CommandOption codeGeneratorOption)
+        {
+            if (codeGeneratorOption.HasValue())
+            {
+                var value = codeGeneratorOption.Value();
+                if (!Enum.TryParse(value, out CodeGenerator _))
+                {
+                    throw new ArgumentException($"Invalid value '{value}' given as code generator.");
+                }
+            }
+        }
+
+        internal string GetFullPath(string path)
+        {
+            return Path.IsPathFullyQualified(path)
+                ? path
+                : Path.GetFullPath(path, WorkingDirectory);
+        }
+
+        private async Task<IDictionary<string, string>> LoadPackageVersionsFromURLAsync()
+        {
+            /* Example Json content
+             {
+              "Version" : "1.0",
+              "Packages"  :  {
+                "Microsoft.Azure.SignalR": "1.1.0-preview1-10442",
+                "Grpc.AspNetCore.Server": "0.1.22-pre2",
+                "Grpc.Net.ClientFactory": "0.1.22-pre2",
+                "Google.Protobuf": "3.8.0",
+                "Grpc.Tools": "1.22.0",
+                "NSwag.ApiDescription.Client": "13.0.3",
+                "Microsoft.Extensions.ApiDescription.Client": "0.3.0-preview7.19365.7",
+                "Newtonsoft.Json": "12.0.2"
+              }
+            }*/
+            try
+            {
+                using var packageVersionStream = await (await _httpClient.GetResponseAsync(PackageVersionUrl)).Stream;
+                using var packageVersionDocument = await JsonDocument.ParseAsync(packageVersionStream);
+                var packageVersionsElement = packageVersionDocument.RootElement.GetProperty("Packages");
+                var packageVersionsDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+                foreach (var packageVersion in packageVersionsElement.EnumerateObject())
+                {
+                    packageVersionsDictionary[packageVersion.Name] = packageVersion.Value.GetString();
+                }
+
+                return packageVersionsDictionary;
+            }
+            catch
+            {
+                // TODO (johluo): Consider logging a message indicating what went wrong and actions, if any, to be taken to resolve possible issues.
+                // Currently not logging anything since the fwlink is not published yet.
+                return null;
+            }
+        }
+
+        private static IDictionary<string, string> GetServicePackages(CodeGenerator? type)
+        {
+            CodeGenerator generator = type ?? CodeGenerator.NSwagCSharp;
+            var name = Enum.GetName(typeof(CodeGenerator), generator);
+            var attributes = typeof(Program).Assembly.GetCustomAttributes<OpenApiDependencyAttribute>();
+
+            var packages = attributes.Where(a => a.CodeGenerators.Contains(generator));
+            var result = new Dictionary<string, string>();
+            if (packages != null)
+            {
+                foreach (var package in packages)
+                {
+                    result[package.Name] = package.Version;
+                }
+            }
+
+            return result;
+        }
+
+        private static byte[] GetHash(Stream stream)
+        {
+            SHA256 algorithm;
+            try
+            {
+                algorithm = SHA256.Create();
+            }
+            catch (TargetInvocationException)
+            {
+                // SHA256.Create is documented to throw this exception on FIPS-compliant machines. See
+                // https://msdn.microsoft.com/en-us/library/z08hz7ad Fall back to a FIPS-compliant SHA256 algorithm.
+                algorithm = new SHA256CryptoServiceProvider();
+            }
+
+            using (algorithm)
+            {
+                return algorithm.ComputeHash(stream);
+            }
+        }
+
+        private async Task WriteToFileAsync(Stream content, string destinationPath, bool overwrite)
+        {
+            if (content.CanSeek)
+            {
+                content.Seek(0, SeekOrigin.Begin);
+            }
+
+            destinationPath = GetFullPath(destinationPath);
+            var destinationExists = File.Exists(destinationPath);
+            if (destinationExists && !overwrite)
+            {
+                throw new ArgumentException($"File '{destinationPath}' already exists. Aborting to avoid conflicts. Provide the '--output-file' argument with an unused file to resolve.");
+            }
+
+            await Out.WriteLineAsync($"Downloading to '{destinationPath}'.");
+            var reachedCopy = false;
+            try
+            {
+                if (destinationExists)
+                {
+                    // Check hashes before using the downloaded information.
+                    var downloadHash = GetHash(content);
+
+                    byte[] destinationHash;
+                    using (var destinationStream = File.OpenRead(destinationPath))
+                    {
+                        destinationHash = GetHash(destinationStream);
+                    }
+
+                    var sameHashes = downloadHash.Length == destinationHash.Length;
+                    for (var i = 0; sameHashes && i < downloadHash.Length; i++)
+                    {
+                        sameHashes = downloadHash[i] == destinationHash[i];
+                    }
+
+                    if (sameHashes)
+                    {
+                        await Out.WriteLineAsync($"Not overwriting existing and matching file '{destinationPath}'.");
+                        return;
+                    }
+                }
+                else
+                {
+                    // May need to create directory to hold the file.
+                    var destinationDirectory = Path.GetDirectoryName(destinationPath);
+                    if (!string.IsNullOrEmpty(destinationDirectory) && !Directory.Exists(destinationDirectory))
+                    {
+                        Directory.CreateDirectory(destinationDirectory);
+                    }
+                }
+
+                // Create or overwrite the destination file.
+                reachedCopy = true;
+                using var fileStream = new FileStream(destinationPath, FileMode.OpenOrCreate, FileAccess.Write);
+                fileStream.Seek(0, SeekOrigin.Begin);
+                if (content.CanSeek)
+                {
+                    content.Seek(0, SeekOrigin.Begin);
+                }
+                await content.CopyToAsync(fileStream);
+            }
+            catch (Exception ex)
+            {
+                await Error.WriteLineAsync($"Downloading failed.");
+                await Error.WriteLineAsync(ex.ToString());
+                if (reachedCopy)
+                {
+                    File.Delete(destinationPath);
+                }
+            }
+        }
+    }
+}

+ 67 - 0
src/Tools/Microsoft.dotnet-openapi/src/Commands/RefreshCommand.cs

@@ -0,0 +1,67 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Build.Evaluation;
+using Microsoft.DotNet.Openapi.Tools;
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.DotNet.OpenApi.Commands
+{
+    internal class RefreshCommand : BaseCommand
+    {
+        private const string CommandName = "refresh";
+
+        private const string SourceURLArgName = "source-URL";
+
+        public RefreshCommand(Application parent, IHttpClientWrapper httpClient) : base(parent, CommandName, httpClient)
+        {
+            _sourceFileArg = Argument(SourceURLArgName, $"The OpenAPI reference to refresh.");
+        }
+
+        internal readonly CommandArgument _sourceFileArg;
+
+        protected override async Task<int> ExecuteCoreAsync()
+        {
+            var projectFile = ResolveProjectFile(ProjectFileOption);
+
+            var sourceFile = Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceURLArgName);
+
+            var destination = FindReferenceFromUrl(projectFile, sourceFile);
+            await DownloadToFileAsync(sourceFile, destination, overwrite: true);
+
+            return 0;
+        }
+
+        private string FindReferenceFromUrl(FileInfo projectFile, string url)
+        {
+            var project = LoadProject(projectFile);
+            var openApiReferenceItems = project.GetItems(OpenApiReference);
+
+            foreach (ProjectItem item in openApiReferenceItems)
+            {
+                var attrUrl = item.GetMetadataValue(SourceUrlAttrName);
+                if (string.Equals(attrUrl, url, StringComparison.Ordinal))
+                {
+                    return item.EvaluatedInclude;
+                }
+            }
+
+            throw new ArgumentException("There was no OpenAPI reference to refresh with the given URL.");
+        }
+
+        protected override bool ValidateArguments()
+        {
+            var sourceFile = Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceURLArgName);
+            if (!IsUrl(sourceFile))
+            {
+                throw new ArgumentException($"'dotnet openapi refresh' must be given a URL");
+            }
+
+            return true;
+        }
+    }
+}

+ 78 - 0
src/Tools/Microsoft.dotnet-openapi/src/Commands/RemoveCommand.cs

@@ -0,0 +1,78 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Build.Evaluation;
+using Microsoft.DotNet.Openapi.Tools;
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.DotNet.OpenApi.Commands
+{
+    internal class RemoveCommand : BaseCommand
+    {
+        private const string CommandName = "remove";
+
+        private const string SourceArgName = "soruce";
+
+        public RemoveCommand(Application parent, IHttpClientWrapper httpClient) : base(parent, CommandName, httpClient)
+        {
+            _sourceProjectArg = Argument(SourceArgName, $"The OpenAPI reference to remove. Must represent a reference which is already in this project", multipleValues: true);
+        }
+
+        internal readonly CommandArgument _sourceProjectArg;
+
+        protected override Task<int> ExecuteCoreAsync()
+        {
+            var projectFile = ResolveProjectFile(ProjectFileOption);
+
+            var sourceFile = Ensure.NotNullOrEmpty(_sourceProjectArg.Value, SourceArgName);
+
+            if (IsProjectFile(sourceFile))
+            {
+                RemoveServiceReference(OpenApiProjectReference, projectFile, sourceFile);
+            }
+            else
+            {
+                var file = RemoveServiceReference(OpenApiReference, projectFile, sourceFile);
+
+                if (file != null)
+                {
+                    File.Delete(GetFullPath(file));
+                }
+            }
+
+            return Task.FromResult(0);
+        }
+
+        private string RemoveServiceReference(string tagName, FileInfo projectFile, string sourceFile)
+        {
+            var project = LoadProject(projectFile);
+            var openApiReferenceItems = project.GetItems(tagName);
+
+            foreach (ProjectItem item in openApiReferenceItems)
+            {
+                var include = item.EvaluatedInclude;
+                var sourceUrl = item.HasMetadata(SourceUrlAttrName) ? item.GetMetadataValue(SourceUrlAttrName) : null;
+                if (string.Equals(include, sourceFile, StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(sourceUrl, sourceFile, StringComparison.OrdinalIgnoreCase))
+                {
+                    project.RemoveItem(item);
+                    project.Save();
+                    return include;
+                }
+            }
+
+            Warning.Write($"No OpenAPI reference was found with the file '{sourceFile}'");
+            return null;
+        }
+
+        protected override bool ValidateArguments()
+        {
+            Ensure.NotNullOrEmpty(_sourceProjectArg.Value, SourceArgName);
+            return true;
+        }
+    }
+}

+ 27 - 0
src/Tools/Microsoft.dotnet-openapi/src/DebugMode.cs

@@ -0,0 +1,27 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+
+namespace Microsoft.DotNet.OpenApi
+{
+    internal static class DebugMode
+    {
+        public static void HandleDebugSwitch(ref string[] args)
+        {
+            if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase))
+            {
+                args = args.Skip(1).ToArray();
+
+                Console.WriteLine("Waiting for debugger in pid: {0}", Process.GetCurrentProcess().Id);
+                while (!Debugger.IsAttached)
+                {
+                    Thread.Sleep(TimeSpan.FromSeconds(3));
+                }
+            }
+        }
+    }
+}

+ 72 - 0
src/Tools/Microsoft.dotnet-openapi/src/HttpClientWrapper.cs

@@ -0,0 +1,72 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+using Microsoft.DotNet.OpenApi;
+using Microsoft.DotNet.OpenApi.Commands;
+
+namespace Microsoft.DotNet.Openapi.Tools
+{
+    public class HttpClientWrapper : IHttpClientWrapper
+    {
+        private readonly HttpClient _client;
+
+        public HttpClientWrapper(HttpClient client)
+        {
+            _client = client;
+        }
+
+        public void Dispose()
+        {
+            _client.Dispose();
+        }
+
+        public async Task<IHttpResponseMessageWrapper> GetResponseAsync(string url)
+        {
+            var response = await _client.GetAsync(url);
+
+            return new HttpResponseMessageWrapper(response);
+        }
+
+        public Task<Stream> GetStreamAsync(string url)
+        {
+            return _client.GetStreamAsync(url);
+        }
+    }
+
+    public class HttpResponseMessageWrapper : IHttpResponseMessageWrapper
+    {
+        private HttpResponseMessage _response;
+
+        public HttpResponseMessageWrapper(HttpResponseMessage response)
+        {
+            _response = response;
+        }
+
+        public Task<Stream> Stream => _response.Content.ReadAsStreamAsync();
+
+        public HttpStatusCode StatusCode => _response.StatusCode;
+
+        public bool IsSuccessCode() => _response.IsSuccessStatusCode;
+
+        public ContentDispositionHeaderValue ContentDisposition()
+        {
+            if (_response.Headers.TryGetValues(BaseCommand.ContentDispositionHeaderName, out var disposition))
+            {
+                return new ContentDispositionHeaderValue(disposition.First());
+            }
+
+            return null;
+        }
+
+        public void Dispose()
+        {
+            _response.Dispose();
+        }
+    }
+}

+ 14 - 0
src/Tools/Microsoft.dotnet-openapi/src/IHttpClientWrapper.cs

@@ -0,0 +1,14 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.DotNet.OpenApi;
+
+namespace Microsoft.DotNet.Openapi.Tools
+{
+    internal interface IHttpClientWrapper : IDisposable
+    {
+        Task<IHttpResponseMessageWrapper> GetResponseAsync(string url);
+    }
+}

+ 19 - 0
src/Tools/Microsoft.dotnet-openapi/src/IHttpResponseMessageWrapper.cs

@@ -0,0 +1,19 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+
+namespace Microsoft.DotNet.OpenApi
+{
+    public interface IHttpResponseMessageWrapper : IDisposable
+    {
+        Task<Stream> Stream { get; }
+        ContentDispositionHeaderValue ContentDisposition();
+        HttpStatusCode StatusCode { get; }
+        bool IsSuccessCode();
+    }
+}

+ 25 - 0
src/Tools/Microsoft.dotnet-openapi/src/Internal/OpenapiDependencyAttribute.cs

@@ -0,0 +1,25 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.DotNet.OpenApi;
+
+namespace Microsoft.DotNet.Openapi.Tools.Internal
+{
+    [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
+    internal class OpenApiDependencyAttribute : Attribute
+    {
+        public OpenApiDependencyAttribute(string name, string version, string codeGenerators)
+        {
+            Name = name;
+            Version = version;
+            CodeGenerators = codeGenerators.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(c => Enum.Parse<CodeGenerator>(c)).ToArray();
+        }
+
+        public string Name { get; set; }
+        public string Version { get; set; }
+        public IEnumerable<CodeGenerator> CodeGenerators { get; set; }
+    }
+}

+ 36 - 0
src/Tools/Microsoft.dotnet-openapi/src/Microsoft.dotnet-openapi.csproj

@@ -0,0 +1,36 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>netcoreapp3.0</TargetFramework>
+    <OutputType>exe</OutputType>
+    <Description>Command line tool to add an OpenAPI service reference</Description>
+    <RootNamespace>Microsoft.DotNet.Openapi.Tools</RootNamespace>
+    <AssemblyName>dotnet-openapi</AssemblyName>
+    <PackageId>Microsoft.dotnet-openapi</PackageId>
+    <PackAsTool>true</PackAsTool>
+    <!-- This package is for internal use only. It contains a CLI tool. -->
+    <IsShippingPackage>false</IsShippingPackage>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Compile Include="$(ToolSharedSourceRoot)CommandLine\**\*.cs" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.Build" ExcludeAssets="runtime" />
+    <Reference Include="Microsoft.Build.Locator" />
+    <Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <AssemblyAttribute Include="Microsoft.DotNet.Openapi.Tools.Internal.OpenApiDependencyAttribute">
+      <_Parameter1>NSwag.ApiDescription.Client</_Parameter1>
+      <_Parameter2>$(NSwagApiDescriptionClientPackageVersion)</_Parameter2>
+      <_Parameter3>NSwagCSharp;NSwagTypeScript</_Parameter3>
+    </AssemblyAttribute>
+    <AssemblyAttribute Include="Microsoft.DotNet.Openapi.Tools.Internal.OpenApiDependencyAttribute">
+      <_Parameter1>Newtonsoft.Json</_Parameter1>
+      <_Parameter2>$(NewtonsoftJsonPackageVersion)</_Parameter2>
+      <_Parameter3>NSwagCSharp;NSwagTypeScript</_Parameter3>
+    </AssemblyAttribute>
+  </ItemGroup>
+</Project>

+ 53 - 0
src/Tools/Microsoft.dotnet-openapi/src/Program.cs

@@ -0,0 +1,53 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Net.Http;
+using Microsoft.DotNet.Openapi.Tools;
+
+namespace Microsoft.DotNet.OpenApi
+{
+    public class Program
+    {
+        public static int Main(string[] args)
+        {
+            var outputWriter = new StringWriter();
+            var errorWriter = new StringWriter();
+
+            DebugMode.HandleDebugSwitch(ref args);
+
+            try
+            {
+                using var httpClient = new HttpClientWrapper(new HttpClient());
+                var application = new Application(
+                    Directory.GetCurrentDirectory(),
+                    httpClient,
+                    outputWriter,
+                    errorWriter);
+
+                var result = application.Execute(args);
+
+                return result;
+            }
+            catch (Exception ex)
+            {
+                errorWriter.Write("Unexpected error:");
+                errorWriter.WriteLine(ex.ToString());
+            }
+            finally
+            {
+                var output = outputWriter.ToString();
+                var error = errorWriter.ToString();
+
+                outputWriter.Dispose();
+                errorWriter.Dispose();
+
+                Console.WriteLine(output);
+                Console.Error.WriteLine(error);
+            }
+
+            return 1;
+        }
+    }
+}

+ 23 - 0
src/Tools/Microsoft.dotnet-openapi/src/ProjectExtensions.cs

@@ -0,0 +1,23 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Build.Evaluation;
+
+namespace Microsoft.DotNet.OpenApi
+{
+    public static class ProjectExtensions
+    {
+        public static void AddElementWithAttributes(this Project project, string tagName, string include, IDictionary<string, string> metadata)
+        {
+            var item = project.AddItem(tagName, include).Single();
+            foreach (var kvp in metadata)
+            {
+                item.Xml.AddMetadata(kvp.Key, kvp.Value, expressAsAttribute: true);
+            }
+
+            project.Save();
+        }
+    }
+}

+ 6 - 0
src/Tools/Microsoft.dotnet-openapi/src/Properties/AssemblyInfo.cs

@@ -0,0 +1,6 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Microsoft.DotNet.Open.Api.Tools.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

+ 254 - 0
src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddFileTests.cs

@@ -0,0 +1,254 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.IO;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using Microsoft.DotNet.OpenApi.Tests;
+using Microsoft.Extensions.Internal;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.DotNet.OpenApi.Add.Tests
+{
+    public class OpenApiAddFileTests : OpenApiTestBase
+    {
+        public OpenApiAddFileTests(ITestOutputHelper output) : base(output) { }
+
+        [Fact]
+        public void OpenApi_Empty_ShowsHelp()
+        {
+            var app = GetApplication();
+            var run = app.Execute(new string[] { });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            Assert.Contains("Usage: openapi ", _output.ToString());
+        }
+
+        [Fact]
+        public void OpenApi_NoProjectExists()
+        {
+            var app = GetApplication();
+            _tempDir.Create();
+            var run = app.Execute(new string[] { "add", "file", "randomfile.json" });
+
+            Assert.Contains("No project files were found in the current directory", _error.ToString());
+            Assert.Equal(1, run);
+        }
+
+        [Fact]
+        public void OpenApi_ExplicitProject_Missing()
+        {
+            var app = GetApplication();
+            _tempDir.Create();
+            var csproj = "fake.csproj";
+            var run = app.Execute(new string[] { "add", "file", "--updateProject", csproj, "randomfile.json" });
+
+            Assert.Contains($"The project '{Path.Combine(_tempDir.Root, csproj)}' does not exist.", _error.ToString());
+            Assert.Equal(1, run);
+        }
+
+        [Fact]
+        public void OpenApi_Add_Empty_ShowsHelp()
+        {
+            var app = GetApplication();
+            var run = app.Execute(new string[] { "add" });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            Assert.Contains("Usage: openapi add", _output.ToString());
+        }
+
+        [Fact]
+        public void OpenApi_Add_File_Empty_ShowsHelp()
+        {
+            var app = GetApplication();
+            var run = app.Execute(new string[] { "add", "file", "--help" });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            Assert.Contains("Usage: openapi ", _output.ToString());
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_ReuseItemGroup()
+        {
+            var project = CreateBasicProject(withOpenApi: true);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "file", project.NSwagJsonFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var secondRun = app.Execute(new[] { "add", "url", FakeOpenApiUrl });
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, secondRun);
+
+            var csproj = new FileInfo(project.Project.Path);
+            string content;
+            using (var csprojStream = csproj.OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains($"<OpenApiReference Include=\"{project.NSwagJsonFile}\"", content);
+            }
+            var projXml = new XmlDocument();
+            projXml.Load(csproj.FullName);
+
+            var openApiRefs = projXml.GetElementsByTagName(Commands.BaseCommand.OpenApiReference);
+            Assert.Same(openApiRefs[0].ParentNode, openApiRefs[1].ParentNode);
+        }
+
+        [Fact]
+        public void OpenApi_Add_File_EquivilentPaths()
+        {
+            var project = CreateBasicProject(withOpenApi: true);
+            var nswagJsonFile = project.NSwagJsonFile;
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "file", nswagJsonFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            app = GetApplication();
+            var absolute = Path.GetFullPath(nswagJsonFile, project.Project.Dir().Root);
+            run = app.Execute(new[] { "add", "file", absolute });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var csproj = new FileInfo(project.Project.Path);
+            var projXml = new XmlDocument();
+            projXml.Load(csproj.FullName);
+
+            var openApiRefs = projXml.GetElementsByTagName(Commands.BaseCommand.OpenApiReference);
+            Assert.Single(openApiRefs);
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_NSwagTypeScript()
+        {
+            var project = CreateBasicProject(withOpenApi: true);
+            var nswagJsonFile = project.NSwagJsonFile;
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "file", nswagJsonFile, "--code-generator", "NSwagTypeScript" });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            var csproj = new FileInfo(project.Project.Path);
+            using (var csprojStream = csproj.OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains($"<OpenApiReference Include=\"{nswagJsonFile}\" CodeGenerator=\"NSwagTypeScript\" />", content);
+            }
+
+            // Build project and make sure it compiles
+            using var buildProc = ProcessEx.Run(_outputHelper, _tempDir.Root, "dotnet", "build");
+            await buildProc.Exited;
+            Assert.True(buildProc.ExitCode == 0, $"Build failed: {buildProc.Output}");
+
+
+            // Run project and make sure it doesn't crash
+            using var runProc = ProcessEx.Run(_outputHelper, _tempDir.Root, "dotnet", "run");
+            Thread.Sleep(100);
+            Assert.False(runProc.HasExited, $"Run failed with: {runProc.Output}");
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_FromJson()
+        {
+            var project = CreateBasicProject(withOpenApi: true);
+            var nswagJsonFile = project.NSwagJsonFile;
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "file", nswagJsonFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            var csproj = new FileInfo(project.Project.Path);
+            using (var csprojStream = csproj.OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains($"<OpenApiReference Include=\"{nswagJsonFile}\"", content);
+            }
+
+            // Build project and make sure it compiles
+            var buildProc = ProcessEx.Run(_outputHelper, _tempDir.Root, "dotnet", "build");
+            await buildProc.Exited;
+            Assert.True(buildProc.ExitCode == 0, $"Build failed: {buildProc.Output}");
+
+            // Run project and make sure it doesn't crash
+            using var runProc = ProcessEx.Run(_outputHelper, _tempDir.Root, "dotnet", "run");
+            Thread.Sleep(100);
+            Assert.False(runProc.HasExited, $"Run failed with: {runProc.Output}");
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_File_UseProjectOption()
+        {
+            var project = CreateBasicProject(withOpenApi: true);
+            var nswagJsonFIle = project.NSwagJsonFile;
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "file", "--updateProject", project.Project.Path, nswagJsonFIle });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            var csproj = new FileInfo(project.Project.Path);
+            using var csprojStream = csproj.OpenRead();
+            using var reader = new StreamReader(csprojStream);
+            var content = await reader.ReadToEndAsync();
+            Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+            Assert.Contains($"<OpenApiReference Include=\"{nswagJsonFIle}\"", content);
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_MultipleTimes_OnlyOneReference()
+        {
+            var project = CreateBasicProject(withOpenApi: true);
+            var nswagJsonFile = project.NSwagJsonFile;
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "file", nswagJsonFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            app = GetApplication();
+            run = app.Execute(new[] { "add", "file", nswagJsonFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            var csproj = new FileInfo(project.Project.Path);
+            using var csprojStream = csproj.OpenRead();
+            using var reader = new StreamReader(csprojStream);
+            var content = await reader.ReadToEndAsync();
+            var escapedPkgRef = Regex.Escape("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"");
+            Assert.Single(Regex.Matches(content, escapedPkgRef));
+            var escapedApiRef = Regex.Escape($"<OpenApiReference Include=\"{nswagJsonFile}\"");
+            Assert.Single(Regex.Matches(content, escapedApiRef));
+        }
+    }
+}

+ 123 - 0
src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddProjectTests.cs

@@ -0,0 +1,123 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.IO;
+using System.Threading.Tasks;
+using System.Xml;
+using Microsoft.DotNet.OpenApi.Tests;
+using Microsoft.Extensions.Tools.Internal;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.DotNet.OpenApi.Add.Tests
+{
+    public class OpenApiAddProjectTests : OpenApiTestBase
+    {
+        public OpenApiAddProjectTests(ITestOutputHelper output) : base(output){}
+
+        [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/12738")]
+        public async Task OpenApi_Add_GlobbingOpenApi()
+        {
+            var project = CreateBasicProject(withOpenApi: true);
+
+            using (var refProj1 = project.Project.Dir().SubDir("refProj1"))
+            using (var refProj2 = project.Project.Dir().SubDir("refProj2"))
+            {
+                var project1 = refProj1.WithCSharpProject("refProj");
+                project1
+                    .WithTargetFrameworks("netcoreapp3.0")
+                    .Dir()
+                    .Create();
+
+                var project2 = refProj2.WithCSharpProject("refProj2");
+                project2
+                    .WithTargetFrameworks("netcoreapp3.0")
+                    .Dir()
+                    .Create();
+
+                var app = GetApplication();
+
+                var run = app.Execute(new[] { "add", "project", project1.Path, project2.Path});
+
+                Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+                Assert.Equal(0, run);
+
+                // csproj contents
+                using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+                using (var reader = new StreamReader(csprojStream))
+                {
+                    var content = await reader.ReadToEndAsync();
+                    Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                    Assert.Contains($"<OpenApiProjectReference Include=\"{project1.Path}\"", content);
+                    Assert.Contains($"<OpenApiProjectReference Include=\"{project2.Path}\"", content);
+                }
+            }
+        }
+
+        [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/12738")]
+        public void OpenApi_Add_Project_EquivilentPaths()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            using (var refProj = new TemporaryDirectory())
+            {
+                var refProjName = "refProj";
+                var csproj = refProj.WithCSharpProject(refProjName);
+                csproj
+                    .WithTargetFrameworks("netcoreapp3.0")
+                    .Dir()
+                    .Create();
+
+                var app = GetApplication();
+                var run = app.Execute(new[] { "add", "project", csproj.Path});
+
+                Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+                Assert.Equal(0, run);
+
+                app = GetApplication();
+                run = app.Execute(new[] { "add", "project", Path.Combine(csproj.Path, "..", "refProj.csproj")});
+
+                Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+                Assert.Equal(0, run);
+
+                var projXml = new XmlDocument();
+                projXml.Load(project.Project.Path);
+
+                var openApiRefs = projXml.GetElementsByTagName(Commands.BaseCommand.OpenApiProjectReference);
+                Assert.Single(openApiRefs);
+            }
+        }
+
+        [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/12738")]
+        public async Task OpenApi_Add_FromCsProj()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            using (var refProj = new TemporaryDirectory())
+            {
+                var refProjName = "refProj";
+                refProj
+                    .WithCSharpProject(refProjName)
+                    .WithTargetFrameworks("netcoreapp3.0")
+                    .Dir()
+                    .Create();
+
+                var app = GetApplication();
+                var refProjFile = Path.Join(refProj.Root, $"{refProjName}.csproj");
+                var run = app.Execute(new[] { "add", "project", refProjFile });
+
+                Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+                Assert.Equal(0, run);
+
+                // csproj contents
+                using(var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+                using(var reader = new StreamReader(csprojStream))
+                {
+                    var content = await reader.ReadToEndAsync();
+                    Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                    Assert.Contains($"<OpenApiProjectReference Include=\"{refProjFile}\"", content);
+                }
+            }
+        }
+    }
+}

+ 493 - 0
src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddURLTests.cs

@@ -0,0 +1,493 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Microsoft.DotNet.OpenApi.Tests;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.DotNet.OpenApi.Add.Tests
+{
+    public class OpenApiAddURLTests : OpenApiTestBase
+    {
+        public OpenApiAddURLTests(ITestOutputHelper output) : base(output){ }
+
+        [Fact]
+        public async Task OpenApi_Add_Url_WithContentDisposition()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = "filename.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" />", content);
+            }
+
+            var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(jsonFile));
+            using (var jsonStream = new FileInfo(jsonFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenAPI_Add_Url_NoContentDisposition()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+            var url = NoDispositionUrl;
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", url});
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = "nodisposition.yaml";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{url}"" />", content);
+            }
+
+            var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(jsonFile));
+            using (var jsonStream = new FileInfo(jsonFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenAPI_Add_Url_NoExtension_AssumesJson()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+            var url = NoExtensionUrl;
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", url });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = "filename.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{url}"" />", content);
+            }
+
+            var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(jsonFile));
+            using (var jsonStream = new FileInfo(jsonFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_Url_NoSegment()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+            var url = NoSegmentUrl;
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", url });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = "contoso.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{url}"" />", content);
+            }
+
+            var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(jsonFile));
+            using (var jsonStream = new FileInfo(jsonFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_Url()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = "filename.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" />", content);
+            }
+
+            var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(jsonFile));
+            using (var jsonStream = new FileInfo(jsonFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_Url_SameName_UniqueFile()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var firstExpectedJsonName = "filename.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{firstExpectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" />", content);
+            }
+
+            var firstJsonFile = Path.Combine(_tempDir.Root, firstExpectedJsonName);
+            Assert.True(File.Exists(firstJsonFile));
+            using (var jsonStream = new FileInfo(firstJsonFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+
+            app = GetApplication();
+            run = app.Execute(new[] { "add", "url", NoExtensionUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var secondExpectedJsonName = "filename1.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{firstExpectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" />", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{secondExpectedJsonName}"" SourceUrl=""{NoExtensionUrl}"" />", content);
+            }
+
+            var secondJsonFile = Path.Combine(_tempDir.Root, secondExpectedJsonName);
+            Assert.True(File.Exists(secondJsonFile));
+            using (var jsonStream = new FileInfo(secondJsonFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_Url_NSwagCSharp()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl, "--code-generator", "NSwagCSharp" });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = "filename.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" CodeGenerator=""NSwagCSharp"" />", content);
+            }
+
+            var resultFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(resultFile));
+            using (var jsonStream = new FileInfo(resultFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_Url_NSwagTypeScript()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl, "--code-generator", "NSwagTypeScript" });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = "filename.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" CodeGenerator=""NSwagTypeScript"" />", content);
+            }
+
+            var resultFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(resultFile));
+            using (var jsonStream = new FileInfo(resultFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_Url_OutputFile()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl, "--output-file", Path.Combine("outputdir", "file.yaml") });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = Path.Combine("outputdir", "file.yaml");
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" />", content);
+            }
+
+            var resultFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(resultFile));
+            using (var jsonStream = new FileInfo(resultFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenApi_Add_URL_FileAlreadyExists_Fail()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var outputFile = Path.Combine("outputdir", "file.yaml");
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl, "--output-file", outputFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonName = Path.Combine("outputdir", "file.yaml");
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" />", content);
+            }
+
+            var resultFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.True(File.Exists(resultFile));
+            using (var jsonStream = new FileInfo(resultFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+
+            // Second reference, same output
+            app = GetApplication();
+            run = app.Execute(new[] { "add", "url", DifferentUrl, "--output-file", outputFile});
+            Assert.Equal(1, run);
+            Assert.True(_error.ToString().Contains("Aborting to avoid conflicts."), $"Should have aborted to avoid conflicts");
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains(
+    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{FakeOpenApiUrl}"" />", content);
+                Assert.DoesNotContain(
+                    $@"<OpenApiReference Include=""{expectedJsonName}"" SourceUrl=""{DifferentUrl}"" CodeGenerator=""NSwagCSharp"" />", content);
+            }
+
+            using (var jsonStream = new FileInfo(resultFile).OpenRead())
+            using (var reader = new StreamReader(jsonStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Equal(Content, content);
+            }
+        }
+
+        [Fact]
+        public void OpenApi_Add_URL_MultipleTimes_OnlyOneReference()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            app = GetApplication();
+            run = app.Execute(new[] { "add", "url", "--output-file", "openapi.yaml", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            var csproj = new FileInfo(project.Project.Path);
+            using var csprojStream = csproj.OpenRead();
+            using var reader = new StreamReader(csprojStream);
+            var content = reader.ReadToEnd();
+            var escapedPkgRef = Regex.Escape("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"");
+            Assert.Single(Regex.Matches(content, escapedPkgRef));
+            var escapedApiRef = Regex.Escape($"SourceUrl=\"{FakeOpenApiUrl}\"");
+            Assert.Single(Regex.Matches(content, escapedApiRef));
+        }
+
+        [Fact]
+        public async Task OpenAPi_Add_URL_InvalidUrl()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication(realHttp: true);
+            var url = BrokenUrl;
+            var run = app.Execute(new[] { "add", "url", url });
+
+            Assert.Equal(_error.ToString(), $"The given url returned 'NotFound', " +
+    "indicating failure. The url might be wrong, or there might be a networking issue."+Environment.NewLine);
+            Assert.Equal(1, run);
+
+            var expectedJsonName = "dingos.json";
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(project.Project.Path).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.DoesNotContain("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.DoesNotContain($@"<OpenApiReference", content);
+            }
+
+            var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName);
+            Assert.False(File.Exists(jsonFile));
+        }
+
+        [Fact]
+        public void OpenApi_Add_URL_ActualResponse()
+        {
+            var project = CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication(realHttp: true);
+            var url = ActualUrl;
+            var run = app.Execute(new[] { "add", "url", url });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            app = GetApplication(realHttp: true);
+            run = app.Execute(new[] { "add", "url", url });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            var csproj = new FileInfo(project.Project.Path);
+            using var csprojStream = csproj.OpenRead();
+            using var reader = new StreamReader(csprojStream);
+            var content = reader.ReadToEnd();
+            var escapedPkgRef = Regex.Escape("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"");
+            Assert.Single(Regex.Matches(content, escapedPkgRef));
+            var escapedApiRef = Regex.Escape($"SourceUrl=\"{url}\"");
+            Assert.Single(Regex.Matches(content, escapedApiRef));
+            Assert.Contains(
+$@"<OpenApiReference Include=""api-with-examples.yaml"" SourceUrl=""{ActualUrl}"" />", content);
+        }
+    }
+}

+ 48 - 0
src/Tools/Microsoft.dotnet-openapi/test/OpenApiRefreshTests.cs

@@ -0,0 +1,48 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.DotNet.OpenApi.Tests;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.DotNet.OpenApi.Refresh.Tests
+{
+    public class OpenApiRefreshTests : OpenApiTestBase
+    {
+        public OpenApiRefreshTests(ITestOutputHelper output) : base(output) { }
+
+        [Fact]
+        public async Task OpenApi_Refresh_Basic()
+        {
+            CreateBasicProject(withOpenApi: false);
+
+            var app = GetApplication();
+            var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var expectedJsonPath = Path.Combine(_tempDir.Root, "filename.json");
+            var json = await File.ReadAllTextAsync(expectedJsonPath);
+            json += "trash";
+            await File.WriteAllTextAsync(expectedJsonPath, json);
+
+            var firstWriteTime = File.GetLastWriteTime(expectedJsonPath);
+
+            Thread.Sleep(TimeSpan.FromSeconds(1));
+
+            app = GetApplication();
+            run = app.Execute(new[] { "refresh", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var secondWriteTime = File.GetLastWriteTime(expectedJsonPath);
+            Assert.True(firstWriteTime < secondWriteTime, $"File wasn't updated! {firstWriteTime} {secondWriteTime}");
+        }
+    }
+}

+ 201 - 0
src/Tools/Microsoft.dotnet-openapi/test/OpenApiRemoveTests.cs

@@ -0,0 +1,201 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.DotNet.OpenApi.Tests;
+using Microsoft.Extensions.Tools.Internal;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.DotNet.OpenApi.Remove.Tests
+{
+    public class OpenApiRemoveTests : OpenApiTestBase
+    {
+        public OpenApiRemoveTests(ITestOutputHelper output) : base(output) { }
+
+        [Fact]
+        public async Task OpenApi_Remove_File()
+        {
+            var nswagJsonFile = "openapi.json";
+            _tempDir
+                .WithCSharpProject("testproj")
+                .WithTargetFrameworks("netcoreapp3.0")
+                .Dir()
+                .WithContentFile(nswagJsonFile)
+                .WithContentFile("Startup.cs")
+                .Create();
+
+            var add = GetApplication();
+            var run = add.Execute(new[] { "add", "file", nswagJsonFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            var csproj = new FileInfo(Path.Join(_tempDir.Root, "testproj.csproj"));
+            using (var csprojStream = csproj.OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains($"<OpenApiReference Include=\"{nswagJsonFile}\"", content);
+            }
+
+            var remove = GetApplication();
+            var removeRun = remove.Execute(new[] { "remove", nswagJsonFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, removeRun);
+
+            // csproj contents
+            csproj = new FileInfo(Path.Join(_tempDir.Root, "testproj.csproj"));
+            using (var csprojStream = csproj.OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                // Don't remove the package reference, they might have taken other dependencies on it
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.DoesNotContain($"<OpenApiReference Include=\"{nswagJsonFile}\"", content);
+            }
+            Assert.False(File.Exists(Path.Combine(_tempDir.Root, nswagJsonFile)));
+        }
+
+        [Fact]
+        public async Task OpenApi_Remove_ViaUrl()
+        {
+            _tempDir
+                .WithCSharpProject("testproj")
+                .WithTargetFrameworks("netcoreapp3.0")
+                .Dir()
+                .WithContentFile("Startup.cs")
+                .Create();
+
+            var add = GetApplication();
+            var run = add.Execute(new[] { "add", "url", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            var csproj = new FileInfo(Path.Join(_tempDir.Root, "testproj.csproj"));
+            using (var csprojStream = csproj.OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                // Don't remove the package reference, they might have taken other dependencies on it
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+            }
+
+            var remove = GetApplication();
+            var removeRun = remove.Execute(new[] { "remove", FakeOpenApiUrl });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, removeRun);
+
+            // csproj contents
+            csproj = new FileInfo(Path.Join(_tempDir.Root, "testproj.csproj"));
+            using var removedCsprojStream = csproj.OpenRead();
+            using var removedReader = new StreamReader(removedCsprojStream);
+            var removedContent = await removedReader.ReadToEndAsync();
+            // Don't remove the package reference, they might have taken other dependencies on it
+            Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", removedContent);
+            Assert.DoesNotContain($"<OpenApiReference", removedContent);
+        }
+
+        [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/12738")]
+        public async Task OpenApi_Remove_Project()
+        {
+            _tempDir
+               .WithCSharpProject("testproj")
+               .WithTargetFrameworks("netcoreapp3.0")
+               .Dir()
+               .WithContentFile("Startup.cs")
+               .Create();
+
+            using var refProj = new TemporaryDirectory();
+            var refProjName = "refProj";
+            refProj
+                .WithCSharpProject(refProjName)
+                .WithTargetFrameworks("netcoreapp3.0")
+                .Dir()
+                .Create();
+
+            var app = GetApplication();
+            var refProjFile = Path.Join(refProj.Root, $"{refProjName}.csproj");
+            var run = app.Execute(new[] { "add", "project", refProjFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(Path.Join(_tempDir.Root, "testproj.csproj")).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.Contains($"<OpenApiProjectReference Include=\"{refProjFile}\"", content);
+            }
+
+            var remove = GetApplication();
+            run = app.Execute(new[] { "remove", refProjFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            // csproj contents
+            using (var csprojStream = new FileInfo(Path.Join(_tempDir.Root, "testproj.csproj")).OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.DoesNotContain($"<OpenApiProjectReference Include=\"{refProjFile}\"", content);
+            }
+        }
+
+        [Fact]
+        public async Task OpenApi_Remove_Multiple()
+        {
+            var nswagJsonFile = "openapi.json";
+            var swagFile2 = "swag2.json";
+            _tempDir
+                .WithCSharpProject("testproj")
+                .WithTargetFrameworks("netcoreapp3.0")
+                .Dir()
+                .WithContentFile(nswagJsonFile)
+                .WithFile(swagFile2)
+                .WithContentFile("Startup.cs")
+                .Create();
+
+            var add = GetApplication();
+            var run = add.Execute(new[] { "add", "file", nswagJsonFile });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            add = GetApplication();
+            run = add.Execute(new[] { "add", "file", swagFile2 });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, run);
+
+            var remove = GetApplication();
+            var removeRun = remove.Execute(new[] { "remove", nswagJsonFile, swagFile2 });
+
+            Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}");
+            Assert.Equal(0, removeRun);
+
+            // csproj contents
+            var csproj = new FileInfo(Path.Join(_tempDir.Root, "testproj.csproj"));
+            using (var csprojStream = csproj.OpenRead())
+            using (var reader = new StreamReader(csprojStream))
+            {
+                var content = await reader.ReadToEndAsync();
+                // Don't remove the package reference, they might have taken other dependencies on it
+                Assert.Contains("<PackageReference Include=\"NSwag.ApiDescription.Client\" Version=\"", content);
+                Assert.DoesNotContain($"<OpenApiReference Include=\"{nswagJsonFile}\"", content);
+            }
+            Assert.False(File.Exists(Path.Combine(_tempDir.Root, nswagJsonFile)));
+        }
+    }
+}

+ 183 - 0
src/Tools/Microsoft.dotnet-openapi/test/OpenApiTestBase.cs

@@ -0,0 +1,183 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.DotNet.Openapi.Tools;
+using Microsoft.Extensions.Tools.Internal;
+using Xunit.Abstractions;
+
+namespace Microsoft.DotNet.OpenApi.Tests
+{
+    public class OpenApiTestBase : IDisposable
+    {
+        protected readonly TemporaryDirectory _tempDir;
+        protected readonly TextWriter _output = new StringWriter();
+        protected readonly TextWriter _error = new StringWriter();
+        protected readonly ITestOutputHelper _outputHelper;
+
+        protected const string Content = @"{""x-generator"": ""NSwag""}";
+        protected const string ActualUrl = "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/api-with-examples.yaml";
+        protected const string BrokenUrl = "https://www.microsoft.com/en-us/dingos.json";
+        protected const string FakeOpenApiUrl = "https://contoso.com/openapi.json";
+        protected const string NoDispositionUrl = "https://contoso.com/nodisposition.yaml";
+        protected const string NoExtensionUrl = "https://contoso.com/noextension";
+        protected const string NoSegmentUrl = "https://contoso.com";
+        protected const string DifferentUrl = "https://contoso.com/different.json";
+        protected const string PackageUrl = "https://go.microsoft.com/fwlink/?linkid=2099561";
+        protected const string DifferentUrlContent = @"
+{
+    ""x-generator"": ""NSwag""
+}";
+        protected const string PackageUrlContent = @"
+{
+  ""Version"" : ""1.0"",
+  ""Packages""  :  {
+    ""Microsoft.Azure.SignalR"": ""1.1.0-preview1-10442"",
+    ""Grpc.AspNetCore.Server"": ""0.1.22-pre2"",
+    ""Grpc.Net.ClientFactory"": ""0.1.22-pre2"",
+    ""Google.Protobuf"": ""3.8.0"",
+    ""Grpc.Tools"": ""1.22.0"",
+    ""NSwag.ApiDescription.Client"": ""13.0.3"",
+    ""Microsoft.Extensions.ApiDescription.Client"": ""0.3.0-preview7.19365.7"",
+    ""Newtonsoft.Json"": ""12.0.2""
+  }
+}";
+
+        public OpenApiTestBase(ITestOutputHelper output)
+        {
+            _tempDir = new TemporaryDirectory();
+            _outputHelper = output;
+        }
+
+        public TemporaryNSwagProject CreateBasicProject(bool withOpenApi)
+        {
+            var nswagJsonFile = "openapi.json";
+            var project = _tempDir
+                .WithCSharpProject("testproj", sdk: "Microsoft.NET.Sdk.Web")
+                .WithTargetFrameworks("netcoreapp3.0");
+            var tmp = project.Dir();
+
+            if (withOpenApi)
+            {
+                tmp = tmp.WithContentFile(nswagJsonFile);
+            }
+
+            tmp.WithContentFile("Startup.cs")
+                .Create();
+
+            return new TemporaryNSwagProject(project, nswagJsonFile);
+        }
+
+        internal Application GetApplication(bool realHttp = false)
+        {
+            IHttpClientWrapper wrapper;
+            if (realHttp)
+            {
+                wrapper = new HttpClientWrapper(new HttpClient());
+            }
+            else
+            {
+                wrapper = new TestHttpClientWrapper(DownloadMock());
+            }
+            return new Application(
+                _tempDir.Root, wrapper, _output, _error);
+        }
+
+        private IDictionary<string, Tuple<string, ContentDispositionHeaderValue>> DownloadMock()
+        {
+            var noExtension = new ContentDispositionHeaderValue("attachment");
+            noExtension.Parameters.Add(new NameValueHeaderValue("filename", "filename"));
+            var extension = new ContentDispositionHeaderValue("attachment");
+            extension.Parameters.Add(new NameValueHeaderValue("filename", "filename.json"));
+            var justAttachments = new ContentDispositionHeaderValue("attachment");
+
+            return new Dictionary<string, Tuple<string, ContentDispositionHeaderValue>> {
+                { FakeOpenApiUrl, Tuple.Create(Content, extension)},
+                { DifferentUrl, Tuple.Create<string, ContentDispositionHeaderValue>(DifferentUrlContent, null) },
+                { PackageUrl, Tuple.Create<string, ContentDispositionHeaderValue>(PackageUrlContent, null) },
+                { NoDispositionUrl, Tuple.Create<string, ContentDispositionHeaderValue>(Content, null) },
+                { NoExtensionUrl, Tuple.Create(Content, noExtension) },
+                { NoSegmentUrl, Tuple.Create(Content, justAttachments) }
+            };
+        }
+
+        public void Dispose()
+        {
+            _outputHelper.WriteLine(_output.ToString());
+            _tempDir.Dispose();
+        }
+    }
+
+    public class TestHttpClientWrapper : IHttpClientWrapper
+    {
+        private readonly IDictionary<string, Tuple<string, ContentDispositionHeaderValue>> _results;
+
+        public TestHttpClientWrapper(IDictionary<string, Tuple<string, ContentDispositionHeaderValue>> results)
+        {
+            _results = results;
+        }
+
+        public void Dispose()
+        {
+        }
+
+        public Task<IHttpResponseMessageWrapper> GetResponseAsync(string url)
+        {
+            var result = _results[url];
+            byte[] byteArray = Encoding.ASCII.GetBytes(result.Item1);
+            var stream = new MemoryStream(byteArray);
+
+            return Task.FromResult<IHttpResponseMessageWrapper>(new TestHttpResponseMessageWrapper(stream, result.Item2));
+        }
+    }
+
+    public class TestHttpResponseMessageWrapper : IHttpResponseMessageWrapper
+    {
+        public Task<Stream> Stream { get; }
+
+        public HttpStatusCode StatusCode { get; } = HttpStatusCode.OK;
+
+        public bool IsSuccessCode()
+        {
+            return true;
+        }
+
+        private ContentDispositionHeaderValue _contentDisposition;
+
+        public TestHttpResponseMessageWrapper(
+            MemoryStream stream,
+            ContentDispositionHeaderValue header)
+        {
+            Stream = Task.FromResult<Stream>(stream);
+            _contentDisposition = header;
+        }
+
+        public ContentDispositionHeaderValue ContentDisposition()
+        {
+            return _contentDisposition;
+        }
+
+        public void Dispose()
+        {
+        }
+    }
+
+    public class TemporaryNSwagProject
+    {
+        public TemporaryNSwagProject(TemporaryCSharpProject project, string jsonFile)
+        {
+            Project = project;
+            NSwagJsonFile = jsonFile;
+        }
+
+        public TemporaryCSharpProject Project { get; set; }
+        public string NSwagJsonFile { get; set; }
+    }
+}

+ 146 - 0
src/Tools/Microsoft.dotnet-openapi/test/ProcessEx.cs

@@ -0,0 +1,146 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit.Abstractions;
+
+namespace Microsoft.Extensions.Internal
+{
+    internal class ProcessEx : IDisposable
+    {
+        private readonly ITestOutputHelper _output;
+        private readonly Process _process;
+        private readonly StringBuilder _stderrCapture = new StringBuilder();
+        private readonly StringBuilder _stdoutCapture = new StringBuilder();
+        private readonly object _pipeCaptureLock = new object();
+        private BlockingCollection<string> _stdoutLines = new BlockingCollection<string>();
+        private TaskCompletionSource<int> _exited;
+
+        private ProcessEx(ITestOutputHelper output, Process proc)
+        {
+            _output = output;
+
+            _process = proc;
+            proc.EnableRaisingEvents = true;
+            proc.OutputDataReceived += OnOutputData;
+            proc.ErrorDataReceived += OnErrorData;
+            proc.Exited += OnProcessExited;
+            proc.BeginOutputReadLine();
+            proc.BeginErrorReadLine();
+
+            _exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
+        }
+
+        public Task Exited => _exited.Task;
+
+        public bool HasExited => _process.HasExited;
+
+        public string Output
+        {
+            get
+            {
+                lock (_pipeCaptureLock)
+                {
+                    return _stdoutCapture.ToString();
+                }
+            }
+        }
+
+        public int ExitCode => _process.ExitCode;
+
+        public static ProcessEx Run(ITestOutputHelper output, string workingDirectory, string command, string args = null, IDictionary<string, string> envVars = null)
+        {
+            var startInfo = new ProcessStartInfo(command, args)
+            {
+                RedirectStandardOutput = true,
+                RedirectStandardError = true,
+                UseShellExecute = false,
+                CreateNoWindow = true,
+                WorkingDirectory = workingDirectory
+            };
+
+            if (envVars != null)
+            {
+                foreach (var envVar in envVars)
+                {
+                    startInfo.EnvironmentVariables[envVar.Key] = envVar.Value;
+                }
+            }
+
+            output.WriteLine($"==> {startInfo.FileName} {startInfo.Arguments} [{startInfo.WorkingDirectory}]");
+            var proc = Process.Start(startInfo);
+
+            return new ProcessEx(output, proc);
+        }
+
+        private void OnErrorData(object sender, DataReceivedEventArgs e)
+        {
+            if (e.Data == null)
+            {
+                return;
+            }
+
+            lock (_pipeCaptureLock)
+            {
+                _stderrCapture.AppendLine(e.Data);
+            }
+
+            _output.WriteLine("[ERROR] " + e.Data);
+        }
+
+        private void OnOutputData(object sender, DataReceivedEventArgs e)
+        {
+            if (e.Data == null)
+            {
+                return;
+            }
+
+            lock (_pipeCaptureLock)
+            {
+                _stdoutCapture.AppendLine(e.Data);
+            }
+
+            _output.WriteLine(e.Data);
+
+            if (_stdoutLines != null)
+            {
+                _stdoutLines.Add(e.Data);
+            }
+        }
+
+        private void OnProcessExited(object sender, EventArgs e)
+        {
+            _process.WaitForExit();
+            _stdoutLines.CompleteAdding();
+            _stdoutLines = null;
+            _exited.TrySetResult(_process.ExitCode);
+        }
+
+        public void Dispose()
+        {
+            if (_process != null && !_process.HasExited)
+            {
+                _process.KillTree();
+            }
+
+            _process.CancelOutputRead();
+            _process.CancelErrorRead();
+
+            _process.ErrorDataReceived -= OnErrorData;
+            _process.OutputDataReceived -= OnOutputData;
+            _process.Exited -= OnProcessExited;
+            _process.Dispose();
+
+            if(_stdoutLines != null)
+            {
+                _stdoutLines.Dispose();
+            }
+        }
+    }
+}

+ 6 - 0
src/Tools/Microsoft.dotnet-openapi/test/Properties/AssemblyInfo.cs

@@ -0,0 +1,6 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Xunit;
+
+[assembly: CollectionBehavior(DisableTestParallelization = true)]

+ 46 - 0
src/Tools/Microsoft.dotnet-openapi/test/TestContent/Startup.cs.txt

@@ -0,0 +1,46 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.IO;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Net.Http.Headers;
+
+namespace SimpleWebSite
+{
+    public class Startup
+    {
+        public void ConfigureServices(IServiceCollection services)
+        {
+            // Example 1
+            services
+                .AddMvcCore()
+                .AddAuthorization()
+                .AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("js", new MediaTypeHeaderValue("application/json")))
+                .SetCompatibilityVersion(CompatibilityVersion.Latest);
+        }
+
+        public void Configure(IApplicationBuilder app)
+        {
+            app.UseMvcWithDefaultRoute();
+        }
+
+        public static void Main(string[] args)
+        {
+            var host = CreateWebHostBuilder(args)
+                .Build();
+
+            host.Run();
+        }
+
+        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
+            new WebHostBuilder()
+                .UseContentRoot(Directory.GetCurrentDirectory())
+                .UseStartup<Startup>()
+                .UseKestrel()
+                .UseIISIntegration();
+    }
+}

+ 514 - 0
src/Tools/Microsoft.dotnet-openapi/test/TestContent/openapi.json.txt

@@ -0,0 +1,514 @@
+{
+  "x-generator": "NSwag v11.17.15.0 (NJsonSchema v9.10.53.0 (Newtonsoft.Json v10.0.0.0))",
+  "openapi": "2.0",
+  "info": {
+    "title": "My Title",
+    "version": "1.0.0"
+  },
+  "host": "localhost:44370",
+  "schemes": [
+    "https"
+  ],
+  "consumes": [
+    "application/json",
+    "application/json-patch+json",
+    "text/json",
+    "application/*+json",
+    "multipart/form-data"
+  ],
+  "produces": [
+    "application/json"
+  ],
+  "paths": {
+    "/pet": {
+      "post": {
+        "tags": [
+          "Pet"
+        ],
+        "operationId": "Pet_AddPet",
+        "consumes": [
+          "application/json"
+        ],
+        "parameters": [
+          {
+            "name": "pet",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/Pet"
+            },
+            "x-nullable": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": ""
+          },
+          "400": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/SerializableError"
+            }
+          }
+        }
+      },
+      "put": {
+        "tags": [
+          "Pet"
+        ],
+        "operationId": "Pet_EditPet",
+        "consumes": [
+          "application/json"
+        ],
+        "parameters": [
+          {
+            "name": "pet",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/Pet"
+            },
+            "x-nullable": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": ""
+          },
+          "400": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/SerializableError"
+            }
+          }
+        }
+      }
+    },
+    "/pet/findByStatus": {
+      "get": {
+        "tags": [
+          "Pet"
+        ],
+        "operationId": "Pet_FindByStatus",
+        "consumes": [
+          "application/json-patch+json",
+          "application/json",
+          "text/json",
+          "application/*+json"
+        ],
+        "parameters": [
+          {
+            "name": "status",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            "x-nullable": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "type": "array",
+              "items": {
+                "$ref": "#/definitions/Pet"
+              }
+            }
+          }
+        }
+      }
+    },
+    "/pet/findByCategory": {
+      "get": {
+        "tags": [
+          "Pet"
+        ],
+        "operationId": "Pet_FindByCategory",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "category",
+            "in": "query",
+            "required": true,
+            "x-nullable": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "type": "array",
+              "items": {
+                "$ref": "#/definitions/Pet"
+              }
+            }
+          },
+          "400": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/SerializableError"
+            }
+          }
+        }
+      }
+    },
+    "/pet/{petId}": {
+      "get": {
+        "tags": [
+          "Pet"
+        ],
+        "operationId": "Pet_FindById",
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "petId",
+            "in": "path",
+            "required": true,
+            "format": "int32",
+            "x-nullable": false
+          }
+        ],
+        "responses": {
+          "200": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/Pet"
+            }
+          },
+          "400": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/SerializableError"
+            }
+          },
+          "404": {
+            "description": ""
+          }
+        }
+      },
+      "post": {
+        "tags": [
+          "Pet"
+        ],
+        "operationId": "Pet_EditPet2",
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "petId",
+            "in": "path",
+            "required": true,
+            "format": "int32",
+            "x-nullable": false
+          },
+          {
+            "type": "integer",
+            "name": "Id",
+            "in": "formData",
+            "required": true,
+            "format": "int32",
+            "x-nullable": false
+          },
+          {
+            "type": "integer",
+            "name": "Age",
+            "in": "formData",
+            "required": true,
+            "format": "int32",
+            "x-nullable": false
+          },
+          {
+            "type": "integer",
+            "name": "Category.Id",
+            "in": "formData",
+            "required": true,
+            "format": "int32",
+            "x-nullable": false
+          },
+          {
+            "type": "string",
+            "name": "Category.Name",
+            "in": "formData",
+            "required": true,
+            "x-nullable": true
+          },
+          {
+            "type": "boolean",
+            "name": "HasVaccinations",
+            "in": "formData",
+            "required": true,
+            "x-nullable": false
+          },
+          {
+            "type": "string",
+            "name": "Name",
+            "in": "formData",
+            "required": true,
+            "x-nullable": true
+          },
+          {
+            "type": "array",
+            "name": "Images",
+            "in": "formData",
+            "required": true,
+            "collectionFormat": "multi",
+            "x-nullable": true,
+            "items": {
+              "$ref": "#/definitions/Image"
+            }
+          },
+          {
+            "type": "array",
+            "name": "Tags",
+            "in": "formData",
+            "required": true,
+            "collectionFormat": "multi",
+            "x-nullable": true,
+            "items": {
+              "$ref": "#/definitions/Tag"
+            }
+          },
+          {
+            "type": "string",
+            "name": "Status",
+            "in": "formData",
+            "required": true,
+            "x-nullable": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": ""
+          },
+          "400": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/SerializableError"
+            }
+          },
+          "404": {
+            "description": ""
+          }
+        }
+      },
+      "delete": {
+        "tags": [
+          "Pet"
+        ],
+        "operationId": "Pet_DeletePet",
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "petId",
+            "in": "path",
+            "required": true,
+            "format": "int32",
+            "x-nullable": false
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": ""
+          },
+          "400": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/SerializableError"
+            }
+          },
+          "404": {
+            "description": ""
+          }
+        }
+      }
+    },
+    "/pet/{petId}/uploadImage": {
+      "post": {
+        "tags": [
+          "Pet"
+        ],
+        "operationId": "Pet_UploadImage",
+        "consumes": [
+          "multipart/form-data"
+        ],
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "petId",
+            "in": "path",
+            "required": true,
+            "format": "int32",
+            "x-nullable": false
+          },
+          {
+            "type": "file",
+            "name": "file",
+            "in": "formData",
+            "required": true,
+            "x-nullable": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/ApiResponse"
+            }
+          },
+          "400": {
+            "x-nullable": true,
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/SerializableError"
+            }
+          },
+          "404": {
+            "description": ""
+          }
+        }
+      }
+    }
+  },
+  "definitions": {
+    "Pet": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "id",
+        "age",
+        "hasVaccinations",
+        "name",
+        "status"
+      ],
+      "properties": {
+        "id": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "age": {
+          "type": "integer",
+          "format": "int32",
+          "maximum": 150.0,
+          "minimum": 0.0
+        },
+        "category": {
+          "$ref": "#/definitions/Category"
+        },
+        "hasVaccinations": {
+          "type": "boolean"
+        },
+        "name": {
+          "type": "string",
+          "maxLength": 50,
+          "minLength": 2
+        },
+        "images": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/Image"
+          }
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/Tag"
+          }
+        },
+        "status": {
+          "type": "string"
+        }
+      }
+    },
+    "Category": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "id"
+      ],
+      "properties": {
+        "id": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "name": {
+          "type": "string"
+        }
+      }
+    },
+    "Image": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "id"
+      ],
+      "properties": {
+        "id": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "url": {
+          "type": "string"
+        }
+      }
+    },
+    "Tag": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "id"
+      ],
+      "properties": {
+        "id": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "name": {
+          "type": "string"
+        }
+      }
+    },
+    "SerializableError": {
+      "type": "object",
+      "additionalProperties": false,
+      "allOf": [
+        {
+          "type": "object",
+          "additionalProperties": {}
+        }
+      ]
+    },
+    "ApiResponse": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "code"
+      ],
+      "properties": {
+        "code": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "message": {
+          "type": "string"
+        },
+        "type": {
+          "type": "string"
+        }
+      }
+    }
+  }
+}

+ 51 - 0
src/Tools/Microsoft.dotnet-openapi/test/dotnet-microsoft.openapi.Tests.csproj

@@ -0,0 +1,51 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>netcoreapp3.0</TargetFramework>
+    <AssemblyName>Microsoft.DotNet.Open.Api.Tools.Tests</AssemblyName>
+    <DefaultItemExcludes>$(DefaultItemExcludes);TestProjects\**\*</DefaultItemExcludes>
+    <TestGroupName>DotNetAddOpenAPIReferenceToolsTests</TestGroupName>
+  </PropertyGroup>
+
+  <PropertyGroup>
+    <OpenAPIToolCSProjPath>..\src\Microsoft.dotnet-openapi.csproj</OpenAPIToolCSProjPath>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Compile Include="$(ToolSharedSourceRoot)TestHelpers\**\*.cs" />
+    <Compile Include="$(SharedSourceRoot)test\SkipOnHelixAttribute.cs" />
+    <Content Include="TestContent\*" LinkBase="TestContent\" CopyToOutputDirectory="PreserveNewest" />
+    <Compile Include="$(SharedSourceRoot)Process\ProcessExtensions.cs" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.Build" ExcludeAssets="runtime" />
+    <ProjectReference Include="$(OpenAPIToolCSProjPath)" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
+      <_Parameter1>TestSettings:RestoreSources</_Parameter1>
+      <_Parameter2>$(RestoreSources)</_Parameter2>
+    </AssemblyAttribute>
+    <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
+      <_Parameter1>TestSettings:RuntimeFrameworkVersion</_Parameter1>
+      <_Parameter2>$(RuntimeFrameworkVersion)</_Parameter2>
+    </AssemblyAttribute>
+    <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
+      <_Parameter1>RepoRoot</_Parameter1>
+      <_Parameter2>$(RepoRoot)</_Parameter2>
+    </AssemblyAttribute>
+  </ItemGroup>
+
+  <Target Name="CleanTestProjects" BeforeTargets="CoreCompile">
+    <RemoveDir Directories="$(TargetDir)TestProjects" Condition="Exists('$(TargetDir)TestProjects')" />
+  </Target>
+
+  <Target Name="PublishDotNetOpenApiOnBuild" BeforeTargets="Build" Condition="'$(DotNetBuildFromSource)' != 'true'">
+    <MSBuild Projects="$(OpenAPIToolCSProjPath)" Targets="Publish" Properties="PublishDir=$(OutputPath)\tool\;Configuration=$(Configuration)" />
+  </Target>
+
+  <Target Name="PublishDotNetOpenApiOnPublish" BeforeTargets="Publish"  Condition="'$(DotNetBuildFromSource)' != 'true'">
+    <MSBuild Projects="$(OpenAPIToolCSProjPath)" Targets="Publish" Properties="PublishDir=$(PublishDir)\tool\;Configuration=$(Configuration)" />
+  </Target>
+</Project>

+ 5 - 0
src/Tools/Microsoft.dotnet-openapi/test/xunit.runner.json

@@ -0,0 +1,5 @@
+{
+  "longRunningTestSeconds": 30,
+  "diagnosticMessages": true,
+  "maxParallelThreads": -1
+}

+ 14 - 7
src/Tools/README.md

@@ -1,18 +1,24 @@
 # DotNetTools
 
-## Projects
+## Bundled tools
 
-The folder contains command-line tools for ASP.NET Core that are bundled* in the .NET Core CLI. Follow the links below for more details on each tool.
+The folder contains command-line tools for ASP.NET Core. The following tools are bundled* in the .NET Core CLI. Follow the links below for more details on each tool.
 
- - [dotnet-watch](dotnet-watch/README.md)
- - [dotnet-user-secrets](dotnet-user-secrets/README.md)
- - [dotnet-sql-cache](dotnet-sql-cache/README.md)
- - [dotnet-dev-certs](dotnet-dev-certs/README.md)
+- [dotnet-watch](dotnet-watch/README.md)
+- [dotnet-user-secrets](dotnet-user-secrets/README.md)
+- [dotnet-sql-cache](dotnet-sql-cache/README.md)
+- [dotnet-dev-certs](dotnet-dev-certs/README.md)
 
 *\*This applies to .NET Core CLI 2.1.300-preview2 and up. For earlier versions of the CLI, these tools must be installed separately.*
 
 *For 2.0 CLI and earlier, see <https://github.com/aspnet/DotNetTools/tree/rel/2.0.0/README.md> for details.*
 
+## Non-bundled tools
+
+The following tools are produced by us but not bundled in the .NET Core CLI. They must be aquired independently.
+
+- [Microsoft.dotnet-openapi](Microsoft.dotnet-openapi/README.md)
+
 This folder also contains the infrastructure for our partners' service reference features:
 
 - [Extensions.ApiDescription.Client](Extensions.ApiDescription.Client/README.md) MSBuild glue for OpenAPI code generation.
@@ -29,10 +35,11 @@ dotnet watch
 dotnet user-secrets
 dotnet sql-cache
 dotnet dev-certs
+dotnet openapi
 ```
 
 Add `--help` to see more details. For example,
 
-```
+```sh
 dotnet watch --help
 ```

+ 1 - 0
src/Tools/Shared/CommandLine/CommandLineApplicationExtensions.cs

@@ -3,6 +3,7 @@
 
 using System;
 using System.Reflection;
+using System.Threading.Tasks;
 
 namespace Microsoft.Extensions.CommandLineUtils
 {

+ 1 - 1
src/Tools/Shared/CommandLine/Ensure.cs

@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// Copyright (c) .NET Foundation. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;

+ 10 - 7
src/Tools/dotnet-watch/test/Utilities/TemporaryCSharpProject.cs → src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs

@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// Copyright (c) .NET Foundation. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
@@ -6,12 +6,12 @@ using System.Collections.Generic;
 using System.Diagnostics;
 using System.Text;
 
-namespace Microsoft.DotNet.Watcher.Tools.Tests
+namespace Microsoft.Extensions.Tools.Internal
 {
     public class TemporaryCSharpProject
     {
         private const string Template =
- @"<Project Sdk=""Microsoft.NET.Sdk"">
+ @"<Project Sdk=""{2}"">
   <PropertyGroup>
     {0}
     <OutputType>Exe</OutputType>
@@ -23,19 +23,22 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
 
         private readonly string _filename;
         private readonly TemporaryDirectory _directory;
-        private List<string> _items = new List<string>();
-        private List<string> _properties = new List<string>();
+        private readonly List<string> _items = new List<string>();
+        private readonly List<string> _properties = new List<string>();
 
-        public TemporaryCSharpProject(string name, TemporaryDirectory directory)
+        public TemporaryCSharpProject(string name, TemporaryDirectory directory, string sdk)
         {
             Name = name;
             _filename = name + ".csproj";
             _directory = directory;
+            Sdk = sdk;
         }
 
         public string Name { get; }
         public string Path => System.IO.Path.Combine(_directory.Root, _filename);
 
+        public string Sdk { get; }
+
         public TemporaryCSharpProject WithTargetFrameworks(params string[] tfms)
         {
             Debug.Assert(tfms.Length > 0);
@@ -95,7 +98,7 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
 
         public void Create()
         {
-            _directory.CreateFile(_filename, string.Format(Template, string.Join("\r\n", _properties), string.Join("\r\n", _items)));
+            _directory.CreateFile(_filename, string.Format(Template, string.Join("\r\n", _properties), string.Join("\r\n", _items), Sdk));
         }
 
         public class ItemSpec

+ 16 - 6
src/Tools/dotnet-watch/test/Utilities/TemporaryDirectory.cs → src/Tools/Shared/TestHelpers/TemporaryDirectory.cs

@@ -5,7 +5,7 @@ using System;
 using System.Collections.Generic;
 using System.IO;
 
-namespace Microsoft.DotNet.Watcher.Tools.Tests
+namespace Microsoft.Extensions.Tools.Internal
 {
     public class TemporaryDirectory : IDisposable
     {
@@ -16,7 +16,7 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
 
         public TemporaryDirectory()
         {
-            Root = Path.Combine(Path.GetTempPath(), "dotnet-watch-tests", Guid.NewGuid().ToString("N"));
+            Root = Path.Combine(Path.GetTempPath(), "dotnet-tool-tests", Guid.NewGuid().ToString("N"));
         }
 
         private TemporaryDirectory(string path, TemporaryDirectory parent)
@@ -34,16 +34,16 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
 
         public string Root { get; }
 
-        public TemporaryCSharpProject WithCSharpProject(string name)
+        public TemporaryCSharpProject WithCSharpProject(string name, string sdk = "Microsoft.NET.Sdk")
         {
-            var project = new TemporaryCSharpProject(name, this);
+            var project = new TemporaryCSharpProject(name, this, sdk);
             _projects.Add(project);
             return project;
         }
 
-        public TemporaryCSharpProject WithCSharpProject(string name, out TemporaryCSharpProject project)
+        public TemporaryCSharpProject WithCSharpProject(string name, out TemporaryCSharpProject project, string sdk = "Microsoft.NET.Sdk")
         {
-            project = WithCSharpProject(name);
+            project = WithCSharpProject(name, sdk);
             return project;
         }
 
@@ -53,6 +53,16 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
             return this;
         }
 
+        public TemporaryDirectory WithContentFile(string name)
+        {
+            using (var stream = File.OpenRead(Path.Combine("TestContent", $"{name}.txt")))
+            using (var streamReader = new StreamReader(stream))
+            {
+                _files[name] = streamReader.ReadToEnd();
+            }
+            return this;
+        }
+
         public TemporaryDirectory Up()
         {
             if (_parent == null)

+ 44 - 4
src/Tools/Tools.sln

@@ -7,13 +7,21 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-watch", "dotnet-watc
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-watch.Tests", "dotnet-watch\test\dotnet-watch.Tests.csproj", "{63F7E822-D1E2-4C41-8ABF-60B9E3A9C54C}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-dev-certs", "dotnet-dev-certs\src\dotnet-dev-certs.csproj", "{0D6D5693-7E0C-4FE8-B4AA-21207B2650AA}"
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E01EE27B-6CF9-4707-9849-5BA2ABA825F2}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat", "FirstRunCertGenerator\src\Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj", "{7BBDBDA2-299F-4C36-8338-23C525901DE0}"
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{2C485EAF-E4DE-4D14-8AE1-330641E17D44}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests", "FirstRunCertGenerator\test\Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests.csproj", "{1EC6FA27-40A5-433F-8CA1-636E7ED8863E}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-dev-certs", "dotnet-dev-certs\src\dotnet-dev-certs.csproj", "{98550159-E04E-44EB-A969-E5BF12444B94}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-sql-cache", "dotnet-sql-cache\src\dotnet-sql-cache.csproj", "{15FB0E39-1A28-4325-AD3C-76352516C80D}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-sql-cache", "dotnet-sql-cache\src\dotnet-sql-cache.csproj", "{216AF7F1-5B05-477E-B8D3-86F6059F268A}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-user-secrets", "dotnet-user-secrets\src\dotnet-user-secrets.csproj", "{5FE62357-2915-4890-813A-D82656BDC4DD}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-user-secrets.Tests", "dotnet-user-secrets\test\dotnet-user-secrets.Tests.csproj", "{25F8DCC4-4571-42F7-BA0F-5C2D5A802297}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.dotnet-openapi", "Microsoft.dotnet-openapi\src\Microsoft.dotnet-openapi.csproj", "{C806041C-30F2-4B27-918A-5FF3576B833B}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-microsoft.openapi.Tests", "Microsoft.dotnet-openapi\test\dotnet-microsoft.openapi.Tests.csproj", "{26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}"
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions.ApiDescription.Client", "Extensions.ApiDescription.Client", "{78610083-1FCE-47F5-AB4D-AF0E1313B351}"
 EndProject
@@ -77,6 +85,30 @@ Global
 		{EB63AECB-B898-475D-90F7-FE61F9C1CCC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{EB63AECB-B898-475D-90F7-FE61F9C1CCC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{EB63AECB-B898-475D-90F7-FE61F9C1CCC6}.Release|Any CPU.Build.0 = Release|Any CPU
+		{98550159-E04E-44EB-A969-E5BF12444B94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{98550159-E04E-44EB-A969-E5BF12444B94}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{98550159-E04E-44EB-A969-E5BF12444B94}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{98550159-E04E-44EB-A969-E5BF12444B94}.Release|Any CPU.Build.0 = Release|Any CPU
+		{216AF7F1-5B05-477E-B8D3-86F6059F268A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{216AF7F1-5B05-477E-B8D3-86F6059F268A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{216AF7F1-5B05-477E-B8D3-86F6059F268A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{216AF7F1-5B05-477E-B8D3-86F6059F268A}.Release|Any CPU.Build.0 = Release|Any CPU
+		{5FE62357-2915-4890-813A-D82656BDC4DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{5FE62357-2915-4890-813A-D82656BDC4DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{5FE62357-2915-4890-813A-D82656BDC4DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{5FE62357-2915-4890-813A-D82656BDC4DD}.Release|Any CPU.Build.0 = Release|Any CPU
+		{25F8DCC4-4571-42F7-BA0F-5C2D5A802297}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{25F8DCC4-4571-42F7-BA0F-5C2D5A802297}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{25F8DCC4-4571-42F7-BA0F-5C2D5A802297}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{25F8DCC4-4571-42F7-BA0F-5C2D5A802297}.Release|Any CPU.Build.0 = Release|Any CPU
+		{C806041C-30F2-4B27-918A-5FF3576B833B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{C806041C-30F2-4B27-918A-5FF3576B833B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{C806041C-30F2-4B27-918A-5FF3576B833B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{C806041C-30F2-4B27-918A-5FF3576B833B}.Release|Any CPU.Build.0 = Release|Any CPU
+		{26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -88,6 +120,14 @@ Global
 		{160A445F-7E1F-430D-9403-41F7F6F4A16E} = {4110117E-3C28-4064-A7A3-B112BD6F8CB9}
 		{233119FC-E4C1-421C-89AE-1A445C5A947F} = {4110117E-3C28-4064-A7A3-B112BD6F8CB9}
 		{EB63AECB-B898-475D-90F7-FE61F9C1CCC6} = {4110117E-3C28-4064-A7A3-B112BD6F8CB9}
+		{E16F10C8-5FC3-420B-AB33-D6E5CBE89B75} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2}
+		{63F7E822-D1E2-4C41-8ABF-60B9E3A9C54C} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44}
+		{98550159-E04E-44EB-A969-E5BF12444B94} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2}
+		{216AF7F1-5B05-477E-B8D3-86F6059F268A} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2}
+		{5FE62357-2915-4890-813A-D82656BDC4DD} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2}
+		{25F8DCC4-4571-42F7-BA0F-5C2D5A802297} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44}
+		{C806041C-30F2-4B27-918A-5FF3576B833B} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2}
+		{26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {EC668D8E-97B9-4758-9E5C-2E5DD6B9137B}

+ 3 - 0
src/Tools/build.cmd

@@ -0,0 +1,3 @@
+@ECHO OFF
+SET RepoRoot=%~dp0..\..
+%RepoRoot%\build.cmd -projects %~dp0\**\*.*proj %*

+ 7 - 0
src/Tools/build.sh

@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+repo_root="$DIR/../.."
+"$repo_root/build.sh" --projects "$DIR/**/*.*proj" "$@"

+ 4 - 4
src/Tools/dotnet-dev-certs/src/Program.cs

@@ -104,10 +104,10 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools
                 app.HelpOption("-h|--help");
 
                 app.OnExecute(() =>
-                    {
-                        app.ShowHelp();
-                        return Success;
-                    });
+                {
+                    app.ShowHelp();
+                    return Success;
+                });
 
                 return app.Execute(args);
             }

+ 7 - 5
src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs

@@ -11,18 +11,20 @@ namespace Microsoft.DotNet.Watcher
     {
         private object _lock = new object();
 
-        public PrefixConsoleReporter(IConsole console, bool verbose, bool quiet)
+        private readonly string _prefix;
+
+        public PrefixConsoleReporter(string prefix, IConsole console, bool verbose, bool quiet)
             : base(console, verbose, quiet)
-        { }
+        {
+            _prefix = prefix;
+        }
 
         protected override void WriteLine(TextWriter writer, string message, ConsoleColor? color)
         {
-            const string prefix = "watch : ";
-
             lock (_lock)
             {
                 Console.ForegroundColor = ConsoleColor.DarkGray;
-                writer.Write(prefix);
+                writer.Write(_prefix);
                 Console.ResetColor();
 
                 base.WriteLine(writer, message, color);

+ 7 - 7
src/Tools/dotnet-watch/src/Program.cs

@@ -15,17 +15,17 @@ namespace Microsoft.DotNet.Watcher
     public class Program : IDisposable
     {
         private readonly IConsole _console;
-        private readonly string _workingDir;
+        private readonly string _workingDirectory;
         private readonly CancellationTokenSource _cts;
         private IReporter _reporter;
 
-        public Program(IConsole console, string workingDir)
+        public Program(IConsole console, string workingDirectory)
         {
             Ensure.NotNull(console, nameof(console));
-            Ensure.NotNullOrEmpty(workingDir, nameof(workingDir));
+            Ensure.NotNullOrEmpty(workingDirectory, nameof(workingDirectory));
 
             _console = console;
-            _workingDir = workingDir;
+            _workingDirectory = workingDirectory;
             _cts = new CancellationTokenSource();
             _console.CancelKeyPress += OnCancelKeyPress;
             _reporter = CreateReporter(verbose: true, quiet: false, console: _console);
@@ -134,7 +134,7 @@ namespace Microsoft.DotNet.Watcher
             string projectFile;
             try
             {
-                projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDir, project);
+                projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDirectory, project);
             }
             catch (FileNotFoundException ex)
             {
@@ -177,7 +177,7 @@ namespace Microsoft.DotNet.Watcher
             string projectFile;
             try
             {
-                projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDir, project);
+                projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDirectory, project);
             }
             catch (FileNotFoundException ex)
             {
@@ -205,7 +205,7 @@ namespace Microsoft.DotNet.Watcher
         }
 
         private static IReporter CreateReporter(bool verbose, bool quiet, IConsole console)
-            => new PrefixConsoleReporter(console, verbose || CliContext.IsGlobalVerbose(), quiet);
+            => new PrefixConsoleReporter("watch : ", console, verbose || CliContext.IsGlobalVerbose(), quiet);
 
         public void Dispose()
         {

+ 2 - 2
src/Tools/dotnet-watch/src/dotnet-watch.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <TargetFramework>netcoreapp3.0</TargetFramework>
@@ -12,7 +12,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <Compile Include="$(SharedSourceRoot)Process\*.cs" />
+    <Compile Include="$(SharedSourceRoot)Process\ProcessExtensions.cs" />
     <Compile Include="$(ToolSharedSourceRoot)CommandLine\**\*.cs" />
     <None Include="assets\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
   </ItemGroup>

+ 0 - 2
src/Tools/dotnet-watch/test/AssertEx.cs

@@ -1,11 +1,9 @@
 // Copyright (c) .NET Foundation. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
-using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
-using Xunit;
 using Xunit.Sdk;
 
 namespace Microsoft.DotNet.Watcher.Tools.Tests

+ 19 - 13
src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs

@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// Copyright (c) .NET Foundation. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
@@ -113,17 +113,20 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
         public async Task MultiTfm()
         {
             _tempDir
-               .SubDir("src")
-                   .SubDir("Project1")
-                       .WithCSharpProject("Project1", out var target)
-                       .WithTargetFrameworks("netcoreapp1.0", "net451")
-                       .WithProperty("EnableDefaultCompileItems", "false")
-                       .WithItem("Compile", "Class1.netcore.cs", "'$(TargetFramework)'=='netcoreapp1.0'")
-                       .WithItem("Compile", "Class1.desktop.cs", "'$(TargetFramework)'=='net451'")
-                       .Dir()
-                       .WithFile("Class1.netcore.cs")
-                       .WithFile("Class1.desktop.cs")
-                       .WithFile("Class1.notincluded.cs");
+                .SubDir("src")
+                    .SubDir("Project1")
+                        .WithCSharpProject("Project1", out var target)
+                        .WithTargetFrameworks("netcoreapp1.0", "net451")
+                        .WithProperty("EnableDefaultCompileItems", "false")
+                        .WithItem("Compile", "Class1.netcore.cs", "'$(TargetFramework)'=='netcoreapp1.0'")
+                        .WithItem("Compile", "Class1.desktop.cs", "'$(TargetFramework)'=='net451'")
+                        .Dir()
+                        .WithFile("Class1.netcore.cs")
+                        .WithFile("Class1.desktop.cs")
+                        .WithFile("Class1.notincluded.cs")
+                    .Up()
+                .Up()
+                .Create();
 
             var fileset = await GetFileSet(target);
 
@@ -155,7 +158,10 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
                         .WithTargetFrameworks("netcoreapp1.0", "net451")
                         .WithProjectReference(proj2)
                         .Dir()
-                        .WithFile("Class1.cs");
+                        .WithFile("Class1.cs")
+                    .Up()
+                .Up()
+                .Create();
 
             var fileset = await GetFileSet(target);
 

+ 4 - 4
src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs

@@ -3,6 +3,7 @@
 
 using System;
 using System.Collections.Generic;
+using Microsoft.Extensions.Tools.Internal;
 
 namespace Microsoft.DotNet.Watcher.Tools.Tests
 {
@@ -10,7 +11,7 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
     {
         private readonly TemporaryDirectory _directory;
         private Action<TemporaryCSharpProject> _onCreate;
-        private Dictionary<string, TemporaryCSharpProject> _projects = new Dictionary<string, TemporaryCSharpProject>();
+        private readonly Dictionary<string, TemporaryCSharpProject> _projects = new Dictionary<string, TemporaryCSharpProject>();
         public TestProjectGraph(TemporaryDirectory directory)
         {
             _directory = directory;
@@ -28,8 +29,7 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
 
         public TemporaryCSharpProject GetOrCreate(string projectName)
         {
-            TemporaryCSharpProject sourceProj;
-            if (!_projects.TryGetValue(projectName, out sourceProj))
+            if (!_projects.TryGetValue(projectName, out TemporaryCSharpProject sourceProj))
             {
                 sourceProj = _directory.SubDir(projectName).WithCSharpProject(projectName);
                 _onCreate?.Invoke(sourceProj);
@@ -38,4 +38,4 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests
             return sourceProj;
         }
     }
-}
+}

+ 1 - 1
src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <TargetFramework>netcoreapp3.0</TargetFramework>