Skip to content

Commit

Permalink
Feature/idp 823 explicit contract first flow (#21)
Browse files Browse the repository at this point in the history
* [IDP-823] Add parameter to control Contract First vs Code first and opt-in code generation for validation

* Add limitation of running system test locally and workaround

---------

Co-authored-by: Mathieu Gamache <[email protected]>
Co-authored-by: Anthony Simmon <[email protected]>
  • Loading branch information
3 people authored Dec 20, 2023
1 parent 7f24464 commit 94ab454
Show file tree
Hide file tree
Showing 29 changed files with 550 additions and 47 deletions.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
# Workleap.OpenApi.MSBuild

Validates at build time that the OpenAPI specification files extracted from the ASP.NET Core Web API being built conform to Workleap API guidelines.

Depending if the user chose the Contract-First or Code-First development mode this MSBuild task will:

- Install tools: [OasDiff](https://github.com/Tufin/oasdiff), [Spectral](https://github.com/stoplightio/spectral), [SwashbuckleCLI](https://github.com/domaindrivendev/Swashbuckle.AspNetCore?tab=readme-ov-file#swashbuckleaspnetcorecli)
- Generate the OpenAPI specification file from the associated Web API
- Validate [Workleap Spectral rules](https://github.com/gsoft-inc/wl-api-guidelines/blob/main/.spectral.yaml)
- Compare the given OpenAPI specification file with the generated one

## How it works

[Official Documentation](https://learn.microsoft.com/en-us/visualstudio/msbuild/tutorial-custom-task-code-generation?view=vs-2022#include-msbuild-properties-and-targets-in-a-package)

For the TLDR version:

- The entry point is `ValidateOpenApiTask.ExecuteAsync()` and will be executed after the referencing project is built. This is defined in `./src/Workleap.OpenApi.MSBuild/msbuild/tools/Workleap.OpenApi.MSBuild.targets` as a `UsingTask.TaskName`
- The default value are defined in the property group on the target `ValidateOpenApi` in this file `./src/Workleap.OpenApi.MSBuild/msbuild/tools/Workleap.OpenApi.MSBuild.targets`

## How to test locally

### How debug the project

Since it's a MSBuild task named `ValidateOpenApi` you can run it this command: `msbuild /t:ValidateOpenApi`. `./src/WebApiDebugger` already have a `launchSettings.json` named `ValidateOpenApi` that you can run in debug.

### With the system test

This command `./Run-SystemTest.ps1` will:

1. Pack the MSBuild library
2. Build the WebApi.MsBuild.SystemTest and inject the previously packed library

Be careful since it will update the project dependencies to use a local version: do not commit this.

Also if you run it multiple time on the same branch you need to clear the local cache `%UserProfile%\.nuget\packages\workleap.openapi.msbuild` since the name won't change.
10 changes: 8 additions & 2 deletions Run-SystemTest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,20 @@ Process {

$workingDir = Join-Path $PSScriptRoot "src"
$outputDir = Join-Path $PSScriptRoot ".output"
$sysTestDir = Join-Path $PSScriptRoot "src/tests/WebApi.MsBuild.SystemTest"
$contractFirstSysTestDir = Join-Path $PSScriptRoot "src/tests/WebApi.MsBuild.SystemTest.ContractFirst"
$codeFirstSysTestDir = Join-Path $PSScriptRoot "src/tests/WebApi.MsBuild.SystemTest.CodeFirst"

try {
Push-Location $workingDir

Exec { & dotnet pack -c Release -o "$outputDir" }

Push-Location $sysTestDir
Push-Location $contractFirstSysTestDir

Exec { & dotnet add package Workleap.OpenApi.MSBuild --prerelease --source $outputDir }
Exec { & dotnet build -c Release }

Push-Location $codeFirstSysTestDir

Exec { & dotnet add package Workleap.OpenApi.MSBuild --prerelease --source $outputDir }
Exec { & dotnet build -c Release }
Expand Down
5 changes: 5 additions & 0 deletions src/WebApiDebugger/WebApiDebugger.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
<OpenApiDebuggingEnabled>true</OpenApiDebuggingEnabled>
</PropertyGroup>

<PropertyGroup>
<OpenApiDevelopmentMode>ContractFirst</OpenApiDevelopmentMode>
<OpenApiCompareCodeAgainstSpecFile>true</OpenApiCompareCodeAgainstSpecFile>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Workleap.OpenApi.MSBuild\Workleap.OpenApi.MSBuild.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
Expand Down
9 changes: 8 additions & 1 deletion src/Workleap.OpenApi.MSBuild.sln
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiDebugger", "WebApiDeb
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4DDE83BF-D190-4CC9-AD36-E9250DABB27D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.MsBuild.SystemTest", "tests\WebApi.MsBuild.SystemTest\WebApi.MsBuild.SystemTest.csproj", "{B8A81C76-574B-40FD-B34B-FA893D6C1423}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.MsBuild.SystemTest.ContractFirst", "tests\WebApi.MsBuild.SystemTest.ContractFirst\WebApi.MsBuild.SystemTest.ContractFirst.csproj", "{B8A81C76-574B-40FD-B34B-FA893D6C1423}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.MsBuild.SystemTest.CodeFirst", "tests\WebApi.MsBuild.SystemTest.CodeFirst\WebApi.MsBuild.SystemTest.CodeFirst.csproj", "{575E14D8-52E8-4B5B-A93C-D3E86E30BD3C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand All @@ -46,9 +48,14 @@ Global
{B8A81C76-574B-40FD-B34B-FA893D6C1423}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8A81C76-574B-40FD-B34B-FA893D6C1423}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8A81C76-574B-40FD-B34B-FA893D6C1423}.Release|Any CPU.Build.0 = Release|Any CPU
{575E14D8-52E8-4B5B-A93C-D3E86E30BD3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{575E14D8-52E8-4B5B-A93C-D3E86E30BD3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{575E14D8-52E8-4B5B-A93C-D3E86E30BD3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{575E14D8-52E8-4B5B-A93C-D3E86E30BD3C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{A4C90BE2-889F-46BB-8B4D-2219B1084695} = {07F66FC2-73B9-44C7-843C-36C13905AE9B}
{B8A81C76-574B-40FD-B34B-FA893D6C1423} = {4DDE83BF-D190-4CC9-AD36-E9250DABB27D}
{575E14D8-52E8-4B5B-A93C-D3E86E30BD3C} = {4DDE83BF-D190-4CC9-AD36-E9250DABB27D}
EndGlobalSection
EndGlobal
53 changes: 53 additions & 0 deletions src/Workleap.OpenApi.MSBuild/CodeFirstProcess.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Workleap.OpenApi.MSBuild;

/// <summary>
/// For a Code First approach it will:
/// 1. (if not disabled) Generate the OpenAPI specification files from the code
/// 2. Validate the OpenAPI specification files base on spectral rules
/// </summary>
internal class CodeFirstProcess
{
private const string DisableSpecGenEnvVarName = "WL_DISABLE_SPECGEN";

private readonly SpectralManager _spectralManager;
private readonly SwaggerManager _swaggerManager;

internal CodeFirstProcess(SpectralManager spectralManager, SwaggerManager swaggerManager)
{
this._spectralManager = spectralManager;
this._swaggerManager = swaggerManager;
}

internal async Task Execute(
string[] openApiSpecificationFiles,
string[] openApiSwaggerDocumentNames,
string openApiSpectralRulesetUrl,
CancellationToken cancellationToken)
{
var isGenerationEnabled = string.Equals(Environment.GetEnvironmentVariable(DisableSpecGenEnvVarName), "true", StringComparison.OrdinalIgnoreCase);

await this.InstallDependencies(isGenerationEnabled, cancellationToken);

if (isGenerationEnabled)
{
var generateOpenApiDocsPath = (await this._swaggerManager.RunSwaggerAsync(openApiSwaggerDocumentNames, cancellationToken)).ToList();
}

await this._spectralManager.RunSpectralAsync(openApiSpecificationFiles, openApiSpectralRulesetUrl, cancellationToken);
}

private async Task InstallDependencies(
bool isGenerationEnable,
CancellationToken cancellationToken)
{
var installationTasks = new List<Task>();
installationTasks.Add(this._spectralManager.InstallSpectralAsync(cancellationToken));

if (!isGenerationEnable)
{
installationTasks.Add(this._swaggerManager.InstallSwaggerCliAsync(cancellationToken));
}

await Task.WhenAll(installationTasks);
}
}
93 changes: 93 additions & 0 deletions src/Workleap.OpenApi.MSBuild/ContractFirstProcess.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
namespace Workleap.OpenApi.MSBuild;

/// <summary>
/// For a Contract First approach it will:
/// 1. Validate the OpenAPI specification files base on spectral rules
/// 2. If <see cref="CompareCodeAgainstSpecFile"/> is enabled, will generate the OpenAPI specification files from the code and validate if it match the provided specifications.
/// </summary>
internal class ContractFirstProcess
{
private readonly ILoggerWrapper _loggerWrapper;
private readonly SpectralManager _spectralManager;
private readonly SwaggerManager _swaggerManager;
private readonly OasdiffManager _oasdiffManager;

internal ContractFirstProcess(ILoggerWrapper loggerWrapper, SpectralManager spectralManager, SwaggerManager swaggerManager, OasdiffManager oasdiffManager)
{
this._loggerWrapper = loggerWrapper;
this._spectralManager = spectralManager;
this._swaggerManager = swaggerManager;
this._oasdiffManager = oasdiffManager;
}

internal enum CompareCodeAgainstSpecFile
{
Disabled,
Enabled,
}

internal async Task<bool> Execute(
string[] openApiSpecificationFiles,
string openApiToolsDirectoryPath,
string[] openApiSwaggerDocumentNames,
string openApiSpectralRulesetUrl,
CompareCodeAgainstSpecFile compareCodeAgainstSpecFile,
CancellationToken cancellationToken)
{
if (!this.CheckIfBaseSpecExists(openApiSpecificationFiles, openApiToolsDirectoryPath))
{
return false;
}

await this.InstallDependencies(compareCodeAgainstSpecFile, cancellationToken);

if (compareCodeAgainstSpecFile == CompareCodeAgainstSpecFile.Enabled)
{
var generateOpenApiDocsPath = (await this._swaggerManager.RunSwaggerAsync(openApiSwaggerDocumentNames, cancellationToken)).ToList();
await this._oasdiffManager.RunOasdiffAsync(openApiSpecificationFiles, generateOpenApiDocsPath, cancellationToken);
}

await this._spectralManager.RunSpectralAsync(openApiSpecificationFiles, openApiSpectralRulesetUrl, cancellationToken);

return true;
}

private bool CheckIfBaseSpecExists(
string[] openApiSpecificationFiles,
string openApiToolsDirectoryPath)
{
foreach (var file in openApiSpecificationFiles)
{
if (File.Exists(file))
{
continue;
}

this._loggerWrapper.LogWarning(
"The file '{0}' does not exist. If you are running this for the first time, we have generated specification here '{1}' which can be used as base specification. " +
"Please copy specification file(s) to your project directory and rebuild.",
file,
openApiToolsDirectoryPath);

return false;
}

return true;
}

private async Task InstallDependencies(
CompareCodeAgainstSpecFile compareCodeAgainstSpecFile,
CancellationToken cancellationToken)
{
var installationTasks = new List<Task>();
installationTasks.Add(this._spectralManager.InstallSpectralAsync(cancellationToken));

if (compareCodeAgainstSpecFile == CompareCodeAgainstSpecFile.Enabled)
{
installationTasks.Add(this._swaggerManager.InstallSwaggerCliAsync(cancellationToken));
installationTasks.Add(this._oasdiffManager.InstallOasdiffAsync(cancellationToken));
}

await Task.WhenAll(installationTasks);
}
}
2 changes: 1 addition & 1 deletion src/Workleap.OpenApi.MSBuild/SpectralManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public async Task RunSpectralAsync(IEnumerable<string> swaggerDocumentPaths, str
this._loggerWrapper.LogMessage("\n ******** Spectral: Validating {0} against ruleset ********", MessageImportance.High, documentPath);
File.Delete(htmlReportPath);
await this.GenerateSpectralReport(spectralExecutePath, documentPath, rulesetUrl, htmlReportPath, cancellationToken);
this._loggerWrapper.LogMessage("\n *********************************************************", MessageImportance.High, documentPath);
this._loggerWrapper.LogMessage("\n ****************************************************************", MessageImportance.High);
}
}

Expand Down
93 changes: 52 additions & 41 deletions src/Workleap.OpenApi.MSBuild/ValidateOpenApiTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,43 @@ namespace Workleap.OpenApi.MSBuild;

public sealed class ValidateOpenApiTask : CancelableAsyncTask
{
private const string CodeFirst = "CodeFirst";
private const string ContractFirst = "ContractFirst";

/// <summary>
/// 2 supported modes:
/// - CodeFirst: Generate the OpenAPI specification files from the code
/// - ContractFirst: Will use the OpenAPI specification files provided
/// </summary>
[Required]
public string OpenApiDevelopmentMode { get; set; } = string.Empty;

/// <summary>When Development mode is Contract first, will validate if the specification match the code.</summary>
[Required]
public bool OpenApiCompareCodeAgainstSpecFile { get; set; } = false;

/// <summary>The path of the ASP.NET Core project startup assembly directory.</summary>
[Required]
public string StartupAssemblyPath { get; set; } = string.Empty;

/// <summary>The path of the ASP.NET Core project being built.</summary>
[Required]
[Microsoft.Build.Framework.Required]
public string OpenApiWebApiAssemblyPath { get; set; } = string.Empty;

/// <summary>The base directory path where the OpenAPI tools will be downloaded.</summary>
[Required]
[Microsoft.Build.Framework.Required]
public string OpenApiToolsDirectoryPath { get; set; } = string.Empty;

/// <summary>The URL of the OpenAPI Spectral ruleset to validate against.</summary>
[Required]
[Microsoft.Build.Framework.Required]
public string OpenApiSpectralRulesetUrl { get; set; } = string.Empty;

/// <summary>The names of the Swagger documents to generate OpenAPI specifications for.</summary>
[Required]
[Microsoft.Build.Framework.Required]
public string[] OpenApiSwaggerDocumentNames { get; set; } = Array.Empty<string>();

/// <summary>The paths of the OpenAPI specification files to validate against.</summary>
[Required]
[Microsoft.Build.Framework.Required]
public string[] OpenApiSpecificationFiles { get; set; } = Array.Empty<string>();

protected override async Task<bool> ExecuteAsync(CancellationToken cancellationToken)
Expand All @@ -40,6 +55,9 @@ protected override async Task<bool> ExecuteAsync(CancellationToken cancellationT
var spectralManager = new SpectralManager(loggerWrapper, processWrapper, this.OpenApiToolsDirectoryPath, reportsPath, httpClientWrapper);
var oasdiffManager = new OasdiffManager(loggerWrapper, processWrapper, this.OpenApiToolsDirectoryPath, httpClientWrapper);

var codeFirstProcess = new CodeFirstProcess(spectralManager, swaggerManager);
var contractFirstProcess = new ContractFirstProcess(loggerWrapper, spectralManager, swaggerManager, oasdiffManager);

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);
Expand All @@ -58,23 +76,36 @@ protected override async Task<bool> ExecuteAsync(CancellationToken cancellationT
await this.GeneratePublicNugetSource();
Directory.CreateDirectory(reportsPath);

var installSwaggerCliTask = swaggerManager.InstallSwaggerCliAsync(cancellationToken);
var installSpectralTask = spectralManager.InstallSpectralAsync(cancellationToken);
var installOasdiffTask = oasdiffManager.InstallOasdiffAsync(cancellationToken);

await installSwaggerCliTask;
await installSpectralTask;
await installOasdiffTask;

var generateOpenApiDocsPath = (await swaggerManager.RunSwaggerAsync(this.OpenApiSwaggerDocumentNames, cancellationToken)).ToList();

if (!this.CheckIfBaseSpecExists())
switch (this.OpenApiDevelopmentMode)
{
return false;
case CodeFirst:
await codeFirstProcess.Execute(
this.OpenApiSpecificationFiles,
this.OpenApiSwaggerDocumentNames,
this.OpenApiSpectralRulesetUrl,
cancellationToken);
break;

case ContractFirst:
var isSuccess = await contractFirstProcess.Execute(
this.OpenApiSpecificationFiles,
this.OpenApiToolsDirectoryPath,
this.OpenApiSwaggerDocumentNames,
this.OpenApiSpectralRulesetUrl,
this.OpenApiCompareCodeAgainstSpecFile ? ContractFirstProcess.CompareCodeAgainstSpecFile.Enabled : ContractFirstProcess.CompareCodeAgainstSpecFile.Disabled,
cancellationToken);

if (!isSuccess)
{
return false;
}

break;

default:
this.Log.LogError("Invalid value for {0}. Allowed values are '{1}' or '{2}'", nameof(ValidateOpenApiTask.OpenApiDevelopmentMode), ContractFirst, CodeFirst);
return false;
}

await spectralManager.RunSpectralAsync(this.OpenApiSpecificationFiles, this.OpenApiSpectralRulesetUrl, cancellationToken);
await oasdiffManager.RunOasdiffAsync(this.OpenApiSpecificationFiles, generateOpenApiDocsPath, cancellationToken);
}
catch (OpenApiTaskFailedException e)
{
Expand All @@ -84,27 +115,7 @@ protected override async Task<bool> ExecuteAsync(CancellationToken cancellationT
return true;
}

private bool CheckIfBaseSpecExists()
{
foreach (var file in this.OpenApiSpecificationFiles)
{
if (File.Exists(file))
{
continue;
}

this.Log.LogWarning(
"The file '{0}' does not exist. If you are running this for the first time, we have generated specification here '{1}' which can be used as base specification. " +
"Please copy specification file(s) to your project directory and rebuild.",
file,
this.OpenApiToolsDirectoryPath);

return false;
}

return true;
}

// Why do we need to generate a public nuget source?
private async Task GeneratePublicNugetSource()
{
Directory.CreateDirectory(this.OpenApiToolsDirectoryPath);
Expand Down
Loading

0 comments on commit 94ab454

Please sign in to comment.