From da02847b0893b618b024876e700e5639e023e4f1 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 22 May 2024 16:32:23 -0700 Subject: [PATCH] Support passing OpenAPI spec version and document name via dotnet-getdocument (#55823) --- AspNetCore.sln | 38 +++++ src/OpenApi/src/Services/IDocumentProvider.cs | 3 + .../src/Services/OpenApiDocumentProvider.cs | 26 +++- .../sample/GetDocumentSample.csproj | 17 ++ .../GetDocumentInsider/sample/Program.cs | 25 +++ .../sample/Properties/launchSettings.json | 38 +++++ .../sample/appsettings.Development.json | 8 + .../sample/appsettings.json | 9 ++ .../src/Commands/GetDocumentCommand.cs | 14 +- .../src/Commands/GetDocumentCommandContext.cs | 11 ++ .../src/Commands/GetDocumentCommandWorker.cs | 109 +++++++++---- .../src/GetDocument.Insider.csproj | 5 + .../GetDocumentInsider/src/ProgramBase.cs | 3 +- .../GetDocumentInsider/src/Resources.resx | 71 +++++---- .../tests/GetDocumentInsider.Tests.csproj | 19 +++ .../tests/GetDocumentTests.cs | 147 ++++++++++++++++++ src/Tools/Tools.slnf | 4 +- 17 files changed, 479 insertions(+), 68 deletions(-) create mode 100644 src/Tools/GetDocumentInsider/sample/GetDocumentSample.csproj create mode 100644 src/Tools/GetDocumentInsider/sample/Program.cs create mode 100644 src/Tools/GetDocumentInsider/sample/Properties/launchSettings.json create mode 100644 src/Tools/GetDocumentInsider/sample/appsettings.Development.json create mode 100644 src/Tools/GetDocumentInsider/sample/appsettings.json create mode 100644 src/Tools/GetDocumentInsider/tests/GetDocumentInsider.Tests.csproj create mode 100644 src/Tools/GetDocumentInsider/tests/GetDocumentTests.cs diff --git a/AspNetCore.sln b/AspNetCore.sln index d3ef22223bf8..bf6010cef399 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1812,6 +1812,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Static EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.StaticAssets.Tests", "src\StaticAssets\test\Microsoft.AspNetCore.StaticAssets.Tests.csproj", "{9536C284-65B4-4884-BB50-06D629095C3E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GetDocumentInsider.Tests", "src\Tools\GetDocumentInsider\tests\GetDocumentInsider.Tests.csproj", "{6A19D94D-2BC6-4198-BE2E-342688FDBA4B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GetDocumentSample", "src\Tools\GetDocumentInsider\sample\GetDocumentSample.csproj", "{D8F7091E-A2D1-4E81-BA7C-97EAE392D683}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10941,6 +10945,38 @@ Global {9536C284-65B4-4884-BB50-06D629095C3E}.Release|x64.Build.0 = Release|Any CPU {9536C284-65B4-4884-BB50-06D629095C3E}.Release|x86.ActiveCfg = Release|Any CPU {9536C284-65B4-4884-BB50-06D629095C3E}.Release|x86.Build.0 = Release|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Debug|arm64.ActiveCfg = Debug|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Debug|arm64.Build.0 = Debug|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Debug|x64.ActiveCfg = Debug|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Debug|x64.Build.0 = Debug|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Debug|x86.ActiveCfg = Debug|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Debug|x86.Build.0 = Debug|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Release|Any CPU.Build.0 = Release|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Release|arm64.ActiveCfg = Release|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Release|arm64.Build.0 = Release|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Release|x64.ActiveCfg = Release|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Release|x64.Build.0 = Release|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Release|x86.ActiveCfg = Release|Any CPU + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B}.Release|x86.Build.0 = Release|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Debug|arm64.ActiveCfg = Debug|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Debug|arm64.Build.0 = Debug|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Debug|x64.Build.0 = Debug|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Debug|x86.Build.0 = Debug|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|Any CPU.Build.0 = Release|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|arm64.ActiveCfg = Release|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|arm64.Build.0 = Release|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|x64.ActiveCfg = Release|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|x64.Build.0 = Release|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|x86.ActiveCfg = Release|Any CPU + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11836,6 +11872,8 @@ Global {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} {4D8DE54A-4F32-4881-B07B-DDC79619E573} = {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} {9536C284-65B4-4884-BB50-06D629095C3E} = {274100A5-5B2D-4EA2-AC42-A62257FC6BDC} + {6A19D94D-2BC6-4198-BE2E-342688FDBA4B} = {A1B75FC7-A777-4412-A635-D0C9ED8FE7A0} + {D8F7091E-A2D1-4E81-BA7C-97EAE392D683} = {A1B75FC7-A777-4412-A635-D0C9ED8FE7A0} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/OpenApi/src/Services/IDocumentProvider.cs b/src/OpenApi/src/Services/IDocumentProvider.cs index 61ef9dc560fe..31344117305e 100644 --- a/src/OpenApi/src/Services/IDocumentProvider.cs +++ b/src/OpenApi/src/Services/IDocumentProvider.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.OpenApi; + namespace Microsoft.Extensions.ApiDescriptions; /// @@ -20,4 +22,5 @@ internal interface IDocumentProvider { IEnumerable GetDocumentNames(); Task GenerateAsync(string documentName, TextWriter writer); + Task GenerateAsync(string documentName, TextWriter writer, OpenApiSpecVersion openApiSpecVersion); } diff --git a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs index 831475f8960a..88640d624319 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs @@ -1,12 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.OpenApi.Writers; -using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using System.Linq; +using Microsoft.OpenApi; using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Writers; +using System.Linq; namespace Microsoft.Extensions.ApiDescriptions; @@ -19,16 +20,29 @@ internal sealed class OpenApiDocumentProvider(IServiceProvider serviceProvider) /// The name of the document to resolve. /// A text writer associated with the document to write to. public async Task GenerateAsync(string documentName, TextWriter writer) + { + var optionsSnapshot = serviceProvider.GetRequiredService>(); + var namedOption = optionsSnapshot.Get(documentName); + var resolvedOpenApiVersion = namedOption.OpenApiVersion; + await GenerateAsync(documentName, writer, resolvedOpenApiVersion); + } + + /// + /// Serializes the OpenAPI document associated with a given document name to + /// the provided writer under the provided OpenAPI spec version. + /// + /// The name of the document to resolve. + /// A text writer associated with the document to write to. + /// The OpenAPI specification version to use when serializing the document. + public async Task GenerateAsync(string documentName, TextWriter writer, OpenApiSpecVersion openApiSpecVersion) { // Microsoft.OpenAPI does not provide async APIs for writing the JSON // document to a file. See https://github.com/microsoft/OpenAPI.NET/issues/421 for // more info. var targetDocumentService = serviceProvider.GetRequiredKeyedService(documentName); - var options = serviceProvider.GetRequiredService>(); - var namedOption = options.Get(documentName); var document = await targetDocumentService.GetOpenApiDocumentAsync(); var jsonWriter = new OpenApiJsonWriter(writer); - document.Serialize(jsonWriter, namedOption.OpenApiVersion); + document.Serialize(jsonWriter, openApiSpecVersion); } /// diff --git a/src/Tools/GetDocumentInsider/sample/GetDocumentSample.csproj b/src/Tools/GetDocumentInsider/sample/GetDocumentSample.csproj new file mode 100644 index 000000000000..3396143d9a9a --- /dev/null +++ b/src/Tools/GetDocumentInsider/sample/GetDocumentSample.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultNetCoreTargetFramework) + enable + enable + GetDocumentSample + + + + + + + + + + diff --git a/src/Tools/GetDocumentInsider/sample/Program.cs b/src/Tools/GetDocumentInsider/sample/Program.cs new file mode 100644 index 000000000000..065f8479a925 --- /dev/null +++ b/src/Tools/GetDocumentInsider/sample/Program.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace GetDocumentSample; + +public class Program +{ + private static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddOpenApi(); + builder.Services.AddOpenApi("internal"); + + var app = builder.Build(); + + app.MapOpenApi(); + + app.MapGet("/hello/{name}", (string name) => $"Hello {name}!"); + app.MapGet("/bye/{name}", (string name) => $"Bye {name}!") + .WithGroupName("internal"); + + app.Run(); + } +} diff --git a/src/Tools/GetDocumentInsider/sample/Properties/launchSettings.json b/src/Tools/GetDocumentInsider/sample/Properties/launchSettings.json new file mode 100644 index 000000000000..9e7b369886f0 --- /dev/null +++ b/src/Tools/GetDocumentInsider/sample/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:29400", + "sslPort": 44315 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5148", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7067;http://localhost:5148", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Tools/GetDocumentInsider/sample/appsettings.Development.json b/src/Tools/GetDocumentInsider/sample/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/src/Tools/GetDocumentInsider/sample/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Tools/GetDocumentInsider/sample/appsettings.json b/src/Tools/GetDocumentInsider/sample/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/src/Tools/GetDocumentInsider/sample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommand.cs b/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommand.cs index 81159c11d8ba..efc2caa0cfb8 100644 --- a/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommand.cs +++ b/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommand.cs @@ -17,6 +17,8 @@ internal sealed class GetDocumentCommand : ProjectCommandBase { private CommandOption _fileListPath; private CommandOption _output; + private CommandOption _openApiVersion; + private CommandOption _documentName; public GetDocumentCommand(IConsole console) : base(console) { @@ -28,6 +30,8 @@ public override void Configure(CommandLineApplication command) _fileListPath = command.Option("--file-list ", Resources.FileListDescription); _output = command.Option("--output ", Resources.OutputDescription); + _openApiVersion = command.Option("--openapi-version ", Resources.OpenApiVersionDescription); + _documentName = command.Option("--document-name ", Resources.DocumentNameDescription); } protected override void Validate() @@ -43,6 +47,12 @@ protected override void Validate() { throw new CommandException(Resources.FormatMissingOption(_output.LongName)); } + + // No need to validate --openapi-version, we'll fallback to whatever is configured by + // the runtime in the event that none is provided. + + // No need to validate --document-name, we'll fallback to generating OpenAPI files for + // documents registered in the application in the event that none is provided. } protected override int Execute() @@ -52,7 +62,7 @@ protected override int Execute() var toolsDirectory = ToolsDirectory.Value(); var packagedAssemblies = Directory .EnumerateFiles(toolsDirectory, "*.dll") - .Except(new[] { Path.GetFullPath(thisAssembly.Location) }) + .Except([Path.GetFullPath(thisAssembly.Location)]) .ToDictionary(Path.GetFileNameWithoutExtension, path => new AssemblyInfo(path)); // Explicitly load all assemblies we need first to preserve target project as much as possible. This @@ -128,6 +138,8 @@ protected override int Execute() AssemblyName = Path.GetFileNameWithoutExtension(assemblyPath), FileListPath = _fileListPath.Value(), OutputDirectory = _output.Value(), + OpenApiVersion = _openApiVersion.Value(), + DocumentName = _documentName.Value(), ProjectName = ProjectName.Value(), Reporter = Reporter, }; diff --git a/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandContext.cs b/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandContext.cs index 780571671a1d..558298a00a48 100644 --- a/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandContext.cs +++ b/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandContext.cs @@ -19,5 +19,16 @@ public class GetDocumentCommandContext public string ProjectName { get; set; } + /// + /// The version of the OpenAPI document to generate. + /// Maps to . + /// Can be null, in which case is used. + /// + public string OpenApiVersion { get; set; } + + // The name of the OpenAPI document to generate. + // Generates all documents if not provided. + public string DocumentName { get; set; } + public IReporter Reporter { get; set; } } diff --git a/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs b/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs index b5f63acd8416..ce7c5a81f188 100644 --- a/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs +++ b/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs @@ -5,12 +5,14 @@ using System.Collections; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Tools.Internal; +using Microsoft.OpenApi; #if NET7_0_OR_GREATER using Microsoft.AspNetCore.Hosting.Server; using Microsoft.Extensions.DependencyInjection; @@ -27,18 +29,19 @@ internal sealed class GetDocumentCommandWorker private const string InvalidFilenameString = ".."; private const string JsonExtension = ".json"; private const string UnderscoreString = "_"; - private static readonly char[] InvalidFilenameCharacters = Path.GetInvalidFileNameChars(); - private static readonly Encoding UTF8EncodingWithoutBOM + private static readonly char[] _invalidFilenameCharacters = Path.GetInvalidFileNameChars(); + private static readonly Encoding _utf8EncodingWithoutBOM = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); private const string GetDocumentsMethodName = "GetDocumentNames"; - private static readonly object[] GetDocumentsArguments = Array.Empty(); - private static readonly Type[] GetDocumentsParameterTypes = Type.EmptyTypes; - private static readonly Type GetDocumentsReturnType = typeof(IEnumerable); + private static readonly object[] _getDocumentsArguments = Array.Empty(); + private static readonly Type[] _getDocumentsParameterTypes = Type.EmptyTypes; + private static readonly Type _getDocumentsReturnType = typeof(IEnumerable); private const string GenerateMethodName = "GenerateAsync"; - private static readonly Type[] GenerateMethodParameterTypes = new[] { typeof(string), typeof(TextWriter) }; - private static readonly Type GenerateMethodReturnType = typeof(Task); + private static readonly Type[] _generateMethodParameterTypes = [typeof(string), typeof(TextWriter)]; + private static readonly Type[] _generateWithVersionMethodParameterTypes = [typeof(string), typeof(TextWriter), typeof(OpenApiSpecVersion)]; + private static readonly Type _generateMethodReturnType = typeof(Task); private readonly GetDocumentCommandContext _context; private readonly IReporter _reporter; @@ -110,7 +113,7 @@ void OnEntryPointExit(Exception exception) try { // Retrieve the service provider from the target host. - var services = ((IHost)factory(new[] { $"--{HostDefaults.ApplicationKey}={assemblyName}" })).Services; + var services = ((IHost)factory([$"--{HostDefaults.ApplicationKey}={assemblyName}"])).Services; if (services == null) { _reporter.WriteError(Resources.FormatServiceProviderNotFound( @@ -206,18 +209,39 @@ private bool GetDocuments(IServiceProvider services) var getDocumentsMethod = GetMethod( GetDocumentsMethodName, serviceType, - GetDocumentsParameterTypes, - GetDocumentsReturnType); + _getDocumentsParameterTypes, + _getDocumentsReturnType); if (getDocumentsMethod == null) { return false; } + var generateWithVersionMethod = serviceType.GetMethod( + GenerateMethodName, + _generateWithVersionMethodParameterTypes); + + if (generateWithVersionMethod is not null) + { + if (generateWithVersionMethod.IsStatic) + { + _reporter.WriteWarning(Resources.FormatMethodIsStatic(GenerateMethodName, serviceType)); + generateWithVersionMethod = null; + } + + if (!_generateMethodReturnType.IsAssignableFrom(generateWithVersionMethod.ReturnType)) + { + _reporter.WriteWarning( + Resources.FormatMethodReturnTypeUnsupported(GenerateMethodName, serviceType, generateWithVersionMethod.ReturnType, _generateMethodReturnType)); + generateWithVersionMethod = null; + + } + } + var generateMethod = GetMethod( GenerateMethodName, serviceType, - GenerateMethodParameterTypes, - GenerateMethodReturnType); + _generateMethodParameterTypes, + _generateMethodReturnType); if (generateMethod == null) { return false; @@ -230,12 +254,19 @@ private bool GetDocuments(IServiceProvider services) return false; } - var documentNames = (IEnumerable)InvokeMethod(getDocumentsMethod, service, GetDocumentsArguments); + // If an explicit document name is provided, then generate only that document. + var documentNames = (IEnumerable)InvokeMethod(getDocumentsMethod, service, _getDocumentsArguments); if (documentNames == null) { return false; } + if (!string.IsNullOrEmpty(_context.DocumentName) && !documentNames.Contains(_context.DocumentName)) + { + _reporter.WriteError(Resources.FormatDocumentNotFound(_context.DocumentName)); + return false; + } + // Write out the documents. var found = false; Directory.CreateDirectory(_context.OutputDirectory); @@ -247,7 +278,8 @@ private bool GetDocuments(IServiceProvider services) _context.ProjectName, _context.OutputDirectory, generateMethod, - service); + service, + generateWithVersionMethod); if (filePath == null) { return false; @@ -275,15 +307,30 @@ private string GetDocument( string projectName, string outputDirectory, MethodInfo generateMethod, - object service) + object service, + MethodInfo? generateWithVersionMethod) { _reporter.WriteInformation(Resources.FormatGeneratingDocument(documentName)); using var stream = new MemoryStream(); - using (var writer = new StreamWriter(stream, UTF8EncodingWithoutBOM, bufferSize: 1024, leaveOpen: true)) + using (var writer = new StreamWriter(stream, _utf8EncodingWithoutBOM, bufferSize: 1024, leaveOpen: true)) { - var arguments = new object[] { documentName, writer }; - using var resultTask = (Task)InvokeMethod(generateMethod, service, arguments); + var targetMethod = generateWithVersionMethod ?? generateMethod; + object[] arguments = [documentName, writer]; + if (generateWithVersionMethod != null) + { + _reporter.WriteInformation(Resources.VersionedGenerateMethod); + if (Enum.TryParse(_context.OpenApiVersion, out var version)) + { + arguments = [documentName, writer, version]; + } + else + { + _reporter.WriteWarning(Resources.FormatInvalidOpenApiVersion(_context.OpenApiVersion)); + arguments = [documentName, writer, OpenApiSpecVersion.OpenApi3_0]; + } + } + using var resultTask = (Task)InvokeMethod(targetMethod, service, arguments); if (resultTask == null) { return null; @@ -338,7 +385,7 @@ private static string GetDocumentPath(string documentName, string projectName, s // characters such as '/' and '?' and the string "..". Do not treat slashes as folder separators. var sanitizedDocumentName = string.Join( UnderscoreString, - documentName.Split(InvalidFilenameCharacters)); + documentName.Split(_invalidFilenameCharacters)); while (sanitizedDocumentName.Contains(InvalidFilenameString)) { @@ -395,19 +442,19 @@ private object InvokeMethod(MethodInfo method, object instance, object[] argumen } #if NET7_0_OR_GREATER - private sealed class NoopHostLifetime : IHostLifetime - { - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } + private sealed class NoopHostLifetime : IHostLifetime + { + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } - private sealed class NoopServer : IServer - { - public IFeatureCollection Features { get; } = new FeatureCollection(); - public void Dispose() { } - public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + private sealed class NoopServer : IServer + { + public IFeatureCollection Features { get; } = new FeatureCollection(); + public void Dispose() { } + public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } + } #endif } diff --git a/src/Tools/GetDocumentInsider/src/GetDocument.Insider.csproj b/src/Tools/GetDocumentInsider/src/GetDocument.Insider.csproj index 77e80185d292..b9196a12777a 100644 --- a/src/Tools/GetDocumentInsider/src/GetDocument.Insider.csproj +++ b/src/Tools/GetDocumentInsider/src/GetDocument.Insider.csproj @@ -30,6 +30,7 @@ + @@ -38,4 +39,8 @@ + + + + diff --git a/src/Tools/GetDocumentInsider/src/ProgramBase.cs b/src/Tools/GetDocumentInsider/src/ProgramBase.cs index a67281417b9d..3453b4c33b52 100644 --- a/src/Tools/GetDocumentInsider/src/ProgramBase.cs +++ b/src/Tools/GetDocumentInsider/src/ProgramBase.cs @@ -31,7 +31,8 @@ protected static IConsole GetConsole() return console; } - protected int Run(string[] args, CommandBase command, bool throwOnUnexpectedArg) + // Internal for testing + internal int Run(string[] args, CommandBase command, bool throwOnUnexpectedArg) { try { diff --git a/src/Tools/GetDocumentInsider/src/Resources.resx b/src/Tools/GetDocumentInsider/src/Resources.resx index 6a7e8287afca..c45f510dce7b 100644 --- a/src/Tools/GetDocumentInsider/src/Resources.resx +++ b/src/Tools/GetDocumentInsider/src/Resources.resx @@ -1,17 +1,17 @@  - @@ -194,4 +194,19 @@ Suppresses all output except warnings and errors. - \ No newline at end of file + + The OpenAPI spec version to use when generating the document. Optional. + + + The name of the OpenAPI document to generate. Optional. + + + Invalid OpenAPI spec version '{0}' provided. Falling back to default: v3.0. + + + Document with name '{0}' not found. + + + Using discovered `GenerateAsync` overload with version parameter. + + diff --git a/src/Tools/GetDocumentInsider/tests/GetDocumentInsider.Tests.csproj b/src/Tools/GetDocumentInsider/tests/GetDocumentInsider.Tests.csproj new file mode 100644 index 000000000000..de41ba15c714 --- /dev/null +++ b/src/Tools/GetDocumentInsider/tests/GetDocumentInsider.Tests.csproj @@ -0,0 +1,19 @@ + + + + $(DefaultNetCoreTargetFramework) + Microsoft.Extensions.ApiDescription.Tool.Tests + + + + + + + + + + + + + + diff --git a/src/Tools/GetDocumentInsider/tests/GetDocumentTests.cs b/src/Tools/GetDocumentInsider/tests/GetDocumentTests.cs new file mode 100644 index 000000000000..69c7915274a8 --- /dev/null +++ b/src/Tools/GetDocumentInsider/tests/GetDocumentTests.cs @@ -0,0 +1,147 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Tools.Internal; +using Xunit.Abstractions; +using Microsoft.Extensions.ApiDescription.Tool.Commands; +using Microsoft.OpenApi.Readers; +using System.Reflection; +using System.Runtime.Versioning; +using Microsoft.OpenApi; + +namespace Microsoft.Extensions.ApiDescription.Tool.Tests; + +public class GetDocumentTests(ITestOutputHelper output) +{ + private readonly TestConsole _console = new(output); + private readonly string _testAppAssembly = typeof(GetDocumentSample.Program).Assembly.Location; + private readonly string _testAppProject = "Sample"; + private readonly string _testAppFrameworkMoniker = typeof(Program).Assembly.GetCustomAttribute().FrameworkName; + private readonly string _toolsDirectory = Path.GetDirectoryName(typeof(Program).Assembly.Location); + + [Fact] + public void GetDocument_Works() + { + // Arrange + var outputPath = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + var app = new Program(_console); + + // Act + app.Run([ + "--assembly", _testAppAssembly, + "--project", _testAppProject, + "--framework", _testAppFrameworkMoniker, + "--tools-directory", _toolsDirectory, + "--output", outputPath.FullName, + "--file-list", Path.Combine(outputPath.FullName, "file-list.cache") + ], new GetDocumentCommand(_console), throwOnUnexpectedArg: false); + + // Assert + var document = new OpenApiStreamReader().Read(File.OpenRead(Path.Combine(outputPath.FullName, "Sample.json")), out var diagnostic); + Assert.Empty(diagnostic.Errors); + Assert.Equal(OpenApiSpecVersion.OpenApi3_0, diagnostic.SpecificationVersion); + Assert.Equal("GetDocumentSample | v1", document.Info.Title); + } + + [Fact] + public void GetDocument_WithOpenApiVersion_Works() + { + // Arrange + var outputPath = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + var app = new Program(_console); + + // Act + app.Run([ + "--assembly", _testAppAssembly, + "--project", _testAppProject, + "--framework", _testAppFrameworkMoniker, + "--tools-directory", _toolsDirectory, + "--output", outputPath.FullName, + "--file-list", Path.Combine(outputPath.FullName, "file-list.cache"), + "--openapi-version", "OpenApi2_0" + ], new GetDocumentCommand(_console), throwOnUnexpectedArg: false); + + // Assert + var document = new OpenApiStreamReader().Read(File.OpenRead(Path.Combine(outputPath.FullName, "Sample.json")), out var diagnostic); + Assert.Empty(diagnostic.Errors); + Assert.Equal(OpenApiSpecVersion.OpenApi2_0, diagnostic.SpecificationVersion); + Assert.Equal("GetDocumentSample | v1", document.Info.Title); + } + + [Fact] + public void GetDocument_WithInvalidOpenApiVersion_Errors() + { + // Arrange + var outputPath = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + var app = new Program(_console); + + // Act + app.Run([ + "--assembly", _testAppAssembly, + "--project", _testAppProject, + "--framework", _testAppFrameworkMoniker, + "--tools-directory", _toolsDirectory, + "--output", outputPath.FullName, + "--file-list", Path.Combine(outputPath.FullName, "file-list.cache"), + "--openapi-version", "OpenApi4_0" + ], new GetDocumentCommand(_console), throwOnUnexpectedArg: false); + + // Assert that error was produced and files were generated with v3. + Assert.Contains("Invalid OpenAPI spec version 'OpenApi4_0' provided. Falling back to default: v3.0.", _console.GetOutput()); + var document = new OpenApiStreamReader().Read(File.OpenRead(Path.Combine(outputPath.FullName, "Sample.json")), out var diagnostic); + Assert.Empty(diagnostic.Errors); + Assert.Equal(OpenApiSpecVersion.OpenApi3_0, diagnostic.SpecificationVersion); + Assert.Equal("GetDocumentSample | v1", document.Info.Title); + } + + [Fact] + public void GetDocument_WithDocumentName_Works() + { + // Arrange + var outputPath = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + var app = new Program(_console); + + // Act + app.Run([ + "--assembly", _testAppAssembly, + "--project", _testAppProject, + "--framework", _testAppFrameworkMoniker, + "--tools-directory", _toolsDirectory, + "--output", outputPath.FullName, + "--file-list", Path.Combine(outputPath.FullName, "file-list.cache"), + "--document-name", "internal" + ], new GetDocumentCommand(_console), throwOnUnexpectedArg: false); + + // Assert + var document = new OpenApiStreamReader().Read(File.OpenRead(Path.Combine(outputPath.FullName, "Sample_internal.json")), out var diagnostic); + Assert.Empty(diagnostic.Errors); + Assert.Equal(OpenApiSpecVersion.OpenApi3_0, diagnostic.SpecificationVersion); + // Document name in the title gives us a clue that the correct document was actually resolved + Assert.Equal("GetDocumentSample | internal", document.Info.Title); + } + + [Fact] + public void GetDocument_WithInvalidDocumentName_Errors() + { + // Arrange + var outputPath = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + var app = new Program(_console); + + // Act + app.Run([ + "--assembly", _testAppAssembly, + "--project", _testAppProject, + "--framework", _testAppFrameworkMoniker, + "--tools-directory", _toolsDirectory, + "--output", outputPath.FullName, + "--file-list", Path.Combine(outputPath.FullName, "file-list.cache"), + "--document-name", "invalid" + ], new GetDocumentCommand(_console), throwOnUnexpectedArg: false); + + // Assert that error was produced and no files were generated + Assert.Contains("Document with name 'invalid' not found.", _console.GetOutput()); + Assert.False(File.Exists(Path.Combine(outputPath.FullName, "Sample.json"))); + Assert.False(File.Exists(Path.Combine(outputPath.FullName, "Sample_internal.json"))); + Assert.False(File.Exists(Path.Combine(outputPath.FullName, "Sample_invalid.json"))); + } +} diff --git a/src/Tools/Tools.slnf b/src/Tools/Tools.slnf index a95ac2e0a6e9..484313af8712 100644 --- a/src/Tools/Tools.slnf +++ b/src/Tools/Tools.slnf @@ -110,6 +110,8 @@ "src\\Tools\\FirstRunCertGenerator\\src\\Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj", "src\\Tools\\FirstRunCertGenerator\\test\\Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests.csproj", "src\\Tools\\GetDocumentInsider\\src\\GetDocument.Insider.csproj", + "src\\Tools\\GetDocumentInsider\\tests\\GetDocumentInsider.Tests.csproj", + "src\\Tools\\GetDocumentInsider\\sample\\GetDocumentSample.csproj", "src\\Tools\\LinkabilityChecker\\LinkabilityChecker.csproj", "src\\Tools\\Microsoft.dotnet-openapi\\src\\Microsoft.dotnet-openapi.csproj", "src\\Tools\\Microsoft.dotnet-openapi\\test\\dotnet-microsoft.openapi.Tests.csproj", @@ -123,4 +125,4 @@ "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] } -} \ No newline at end of file +}