Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,11 @@ OPTIONS:
--no-inline-json-converters Don't inline JsonConverter attributes for enum types. When disabled, no [JsonConverter(typeof(JsonStringEnumConverter))] attributes are emitted. By default (enabled), the attribute is placed on the enum type declaration (not on properties), allowing custom converters to be registered via JsonSerializerOptions.Converters
--integer-type int The .NET type to use for OpenAPI integer types without a format specifier. Common values: 'int' (default), 'long'
--custom-template-directory Custom directory with NSwag fluid templates for code generation. Default is null which uses the default NSwag templates. See <https://github.com/RicoSuter/NSwag/wiki/Templates>
--generate-authentication-header None Controls generation of Authorization header support.
Options: None (no authentication code is generated),
Parameter (adds method parameters for authentication),
Method (generates a Refit [Headers] attribute for bearer token authentication)
--generate-authentication-header [STYLE] Controls generation of Authorization header support.
Options: None (no authentication code is generated),
Parameter (adds method parameters for authentication),
Method (generates a Refit [Headers] attribute for bearer token authentication).
Legacy boolean forms (true/false) and omitting the value are also accepted for compatibility.
--security-scheme Generate Authorization header for a specific security scheme. When omitted, authentication headers will be generated for all security schemes
```

Expand Down
55 changes: 46 additions & 9 deletions src/Refitter.Core/OpenApiDocumentFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@

private static OpenApiDocument Merge(OpenApiDocument[] documents)
{
var baseDocument = documents[0];
var baseDocument = OpenApiDocument.FromJsonAsync(documents[0].ToJson(documents[0].SchemaType)).GetAwaiter().GetResult();

Check warning on line 57 in src/Refitter.Core/OpenApiDocumentFactory.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

'OpenApiDocument.ToJson(SchemaType)' is obsolete: 'Do not use this method but only ToJson(). Use the correct generator settings to generate a document in the correct format.'

Check warning on line 57 in src/Refitter.Core/OpenApiDocumentFactory.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

'OpenApiDocument.ToJson(SchemaType)' is obsolete: 'Do not use this method but only ToJson(). Use the correct generator settings to generate a document in the correct format.'

Check warning on line 57 in src/Refitter.Core/OpenApiDocumentFactory.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

'OpenApiDocument.ToJson(SchemaType)' is obsolete: 'Do not use this method but only ToJson(). Use the correct generator settings to generate a document in the correct format.'

Check warning on line 57 in src/Refitter.Core/OpenApiDocumentFactory.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

'OpenApiDocument.ToJson(SchemaType)' is obsolete: 'Do not use this method but only ToJson(). Use the correct generator settings to generate a document in the correct format.'

Check warning on line 57 in src/Refitter.Core/OpenApiDocumentFactory.cs

View workflow job for this annotation

GitHub Actions / script

'OpenApiDocument.ToJson(SchemaType)' is obsolete: 'Do not use this method but only ToJson(). Use the correct generator settings to generate a document in the correct format.'
var tags = baseDocument.Tags;
HashSet<string>? tagNames = null;

Expand All @@ -68,27 +68,30 @@
var document = documents[i];
foreach (var path in document.Paths)
{
if (!baseDocument.Paths.ContainsKey(path.Key))
baseDocument.Paths[path.Key] = path.Value;
MergeIfMissingOrThrowOnConflict(baseDocument.Paths, path.Key, path.Value, "path");
}

if (document.Components?.Schemas != null)
{
// Ensure base document has schemas dictionary initialized (#1016)
// Components property is read-only but auto-initialized by NSwag
foreach (var schema in document.Components.Schemas)
{
if (!baseDocument.Components.Schemas.ContainsKey(schema.Key))
baseDocument.Components.Schemas[schema.Key] = schema.Value;
MergeIfMissingOrThrowOnConflict(baseDocument.Components.Schemas, schema.Key, schema.Value, "schema");
}
}

if (document.Definitions != null)
{
foreach (var definition in document.Definitions)
{
if (!baseDocument.Definitions.ContainsKey(definition.Key))
baseDocument.Definitions[definition.Key] = definition.Value;
MergeIfMissingOrThrowOnConflict(baseDocument.Definitions, definition.Key, definition.Value, "definition");
}
}

if (document.SecurityDefinitions != null)
{
foreach (var securityDefinition in document.SecurityDefinitions)
{
MergeIfMissingOrThrowOnConflict(baseDocument.SecurityDefinitions, securityDefinition.Key, securityDefinition.Value, "security scheme");
}
}

Expand All @@ -107,6 +110,40 @@
return baseDocument;
}

private static void MergeIfMissingOrThrowOnConflict<TValue>(
IDictionary<string, TValue> target,
string key,
TValue value,
string itemType)
{
if (!target.TryGetValue(key, out var existingValue))
{
target[key] = value;
return;
}

if (!AreEquivalent(existingValue, value))
throw CreateMergeConflictException(itemType, key);
}
Comment thread
christianhelle marked this conversation as resolved.

private static bool AreEquivalent<TValue>(TValue existingValue, TValue incomingValue)
{
if (ReferenceEquals(existingValue, incomingValue) || EqualityComparer<TValue>.Default.Equals(existingValue, incomingValue))
return true;

try
{
return Serializer.Serialize(existingValue!) == Serializer.Serialize(incomingValue!);
}
catch
{
return false;
}
}

private static InvalidOperationException CreateMergeConflictException(string itemType, string key) =>
new($"Cannot merge OpenAPI documents because a duplicate {itemType} '{key}' was found. Refitter fails fast on merge collisions to avoid silent data loss.");

/// <summary>
/// Creates a new instance of the <see cref="NSwag.OpenApiDocument"/> class asynchronously.
/// </summary>
Expand Down
15 changes: 6 additions & 9 deletions src/Refitter.Core/ParameterExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -421,14 +421,13 @@
{
List<string>? parameters = null;
var dynamicQuerystringParametersCodeBuilder = new StringBuilder();
var queryParameters = operationModel.Parameters
.Where(p => p.Kind == OpenApiParameterKind.Query)
.ToList();

if (settings.UseDynamicQuerystringParameters)
{
var operationParameters = operationModel.Parameters
.Where(p => p.Kind == OpenApiParameterKind.Query)
.ToList();

if (operationParameters.Count >= 2)
if (queryParameters.Count >= 2)

Check warning on line 430 in src/Refitter.Core/ParameterExtractor.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=christianhelle_refitter&issues=AZ3E7i35MMAbgOavX00I&open=AZ3E7i35MMAbgOavX00I&pullRequest=1070
{
var modifier = settings.TypeAccessibility.ToString().ToLowerInvariant();
var isRecord = settings.ImmutableRecords ||
Expand All @@ -444,7 +443,7 @@
var initializedParametersCodeBuilder = new StringBuilder();
var propertiesCodeBuilder = new StringBuilder();
var allNullable = true;
foreach (var operationParameter in operationParameters)
foreach (var operationParameter in queryParameters)
{
var propertyType = GetQueryParameterType(operationParameter, settings);
allNullable = allNullable && propertyType.EndsWith("?");
Expand Down Expand Up @@ -483,7 +482,6 @@
propertiesCodeBuilder.Append($" = {formattedDefaultValue};");
}
propertiesCodeBuilder.AppendLine();
operationModel.Parameters.Remove(operationParameter);
}

dynamicQuerystringParametersCodeBuilder.AppendLine(
Expand Down Expand Up @@ -519,8 +517,7 @@

dynamicQuerystringParameters = dynamicQuerystringParametersCodeBuilder.ToString();

parameters ??= operationModel.Parameters
.Where(p => p.Kind == OpenApiParameterKind.Query)
parameters ??= queryParameters
.Select(p =>
{
var variableName = GetVariableName(p);
Expand Down
9 changes: 8 additions & 1 deletion src/Refitter.Core/RefitGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
{
if (settings.OpenApiPaths is { Length: > 0 })
return await OpenApiDocumentFactory.CreateAsync(settings.OpenApiPaths).ConfigureAwait(false);
return await OpenApiDocumentFactory.CreateAsync(settings.OpenApiPath).ConfigureAwait(false);

Check warning on line 50 in src/Refitter.Core/RefitGenerator.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'openApiPath' in 'Task<OpenApiDocument> OpenApiDocumentFactory.CreateAsync(string openApiPath)'.

Check warning on line 50 in src/Refitter.Core/RefitGenerator.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'openApiPath' in 'Task<OpenApiDocument> OpenApiDocumentFactory.CreateAsync(string openApiPath)'.

Check warning on line 50 in src/Refitter.Core/RefitGenerator.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'openApiPath' in 'Task<OpenApiDocument> OpenApiDocumentFactory.CreateAsync(string openApiPath)'.

Check warning on line 50 in src/Refitter.Core/RefitGenerator.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'openApiPath' in 'Task<OpenApiDocument> OpenApiDocumentFactory.CreateAsync(string openApiPath)'.

Check warning on line 50 in src/Refitter.Core/RefitGenerator.cs

View workflow job for this annotation

GitHub Actions / script

Possible null reference argument for parameter 'openApiPath' in 'Task<OpenApiDocument> OpenApiDocumentFactory.CreateAsync(string openApiPath)'.
}

private static void ProcessContractFilter(OpenApiDocument openApiDocument, bool removeUnusedSchema, string[] includeSchemaMatches,
Expand Down Expand Up @@ -289,10 +289,12 @@
// This allows users to override the converter via JsonSerializerOptions.Converters (e.g. to use
// JsonStringEnumMemberConverter for enums with [EnumMember] values containing special characters).
contracts = JsonStringEnumConverterAttributeRegex.Replace(contracts, string.Empty);
var newLine = GetPreferredNewLine(contracts);
return EnumDeclarationRegex
.Replace(
contracts,
"$1[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]\n$1$2")
match =>
$"{match.Groups[1].Value}[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]{newLine}{match.Groups[1].Value}{match.Groups[2].Value}")
.TrimEnd();
}

Expand All @@ -302,6 +304,11 @@
.TrimEnd();
}

private static string GetPreferredNewLine(string content) =>
content.Contains("\r\n", StringComparison.Ordinal)
? "\r\n"
: "\n";

private string NormalizeSwagger2OptionalReferencePropertyNullability(string contracts)
{
if (document.SchemaType != NJsonSchema.SchemaType.Swagger2 ||
Expand Down
126 changes: 109 additions & 17 deletions src/Refitter.MSBuild/RefitterGenerateTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@
private static readonly System.Threading.AsyncLocal<Func<ProcessStartInfo, Action<string?>, Action<string?>, ProcessExecutionResult>?> ProcessRunnerOverride = new();
private static readonly System.Threading.AsyncLocal<int?> ProcessTimeoutMillisecondsOverride = new();
private static readonly System.Threading.AsyncLocal<Action<Process>?> ProcessTerminatorOverride = new();
private static readonly System.Threading.AsyncLocal<Func<string, bool>?> FileExistsOverride = new();

private static readonly (string TargetFramework, string RuntimePrefix)[] PreferredRuntimeOrder =
[
("net10.0", "Microsoft.NETCore.App 10."),
("net9.0", "Microsoft.NETCore.App 9."),
("net8.0", "Microsoft.NETCore.App 8.")
];

private static readonly string[] CompatibilityFallbackOrder =
[
"net8.0",
"net9.0",
"net10.0"
];

internal sealed class ProcessExecutionResult
{
Expand Down Expand Up @@ -53,6 +68,12 @@
set => ProcessTerminatorOverride.Value = value;
}

internal static Func<string, bool> FileExists
{
get => FileExistsOverride.Value ?? File.Exists;
set => FileExistsOverride.Value = value;
}

public string ProjectFileDirectory { get; set; }

public bool DisableLogging { get; set; }
Expand All @@ -70,6 +91,7 @@
ProcessRunnerOverride.Value = null;
ProcessTimeoutMillisecondsOverride.Value = null;
ProcessTerminatorOverride.Value = null;
FileExistsOverride.Value = null;
}

public override bool Execute()
Expand Down Expand Up @@ -131,26 +153,24 @@
failed = false;
var assembly = Assembly.GetExecutingAssembly();
var packageFolder = Path.GetDirectoryName(assembly.Location);
var separator = Path.DirectorySeparatorChar;
var refitterDll = $"{packageFolder}{separator}..{separator}net8.0{separator}refitter.dll";
var outputLines = new List<string>();

List<string> installedRuntimes = InstalledDotnetRuntimesProvider();
if (installedRuntimes.Any(r => r.StartsWith("Microsoft.NETCore.App 10.")))
List<string>? installedRuntimes = null;
try
{
// Use .NET 10 version if available
refitterDll = $"{packageFolder}{separator}..{separator}net10.0{separator}refitter.dll";
TryLogCommandLine("Detected .NET 10 runtime. Using .NET 10 version of Refitter.");
installedRuntimes = InstalledDotnetRuntimesProvider();
}
else if (installedRuntimes.Any(r => r.StartsWith("Microsoft.NETCore.App 9.")))
catch (Exception exception)
{
// Use .NET 9 version if available
refitterDll = $"{packageFolder}{separator}..{separator}net9.0{separator}refitter.dll";
TryLogCommandLine("Detected .NET 9 runtime. Using .NET 9 version of Refitter.");
TryLogCommandLine($"Failed to inspect installed .NET runtimes: {exception.Message}. Falling back to bundled Refitter runtime selection.");
}
else

var refitterDll = ResolveRefitterDll(packageFolder, installedRuntimes, TryLogCommandLine);
if (string.IsNullOrWhiteSpace(refitterDll) || !FileExists(refitterDll))

Check warning on line 169 in src/Refitter.MSBuild/RefitterGenerateTask.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'arg' in 'bool Func<string, bool>.Invoke(string arg)'.

Check warning on line 169 in src/Refitter.MSBuild/RefitterGenerateTask.cs

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'arg' in 'bool Func<string, bool>.Invoke(string arg)'.
{
TryLogCommandLine("Using .NET 8 version of Refitter.");
failed = true;
TryLogError("Unable to locate a bundled Refitter CLI runtime for the MSBuild task.");
return new List<string>();
}

var args = $"\"{refitterDll}\" --settings-file \"{file}\" --simple-output";
Expand Down Expand Up @@ -185,13 +205,14 @@
if (processResult.TimedOut)
{
failed = true;
var timeoutDescription = FormatTimeout(ProcessTimeoutMilliseconds);
if (processResult.TerminationException is null)
{
TryLogError("Refitter process timed out after 300 seconds and was terminated");
TryLogError($"Refitter process timed out after {timeoutDescription} and was terminated");
}
else
{
TryLogError($"Failed to terminate timed-out process: {processResult.TerminationException.Message}");
TryLogError($"Refitter process timed out after {timeoutDescription}. Failed to terminate timed-out process: {processResult.TerminationException.Message}");
}

return new List<string>();
Expand Down Expand Up @@ -261,15 +282,86 @@
process.Start();
using (var reader = process.StandardOutput)
{
var output = reader.ReadToEnd();
installedRuntimes.AddRange(output.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries));
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
if (!string.IsNullOrWhiteSpace(line))
{
installedRuntimes.Add(line);
}
}
}
process.WaitForExit();
}

return installedRuntimes;
}

internal static string? ResolveRefitterDll(string? packageFolder, IReadOnlyList<string>? installedRuntimes, Action<string> logCommandLine)
{
if (string.IsNullOrWhiteSpace(packageFolder))
{
return null;
}

var bundledRuntimes = PreferredRuntimeOrder
.Select(candidate => new
{
candidate.TargetFramework,
candidate.RuntimePrefix,
Path = Path.GetFullPath(Path.Combine(packageFolder, "..", candidate.TargetFramework, "refitter.dll")),
})
.ToArray();

if (installedRuntimes is not null)
{
foreach (var runtime in bundledRuntimes)

Check warning on line 318 in src/Refitter.MSBuild/RefitterGenerateTask.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=AZ3E7i7iMMAbgOavX00M&open=AZ3E7i7iMMAbgOavX00M&pullRequest=1070
{
if (FileExists(runtime.Path) &&
installedRuntimes.Any(installed =>
!string.IsNullOrWhiteSpace(installed) &&
installed.StartsWith(runtime.RuntimePrefix, StringComparison.Ordinal)))
{
logCommandLine($"Detected {GetDisplayFramework(runtime.TargetFramework)} runtime. Using {GetDisplayFramework(runtime.TargetFramework)} version of Refitter.");
return runtime.Path;
}
}
}

foreach (var targetFramework in CompatibilityFallbackOrder)
{
var fallbackPath = bundledRuntimes
.First(runtime => runtime.TargetFramework == targetFramework)
.Path;

if (FileExists(fallbackPath))
{
logCommandLine($"Falling back to bundled {GetDisplayFramework(targetFramework)} version of Refitter.");
return fallbackPath;
}
}

var coLocatedCli = Path.GetFullPath(Path.Combine(packageFolder, "refitter.dll"));
if (FileExists(coLocatedCli))
{
logCommandLine("Falling back to co-located Refitter CLI.");
return coLocatedCli;
}

return bundledRuntimes
.Select(runtime => runtime.Path)
.FirstOrDefault();
}

private static string FormatTimeout(int timeoutMilliseconds) =>
timeoutMilliseconds >= 1000 && timeoutMilliseconds % 1000 == 0
? $"{timeoutMilliseconds / 1000} seconds"
: timeoutMilliseconds >= 1000
? $"{timeoutMilliseconds / 1000d:0.###} seconds"
: $"{timeoutMilliseconds} ms";

Check warning on line 361 in src/Refitter.MSBuild/RefitterGenerateTask.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=christianhelle_refitter&issues=AZ3E7i7iMMAbgOavX00L&open=AZ3E7i7iMMAbgOavX00L&pullRequest=1070

private static string GetDisplayFramework(string targetFramework) => targetFramework.Replace("net", ".NET ");

private void TryLogErrorFromException(Exception e)
{
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.4.0" />
<PackageReference Include="FluentAssertions" Version="7.2.2" />
<PackageReference Include="System.Text.Json" Version="10.0.5" />
<PackageReference Include="H.Generators.Extensions" Version="1.24.2" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
<PackageReference Include="TUnit" Version="1.35.2" />
<PackageReference Include="coverlet.collector" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Loading
Loading