Skip to content

Commit

Permalink
Merge pull request #141 from christianhelle/openapi-validation
Browse files Browse the repository at this point in the history
  • Loading branch information
christianhelle authored Sep 5, 2023
2 parents 864ac04 + 0802d7b commit b06c3b6
Show file tree
Hide file tree
Showing 9 changed files with 262 additions and 5 deletions.
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);
}
}

0 comments on commit b06c3b6

Please sign in to comment.