Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce OpenAPI validation #141

Merged
merged 6 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ OPTIONS:
--multiple-interfaces Generate a Refit interface for each endpoint. May be one of ByEndpoint, ByTag
--match-path Only include Paths that match the provided regular expression. May be set multiple times
--tag Only include Endpoints that contain this tag. May be set multiple times and result in OR'ed evaluation
--skip-validation Skip validation of the OpenAPI specification
```

To generate code from an OpenAPI specifications file, run the following:
Expand Down
61 changes: 56 additions & 5 deletions src/Refitter/GenerateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
using Spectre.Console.Cli;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.OpenApi.Models;
using Refitter.Validation;

namespace Refitter;

public sealed class GenerateCommand : AsyncCommand<Settings>
{
private static readonly string Crlf = Environment.NewLine;

public override ValidationResult Validate(CommandContext context, Settings settings)
{
if (string.IsNullOrWhiteSpace(settings.OpenApiPath))
Expand Down Expand Up @@ -43,13 +47,17 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se
IncludeTags = settings.Tags ?? Array.Empty<string>(),
};

var crlf = Environment.NewLine;
try
{
var stopwatch = Stopwatch.StartNew();
AnsiConsole.MarkupLine($"[green]Refitter v{GetType().Assembly.GetName().Version!}[/]");
AnsiConsole.MarkupLine($"[green]Support key: {SupportInformation.GetSupportKey()}[/]");


if (!settings.SkipValidation)
{
await ValidateOpenApiSpec(settings);
}

if (!string.IsNullOrWhiteSpace(settings.SettingsFilePath))
{
var json = await File.ReadAllTextAsync(settings.SettingsFilePath);
Expand All @@ -73,18 +81,61 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se
await File.WriteAllTextAsync(outputPath, code);
await Analytics.LogFeatureUsage(settings);

AnsiConsole.MarkupLine($"[green]Duration: {stopwatch.Elapsed}{crlf}[/]");
AnsiConsole.MarkupLine($"[green]Duration: {stopwatch.Elapsed}{Crlf}[/]");
return 0;
}
catch (Exception exception)
{
AnsiConsole.MarkupLine($"[red]Error:{crlf}{exception.Message}[/]");
AnsiConsole.MarkupLine($"[yellow]Stack Trace:{crlf}{exception.StackTrace}[/]");
if (exception is not OpenApiValidationException)
{
AnsiConsole.MarkupLine($"[red]Error:{Crlf}{exception.Message}[/]");
AnsiConsole.MarkupLine($"[red]Exception:{Crlf}{exception.GetType()}[/]");
AnsiConsole.MarkupLine($"[yellow]Stack Trace:{Crlf}{exception.StackTrace}[/]");
}

await Analytics.LogError(exception, settings);
return exception.HResult;
}
}

private static async Task ValidateOpenApiSpec(Settings settings)
{
var validationResult = await OpenApiValidator.Validate(settings.OpenApiPath!);
if (!validationResult.IsValid)
{
AnsiConsole.MarkupLine($"[red]{Crlf}OpenAPI validation failed:{Crlf}[/]");

foreach (var error in validationResult.Diagnostics.Errors)
{
TryWriteLine(error, "red", "Error");
}

foreach (var warning in validationResult.Diagnostics.Warnings)
{
TryWriteLine(warning, "yellow", "Warning");
}

validationResult.ThrowIfInvalid();
}

AnsiConsole.MarkupLine($"[green]{Crlf}OpenAPI statistics:{Crlf}{validationResult.Statistics}{Crlf}[/]");
}

private static void TryWriteLine(
OpenApiError error,
string color,
string label)
{
try
{
AnsiConsole.MarkupLine($"[{color}]{label}:{Crlf}{error}{Crlf}[/]");
}
catch
{
// ignored
}
}

private static bool IsUrl(string openApiPath)
{
return Uri.TryCreate(openApiPath, UriKind.Absolute, out var uriResult) &&
Expand Down
1 change: 1 addition & 0 deletions src/Refitter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ OPTIONS:
--multiple-interfaces Generate a Refit interface for each endpoint. May be one of ByEndpoint, ByTag
--match-path Only include Paths that match the provided regular expression. May be set multiple times
--tag Only include Endpoints that contain this tag. May be set multiple times and result in OR'ed evaluation
--skip-validation Skip validation of the OpenAPI specification
```

To generate code from an OpenAPI specifications file, run the following:
Expand Down
2 changes: 2 additions & 0 deletions src/Refitter/Refitter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

<ItemGroup>
<PackageReference Include="Exceptionless" Version="6.0.2" />
<PackageReference Include="Microsoft.OpenApi.OData" Version="1.4.0" />
<PackageReference Include="Microsoft.OpenApi.Readers" Version="1.6.7" />
<PackageReference Include="Spectre.Console.Cli" Version="0.47.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
Expand Down
4 changes: 4 additions & 0 deletions src/Refitter/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,8 @@ public sealed class Settings : CommandSettings
[CommandOption("--tag")]
[DefaultValue(new string[0])]
public string[]? Tags { get; set; }

