Skip to content
20 changes: 20 additions & 0 deletions src/Refitter.Core/Abstractions/IValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Threading;
using System.Threading.Tasks;
using Refitter.Core.Validation;

namespace Refitter.Core;

/// <summary>
/// Validates an OpenAPI specification file and returns validation diagnostics and statistics.
/// Each distribution form (CLI, MSBuild, Source Generator) can provide its own adapter.
/// </summary>
public interface IValidator
{
/// <summary>
/// Validates the specified OpenAPI specification file.
/// </summary>
/// <param name="openApiPath">The path to the OpenAPI specification file.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An <see cref="OpenApiValidationResult"/> containing diagnostics and element counts.</returns>
Task<OpenApiValidationResult> ValidateAsync(string openApiPath, CancellationToken cancellationToken = default);
}
202 changes: 202 additions & 0 deletions src/Refitter.Core/RefitterRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Refitter.Core.Validation;

namespace Refitter.Core;

/// <summary>
/// Encapsulates the shared generation workflow across all distribution forms (CLI, MSBuild, Source Generator).
/// Handles generator creation, code generation, output planning, validation, warning detection, and file writing.
/// </summary>
public class RefitterRunner
{
/// <summary>
/// Runs the complete generation workflow.
/// </summary>
/// <param name="settings">The generator settings.</param>
/// <param name="writer">Optional file writer for writing output files. When null, files are not written.</param>
/// <param name="validator">Optional validator for OpenAPI spec validation. When null, validation is skipped.</param>
/// <param name="settingsFilePath">Optional path to the .refitter settings file, used for resolving relative output paths.</param>
/// <param name="outputPath">Optional CLI output path override.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="RunResult"/> containing generated files, warnings, diagnostics, and the exit code.</returns>
public async Task<RunResult> RunAsync(

Check warning on line 26 in src/Refitter.Core/RefitterRunner.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make 'RunAsync' a static method.

See more on https://sonarcloud.io/project/issues?id=christianhelle_refitter&issues=AZ7m3G_MLaKZabW99IcN&open=AZ7m3G_MLaKZabW99IcN&pullRequest=1166
RefitGeneratorSettings settings,
IFileWriter? writer = null,
IValidator? validator = null,
string? settingsFilePath = null,
string? outputPath = null,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var warnings = new List<Warning>();
var diagnostics = new List<RunnerDiagnostic>();

try
{
var generator = await RefitGenerator.CreateAsync(
settings,
cancellationToken);

var output = GenerateCode(generator, settings);

var planner = new OutputPlannerAdapter();
var plannedFiles = planner.Plan(
output,
settings,
settingsFilePath,
outputPath);

if (validator != null)
{
await ValidateOpenApiSpecsAsync(
settings,
validator,
diagnostics,
cancellationToken);
}

if (writer != null)
{
await WriteFilesAsync(plannedFiles, writer, cancellationToken);
}

DetectWarnings(settings, warnings);

DetectPathFilteringDiagnostics(settings, generator, diagnostics);

stopwatch.Stop();

return new RunResult(
plannedFiles,
warnings.AsReadOnly(),
diagnostics.AsReadOnly(),
stopwatch.Elapsed,
ExitCode: 0);
}
catch (Exception ex)
{
stopwatch.Stop();

var errorDiagnostics = new List<RunnerDiagnostic>(diagnostics)
{
new RunnerDiagnostic(ex.Message, IsError: true)
};

return new RunResult(
Array.Empty<PlannedFile>(),
warnings.AsReadOnly(),
errorDiagnostics.AsReadOnly(),
stopwatch.Elapsed,
ex.HResult,
ex);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private static GeneratorOutput GenerateCode(RefitGenerator generator, RefitGeneratorSettings settings)
{
if (settings.GenerateMultipleFiles)
{
return generator.GenerateMultipleFiles();
}
else
{
var code = generator.Generate()
.Replace("\r\n", "\n")
.Replace("\n", "\r\n");
return new GeneratorOutput(
new List<GeneratedCode> { new(string.Empty, code) });
}
}

private static async Task ValidateOpenApiSpecsAsync(
RefitGeneratorSettings settings,
IValidator validator,
List<RunnerDiagnostic> diagnostics,
CancellationToken cancellationToken)
{
var openApiPaths = GetOpenApiPaths(settings);
foreach (var specPath in openApiPaths)

Check warning on line 122 in src/Refitter.Core/RefitterRunner.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=christianhelle_refitter&issues=AZ7m3G_MLaKZabW99IcM&open=AZ7m3G_MLaKZabW99IcM&pullRequest=1166
{
if (!string.IsNullOrWhiteSpace(specPath))
{
var validationResult = await validator.ValidateAsync(
specPath,
cancellationToken);

foreach (var error in validationResult.Diagnostics.Errors)
diagnostics.Add(new RunnerDiagnostic(error.Message, IsError: true));

foreach (var warning in validationResult.Diagnostics.Warnings)
diagnostics.Add(new RunnerDiagnostic(warning.Message, IsError: false));

validationResult.ThrowIfInvalid();
}
}
}

private static async Task WriteFilesAsync(
IReadOnlyList<PlannedFile> plannedFiles,
IFileWriter writer,
CancellationToken cancellationToken)
{
foreach (var file in plannedFiles)
{
await writer.WriteAsync(file, cancellationToken);
}
}

private static void DetectPathFilteringDiagnostics(
RefitGeneratorSettings settings,
RefitGenerator generator,
List<RunnerDiagnostic> diagnostics)
{
if (settings.IncludePathMatches.Length > 0 &&
generator.OpenApiDocument.Paths.Count == 0)
{
diagnostics.Add(new RunnerDiagnostic(
$"All paths were filtered out by include path patterns: [{string.Join(", ", settings.IncludePathMatches)}]",
IsError: false));
}
}

private static string[] GetOpenApiPaths(RefitGeneratorSettings settings)
{
if (settings.OpenApiPaths is { Length: > 0 })
return settings.OpenApiPaths;

if (settings.OpenApiPath is not null)
return new[] { settings.OpenApiPath };

return Array.Empty<string>();
}

/// <summary>
/// Detects configuration warnings in the settings and adds them to the provided list.
/// </summary>
internal static void DetectWarnings(
RefitGeneratorSettings settings,
List<Warning> warnings)
{
if (settings is { UseIsoDateFormat: true, CodeGeneratorSettings.DateFormat: not null })
{
warnings.Add(
new Warning(
"Date Format Override",
"'codeGeneratorSettings.dateFormat' will be ignored due to 'useIsoDateFormat' set to true"));
}

#pragma warning disable CS0618
if (settings.DependencyInjectionSettings?.UsePolly is true)
#pragma warning restore CS0618
{
warnings.Add(
new Warning(
"Deprecated Setting",
"The 'usePolly' property is deprecated. Use 'transientErrorHandler: Polly' instead"));
}
}
}
21 changes: 21 additions & 0 deletions src/Refitter.Core/RunResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;

namespace Refitter.Core;

/// <summary>
/// Represents the result of a <see cref="RefitterRunner.RunAsync"/> invocation.
/// </summary>
/// <param name="GeneratedFiles">The list of planned output files with their paths and content.</param>
/// <param name="Warnings">Configuration warnings detected during the run.</param>
/// <param name="Diagnostics">Validation and error diagnostics collected during the run.</param>
/// <param name="Elapsed">The elapsed time of the run.</param>
/// <param name="ExitCode">The exit code (0 for success, non-zero for failure).</param>
/// <param name="Exception">The original exception, if any, for form-specific error handling.</param>
public record RunResult(
IReadOnlyList<PlannedFile> GeneratedFiles,
IReadOnlyList<Warning> Warnings,
IReadOnlyList<RunnerDiagnostic> Diagnostics,
TimeSpan Elapsed,
int ExitCode,
Exception? Exception = null);
8 changes: 8 additions & 0 deletions src/Refitter.Core/RunnerDiagnostic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Refitter.Core;

/// <summary>
/// Represents a diagnostic message from a generation run, such as a validation error or warning.
/// </summary>
/// <param name="Message">The diagnostic message text.</param>
/// <param name="IsError">Whether this diagnostic represents an error (true) or a warning (false).</param>
public record RunnerDiagnostic(string Message, bool IsError);
16 changes: 16 additions & 0 deletions src/Refitter.Core/Validation/OpenApiValidatorAdapter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;

namespace Refitter.Core.Validation;

/// <summary>
/// Adapts the static <see cref="OpenApiValidator"/> to the <see cref="IValidator"/> interface.
/// </summary>
public sealed class OpenApiValidatorAdapter : IValidator
{
/// <inheritdoc />
public async Task<OpenApiValidationResult> ValidateAsync(string openApiPath, CancellationToken cancellationToken = default)
{
return await OpenApiValidator.Validate(openApiPath, cancellationToken);
}
}
8 changes: 8 additions & 0 deletions src/Refitter.Core/Warning.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Refitter.Core;

/// <summary>
/// Represents a user-facing configuration warning message.
/// </summary>
/// <param name="Title">The short title of the warning.</param>
/// <param name="Description">The extended description of the warning.</param>
public record Warning(string Title, string Description);
71 changes: 17 additions & 54 deletions src/Refitter.MSBuild/RefitterGenerateTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,65 +71,28 @@ private List<string> ProcessRefitterFile(string filePath)
var settings = RefitterSettingsLoader.Load(json, baseDirectory);
RefitterSettingsLoader.ApplyDefaults(filePath, settings);

var generator = RefitGenerator.CreateAsync(settings)
.GetAwaiter().GetResult();

var planner = new OutputPlannerAdapter();
var writer = new MsBuildFileWriter();
IValidator? validator = SkipValidation
? null
: new OpenApiValidatorAdapter();

var generatedFiles = new List<string>();

if (settings.GenerateMultipleFiles)
{
var output = generator.GenerateMultipleFiles();
var plannedFiles = planner.Plan(
output,
settings,
filePath,
cliOutputPath: null);

foreach (var plannedFile in plannedFiles)
{
writer.WriteAsync(plannedFile).GetAwaiter().GetResult();
generatedFiles.Add(Path.GetFullPath(plannedFile.Path));
}
}
else
{
var code = generator.Generate().Replace("\r\n", "\n");
var output = new GeneratorOutput(
new List<GeneratedCode> { new(string.Empty, code) });

var plannedFiles = planner.Plan(
output,
var runner = new RefitterRunner();
var result = runner.RunAsync(
settings,
filePath,
cliOutputPath: null);

writer.WriteAsync(plannedFiles[0]).GetAwaiter().GetResult();
generatedFiles.Add(Path.GetFullPath(plannedFiles[0].Path));
}
writer,
validator,
settingsFilePath: filePath,
outputPath: null,
CancellationToken.None)
.GetAwaiter().GetResult();

if (!SkipValidation)
{
var openApiPaths = settings.OpenApiPaths is { Length: > 0 }
? settings.OpenApiPaths
: settings.OpenApiPath is not null
? [settings.OpenApiPath]
: [];
if (result.ExitCode != 0)
throw result.Exception ?? new InvalidOperationException(
result.Diagnostics.FirstOrDefault()?.Message ?? "Unknown error");

foreach (var specPath in openApiPaths)
{
if (!string.IsNullOrWhiteSpace(specPath))
{
var validationResult = OpenApiValidator.Validate(specPath)
.GetAwaiter().GetResult();
validationResult.ThrowIfInvalid();
}
}
}

return generatedFiles;
return result.GeneratedFiles
.Select(f => Path.GetFullPath(f.Path))
.ToList();
}

internal static string[] FilterFiles(string[] files, string includePatterns, string projectFileDirectory)
Expand Down
Loading
Loading