diff --git a/.gitignore b/.gitignore index 0a70ab9..7aff56f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ +[Tt]ool/ # Visual Studio 2015/2017 cache/options directory .vs/ diff --git a/src/Workleap.OpenApi.MSBuild/HttpClientWrapper.cs b/src/Workleap.OpenApi.MSBuild/HttpClientWrapper.cs new file mode 100644 index 0000000..1146a1e --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/HttpClientWrapper.cs @@ -0,0 +1,42 @@ +using System.Net; + +namespace Workleap.OpenApi.MSBuild; + +internal sealed class HttpClientWrapper : IHttpClientWrapper, IDisposable +{ + private readonly HttpClient _httpClient = new(); + + public async Task DownloadFileToDestinationAsync(string url, string destination, CancellationToken cancellationToken) + { + using var responseStream = await this._httpClient.GetStreamAsync(url); + + if (responseStream == null) + { + using var retryResponseStream = await this._httpClient.GetStreamAsync(url); + if (retryResponseStream != null) + { + await SaveFileFromResponseAsync(destination, retryResponseStream, cancellationToken); + } + else + { + throw new OpenApiTaskFailedException("Spectral could not be downloaded."); + } + } + else + { + await SaveFileFromResponseAsync(destination, responseStream, cancellationToken); + } + } + + private static async Task SaveFileFromResponseAsync(string destination, Stream responseStream, CancellationToken cancellationToken) + { + using var fileTarget = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None); + + await responseStream.CopyToAsync(fileTarget, 81920, cancellationToken); + } + + public void Dispose() + { + this._httpClient.Dispose(); + } +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/IHttpClientWrapper.cs b/src/Workleap.OpenApi.MSBuild/IHttpClientWrapper.cs new file mode 100644 index 0000000..0ecd3e1 --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/IHttpClientWrapper.cs @@ -0,0 +1,6 @@ +namespace Workleap.OpenApi.MSBuild; + +internal interface IHttpClientWrapper +{ + Task DownloadFileToDestinationAsync(string url, string destination, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/ILoggerWrapper.cs b/src/Workleap.OpenApi.MSBuild/ILoggerWrapper.cs new file mode 100644 index 0000000..7fcdad0 --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/ILoggerWrapper.cs @@ -0,0 +1,10 @@ +using Microsoft.Build.Utilities; + +namespace Workleap.OpenApi.MSBuild; + +public interface ILoggerWrapper +{ + void LogWarning(string message, params object[] messageArgs); + + void LogMessage(string message, params object[] messageArgs); +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/IProcessWrapper.cs b/src/Workleap.OpenApi.MSBuild/IProcessWrapper.cs new file mode 100644 index 0000000..7040fff --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/IProcessWrapper.cs @@ -0,0 +1,6 @@ +namespace Workleap.OpenApi.MSBuild; + +public interface IProcessWrapper +{ + public Task RunProcessAsync(string filename, string[] arguments, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/ISpectralManager.cs b/src/Workleap.OpenApi.MSBuild/ISpectralManager.cs new file mode 100644 index 0000000..11fd5db --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/ISpectralManager.cs @@ -0,0 +1,6 @@ +namespace Workleap.OpenApi.MSBuild; + +public interface ISpectralManager +{ + public Task InstallSpectralAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/ISwaggerManager.cs b/src/Workleap.OpenApi.MSBuild/ISwaggerManager.cs new file mode 100644 index 0000000..d58c4f7 --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/ISwaggerManager.cs @@ -0,0 +1,10 @@ +namespace Workleap.OpenApi.MSBuild; + +public interface ISwaggerManager +{ + Task RunSwaggerAsync(string[] openApiSwaggerDocumentNames, CancellationToken cancellationToken); + + Task InstallSwaggerCliAsync(CancellationToken cancellationToken); + + Task GenerateOpenApiSpecAsync(string swaggerExePath, string outputOpenApiSpecPath, string documentName, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/LoggerWrapper.cs b/src/Workleap.OpenApi.MSBuild/LoggerWrapper.cs new file mode 100644 index 0000000..95b59b3 --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/LoggerWrapper.cs @@ -0,0 +1,23 @@ +using Microsoft.Build.Utilities; + +namespace Workleap.OpenApi.MSBuild; + +internal sealed class LoggerWrapper : ILoggerWrapper +{ + public LoggerWrapper(TaskLoggingHelper helper) + { + this.Helper = helper; + } + + public TaskLoggingHelper Helper { get; set; } + + public void LogWarning(string message, params object[] messageArgs) + { + this.Helper.LogWarning(message, messageArgs); + } + + public void LogMessage(string message, params object[] messageArgs) + { + this.Helper.LogMessage(message, messageArgs); + } +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/OpenApiTaskFailedException.cs b/src/Workleap.OpenApi.MSBuild/OpenApiTaskFailedException.cs new file mode 100644 index 0000000..56b0066 --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/OpenApiTaskFailedException.cs @@ -0,0 +1,8 @@ +namespace Workleap.OpenApi.MSBuild; + +public class OpenApiTaskFailedException : Exception +{ + public OpenApiTaskFailedException(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/ProcessWrapper.cs b/src/Workleap.OpenApi.MSBuild/ProcessWrapper.cs new file mode 100644 index 0000000..1ae50d6 --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/ProcessWrapper.cs @@ -0,0 +1,27 @@ +using CliWrap; +using CliWrap.Buffered; + +namespace Workleap.OpenApi.MSBuild; + +internal sealed class ProcessWrapper : IProcessWrapper +{ + private readonly string _workingDirectory; + + public ProcessWrapper(string workingDirectory) + { + this._workingDirectory = workingDirectory; + } + + public async Task RunProcessAsync(string filename, string[] arguments, CancellationToken cancellationToken) + { + var result = await Cli.Wrap(filename) + .WithWorkingDirectory(this._workingDirectory) + .WithValidation(CommandResultValidation.None) + .WithArguments(arguments) + .WithStandardOutputPipe(PipeTarget.ToStream(Console.OpenStandardOutput())) + .WithStandardErrorPipe(PipeTarget.ToStream(Console.OpenStandardError())) + .ExecuteBufferedAsync(cancellationToken); + + return result.ExitCode; + } +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/SpectralManager.cs b/src/Workleap.OpenApi.MSBuild/SpectralManager.cs new file mode 100644 index 0000000..339e42f --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/SpectralManager.cs @@ -0,0 +1,89 @@ +using System.Runtime.InteropServices; +using Microsoft.Build.Framework; + +namespace Workleap.OpenApi.MSBuild; + +internal sealed class SpectralManager : ISpectralManager +{ + private const string SpectralVersion = "6.11.0"; + + private readonly ILoggerWrapper _loggerWrapper; + private readonly IHttpClientWrapper _httpClientWrapper; + private readonly string _toolDirectory; + + public SpectralManager(LoggerWrapper loggerWrapper, string openApiToolsDirectoryPath, IHttpClientWrapper httpClientWrapper) + { + this._loggerWrapper = loggerWrapper; + this._httpClientWrapper = httpClientWrapper; + this._toolDirectory = Path.Combine(openApiToolsDirectoryPath, "spectral", SpectralVersion); + } + + public async Task InstallSpectralAsync(CancellationToken cancellationToken) + { + Directory.CreateDirectory(this._toolDirectory); + + var executableFileName = GetSpectralFileName(); + var url = $"https://github.com/stoplightio/spectral/releases/download/v{SpectralVersion}/{executableFileName}"; + + await this._httpClientWrapper.DownloadFileToDestinationAsync(url, Path.Combine(this._toolDirectory, executableFileName), cancellationToken); + } + + private static string GetSpectralFileName() + { + var osType = GetOperatingSystem(); + var architecture = GetArchitecture(); + + if (osType == "linux") + { + var distro = File.Exists("/etc/os-release") ? File.ReadAllText("/etc/os-release") : string.Empty; + if (distro.Contains("Alpine Linux")) + { + osType = "alpine"; + } + } + + var fileName = $"spectral-{osType}-{architecture}"; + + if (osType == "win") + { + fileName = "spectral.exe"; + } + + return fileName; + } + + private static string GetOperatingSystem() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "linux"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "macos"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "win"; + } + + throw new OpenApiTaskFailedException("Unknown operating system encountered"); + } + + private static string GetArchitecture() + { + if (RuntimeInformation.OSArchitecture == Architecture.X64) + { + return "x64"; + } + + if (RuntimeInformation.OSArchitecture == Architecture.Arm64) + { + return "arm64"; + } + + throw new OpenApiTaskFailedException("Unknown processor architecture encountered"); + } +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/SwaggerManager.cs b/src/Workleap.OpenApi.MSBuild/SwaggerManager.cs new file mode 100644 index 0000000..24edf62 --- /dev/null +++ b/src/Workleap.OpenApi.MSBuild/SwaggerManager.cs @@ -0,0 +1,67 @@ +using System.Runtime.InteropServices; + +namespace Workleap.OpenApi.MSBuild; + +internal sealed class SwaggerManager : ISwaggerManager +{ + private const string SwaggerVersion = "6.5.0"; + private readonly IProcessWrapper _processWrapper; + private readonly ILoggerWrapper _loggerWrapper; + private readonly string _openApiWebApiAssemblyPath; + private readonly string _swaggerDirectory; + + public SwaggerManager(IProcessWrapper processWrapper, ILoggerWrapper loggerWrapper, string openApiToolsDirectoryPath, string openApiWebApiAssemblyPath) + { + this._processWrapper = processWrapper; + this._loggerWrapper = loggerWrapper; + this._openApiWebApiAssemblyPath = openApiWebApiAssemblyPath; + this._swaggerDirectory = Path.Combine(openApiToolsDirectoryPath, "swagger", SwaggerVersion); + } + + public async Task RunSwaggerAsync(string[] openApiSwaggerDocumentNames, CancellationToken cancellationToken) + { + var swaggerExePath = Path.Combine(this._swaggerDirectory, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "swagger.exe" : "swagger"); + + foreach (var documentName in openApiSwaggerDocumentNames) + { + var outputOpenApiSpecName = $"openapi-{documentName.ToLowerInvariant()}.yaml"; + + var outputOpenApiSpecPath = Path.Combine(this._swaggerDirectory, outputOpenApiSpecName); + + await this.GenerateOpenApiSpecAsync(swaggerExePath, outputOpenApiSpecPath, documentName, cancellationToken); + } + } + + public async Task InstallSwaggerCliAsync(CancellationToken cancellationToken) + { + var retryCount = 0; + while (retryCount < 2) + { + var exitCode = await this._processWrapper.RunProcessAsync("dotnet", new[] { "tool", "update", "Swashbuckle.AspNetCore.Cli", "--tool-path", this._swaggerDirectory, "--version", SwaggerVersion }, cancellationToken); + + if (exitCode != 0 && retryCount != 1) + { + this._loggerWrapper.LogWarning("Swashbuckle download failed. Retrying once more..."); + retryCount++; + continue; + } + + if (retryCount == 1 && exitCode != 0) + { + throw new OpenApiTaskFailedException("Swashbuckle CLI could not be installed."); + } + + break; + } + } + + public async Task GenerateOpenApiSpecAsync(string swaggerExePath, string outputOpenApiSpecPath, string documentName, CancellationToken cancellationToken) + { + var exitCode = await this._processWrapper.RunProcessAsync(swaggerExePath, new[] { "tofile", "--output", outputOpenApiSpecPath, "--yaml", this._openApiWebApiAssemblyPath, documentName }, cancellationToken); + + if (exitCode != 0) + { + throw new OpenApiTaskFailedException("OpenApi file could not be created."); + } + } +} \ No newline at end of file diff --git a/src/Workleap.OpenApi.MSBuild/ValidateOpenApiTask.cs b/src/Workleap.OpenApi.MSBuild/ValidateOpenApiTask.cs index 3e4d456..bb96563 100644 --- a/src/Workleap.OpenApi.MSBuild/ValidateOpenApiTask.cs +++ b/src/Workleap.OpenApi.MSBuild/ValidateOpenApiTask.cs @@ -24,14 +24,53 @@ public sealed class ValidateOpenApiTask : CancelableAsyncTask [Required] public string[] OpenApiSpecificationFiles { get; set; } = Array.Empty(); - protected override Task ExecuteAsync(CancellationToken cancellationToken) + protected override async Task ExecuteAsync(CancellationToken cancellationToken) { - this.Log.LogMessage(MessageImportance.High, "OpenApiWebApiAssemblyPath = '{0}'", this.OpenApiWebApiAssemblyPath); - this.Log.LogMessage(MessageImportance.High, "OpenApiToolsDirectoryPath = '{0}'", this.OpenApiToolsDirectoryPath); - this.Log.LogMessage(MessageImportance.High, "OpenApiSpectralRulesetUrl = '{0}'", this.OpenApiSpectralRulesetUrl); - this.Log.LogMessage(MessageImportance.High, "OpenApiSwaggerDocumentNames = '{0}'", string.Join(", ", this.OpenApiSwaggerDocumentNames)); - this.Log.LogMessage(MessageImportance.High, "OpenApiSpecificationFiles = '{0}'", string.Join(", ", this.OpenApiSpecificationFiles)); + var loggerWrapper = new LoggerWrapper(this.Log); + var processWrapper = new ProcessWrapper(this.OpenApiToolsDirectoryPath); + var swaggerManager = new SwaggerManager(processWrapper, loggerWrapper, this.OpenApiToolsDirectoryPath, this.OpenApiToolsDirectoryPath); + using var httpClientWrapper = new HttpClientWrapper(); - return Task.FromResult(true); + var spectralManager = new SpectralManager(loggerWrapper, this.OpenApiToolsDirectoryPath, httpClientWrapper); + + this.Log.LogMessage(MessageImportance.Low, "{0} = '{1}'", nameof(this.OpenApiWebApiAssemblyPath), this.OpenApiWebApiAssemblyPath); + this.Log.LogMessage(MessageImportance.Low, "{0} = '{1}'", nameof(this.OpenApiToolsDirectoryPath), this.OpenApiToolsDirectoryPath); + this.Log.LogMessage(MessageImportance.Low, "{0} = '{1}'", nameof(this.OpenApiSpectralRulesetUrl), this.OpenApiSpectralRulesetUrl); + this.Log.LogMessage(MessageImportance.Low, "{0} = '{1}'", nameof(this.OpenApiSwaggerDocumentNames), string.Join(", ", this.OpenApiSwaggerDocumentNames)); + this.Log.LogMessage(MessageImportance.Low, "{0} = '{1}'", nameof(this.OpenApiSpecificationFiles), string.Join(", ", this.OpenApiSpecificationFiles)); + + if (this.OpenApiSpecificationFiles.Length != this.OpenApiSwaggerDocumentNames.Length) + { + this.Log.LogWarning("You must provide the same amount of open api specification file names and swagger document file names"); + + return false; + } + + try + { + await this.GeneratePublicNugetSource(); + + await swaggerManager.InstallSwaggerCliAsync(cancellationToken); + await spectralManager.InstallSpectralAsync(cancellationToken); + + // await swaggerManager.RunSwaggerAsync(this.OpenApiSwaggerDocumentNames, cancellationToken); + } + catch (OpenApiTaskFailedException e) + { + this.Log.LogWarning("An error occured while validating the OpenAPI specification: {0}", e.Message); + } + + return true; + } + + private async Task GeneratePublicNugetSource() + { + Directory.CreateDirectory(this.OpenApiToolsDirectoryPath); + + if (!File.Exists(Path.Combine(this.OpenApiToolsDirectoryPath, "nuget.config"))) + { + var path = Path.Combine(this.OpenApiToolsDirectoryPath, "nuget.config"); + File.WriteAllText(path, "\n\n \n \n \n \n"); + } } } \ No newline at end of file