[Description("Skip validation of the OpenAPI specification")]
[CommandOption("--skip-validation")]
public bool SkipValidation { get; set; }
}
82 changes: 82 additions & 0 deletions src/Refitter/Validation/OpenApiStats.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Services;

namespace Refitter.Validation;

public class OpenApiStats : OpenApiVisitorBase
{
public int ParameterCount { get; set; } = 0;
public int SchemaCount { get; set; } = 0;
public int HeaderCount { get; set; } = 0;
public int PathItemCount { get; set; } = 0;
public int RequestBodyCount { get; set; } = 0;
public int ResponseCount { get; set; } = 0;
public int OperationCount { get; set; } = 0;
public int LinkCount { get; set; } = 0;
public int CallbackCount { get; set; } = 0;

public override void Visit(OpenApiParameter parameter)
{
ParameterCount++;
}

public override void Visit(OpenApiSchema schema)
{
SchemaCount++;
}


public override void Visit(IDictionary<string, OpenApiHeader> headers)
{
HeaderCount++;
}


public override void Visit(OpenApiPathItem pathItem)
{
PathItemCount++;
}


public override void Visit(OpenApiRequestBody requestBody)
{
RequestBodyCount++;
}


public override void Visit(OpenApiResponses response)
{
ResponseCount++;
}


public override void Visit(OpenApiOperation operation)
{
OperationCount++;
}


public override void Visit(OpenApiLink link)
{
LinkCount++;
}

public override void Visit(OpenApiCallback callback)
{
CallbackCount++;
}

public override string ToString()
{
return $"""
- Path Items: {PathItemCount}
- Operations: {OperationCount}
- Parameters: {ParameterCount}
- Request Bodies: {RequestBodyCount}
- Responses: {ResponseCount}
- Links: {LinkCount}
- Callbacks: {CallbackCount}
- Schemas: {SchemaCount}
""";
}
}
22 changes: 22 additions & 0 deletions src/Refitter/Validation/OpenApiValidationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Runtime.Serialization;

namespace Refitter.Validation;

[Serializable]
public class OpenApiValidationException : Exception
{
public OpenApiValidationResult ValidationResult { get; } = null!;

public OpenApiValidationException(
OpenApiValidationResult validationResult)
: base("OpenAPI validation failed")
{
ValidationResult = validationResult;
}

protected OpenApiValidationException(
SerializationInfo info,
StreamingContext context) : base(info, context)
{
}
}
16 changes: 16 additions & 0 deletions src/Refitter/Validation/OpenApiValidationResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Microsoft.OpenApi.Readers;

namespace Refitter.Validation;

public record OpenApiValidationResult(
OpenApiDiagnostic Diagnostics,
OpenApiStats Statistics)
{
public bool IsValid => Diagnostics.Errors.Count == 0;

public void ThrowIfInvalid()
{
if (!IsValid)
throw new OpenApiValidationException(this);
}
}
78 changes: 78 additions & 0 deletions src/Refitter/Validation/OpenApiValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Net;
using System.Security;

using Microsoft.OpenApi.Readers;
using Microsoft.OpenApi.Services;

namespace Refitter.Validation;

public static class OpenApiValidator
{
public static async Task<OpenApiValidationResult> Validate(string openApiPath)
{
var result = await ParseOpenApi(openApiPath);

var statsVisitor = new OpenApiStats();
var walker = new OpenApiWalker(statsVisitor);
walker.Walk(result.OpenApiDocument);

return new(
result.OpenApiDiagnostic,
statsVisitor);
}

private static async Task<Stream> GetStream(
string input,
CancellationToken cancellationToken)
{
if (input.StartsWith("http"))
{
try
{
var httpClientHandler = new HttpClientHandler()
{
SslProtocols = System.Security.Authentication.SslProtocols.Tls12,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
using var httpClient = new HttpClient(httpClientHandler);
httpClient.DefaultRequestVersion = HttpVersion.Version20;
return await httpClient.GetStreamAsync(input, cancellationToken);
}
catch (HttpRequestException ex)
{
throw new InvalidOperationException($"Could not download the file at {input}", ex);
}
}

try
{
var fileInput = new FileInfo(input);
return fileInput.OpenRead();
}
catch (Exception ex) when (ex is FileNotFoundException ||
ex is PathTooLongException ||
ex is DirectoryNotFoundException ||
ex is IOException ||
ex is UnauthorizedAccessException ||
ex is SecurityException ||
ex is NotSupportedException)
{
throw new InvalidOperationException($"Could not open the file at {input}", ex);
}
}

private static async Task<ReadResult> ParseOpenApi(string openApiFile)
{
var directoryName = new FileInfo(openApiFile).DirectoryName;
var openApiReaderSettings = new OpenApiReaderSettings
{
BaseUrl = openApiFile.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? new Uri(openApiFile)
: new Uri($"file://{directoryName}{Path.DirectorySeparatorChar}")
};

await using var stream = await GetStream(openApiFile, CancellationToken.None);
var reader = new OpenApiStreamReader(openApiReaderSettings);
return await reader.ReadAsync(stream, CancellationToken.None);
}
}