Skip to content
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
36 changes: 24 additions & 12 deletions src/Refitter.Core/OpenApiDocumentFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ namespace Refitter.Core;
/// </summary>
public static class OpenApiDocumentFactory
{
private static readonly HttpClient HttpClient = new(
new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
});

/// <summary>
/// Creates a merged <see cref="NSwag.OpenApiDocument"/> from multiple paths or URLs.
/// The first document serves as the base; paths and schemas from subsequent documents are merged in.
Expand All @@ -37,8 +43,17 @@ public static async Task<OpenApiDocument> CreateAsync(IEnumerable<string> openAp
private static OpenApiDocument Merge(OpenApiDocument[] documents)
{
var baseDocument = documents[0];
foreach (var document in documents.Skip(1))
var tags = baseDocument.Tags;
HashSet<string>? tagNames = null;

if (tags != null)
{
tagNames = new HashSet<string>(tags.Select(t => t.Name), StringComparer.Ordinal);
}

for (var i = 1; i < documents.Length; i++)
{
var document = documents[i];
foreach (var path in document.Paths)
{
if (!baseDocument.Paths.ContainsKey(path.Key))
Expand Down Expand Up @@ -67,9 +82,10 @@ private static OpenApiDocument Merge(OpenApiDocument[] documents)
if (document.Tags != null)
{
baseDocument.Tags ??= [];
tagNames ??= new HashSet<string>(baseDocument.Tags.Select(t => t.Name), StringComparer.Ordinal);
foreach (var tag in document.Tags)
{
if (baseDocument.Tags.All(t => t.Name != tag.Name))
if (tagNames.Add(tag.Name))
baseDocument.Tags.Add(tag);
}
}
Expand Down Expand Up @@ -152,22 +168,17 @@ private static void PopulateMissingRequiredFields(
/// <returns>True if the path is an HTTP URL, otherwise false.</returns>
private static bool IsHttp(string path)
{
return path.StartsWith("http://") || path.StartsWith("https://");
return path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Gets the content of the URI as a string and decompresses it if necessary.
/// </summary>
/// <param name="openApiPath">The path to the OpenAPI document.</param>
/// <returns>The content of the HTTP request.</returns>
private static async Task<string> GetHttpContent(string openApiPath)
{
var httpMessageHandler = new HttpClientHandler();
httpMessageHandler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
using var http = new HttpClient(httpMessageHandler);
var content = await http.GetStringAsync(openApiPath);
return content;
}
private static Task<string> GetHttpContent(string openApiPath)
=> HttpClient.GetStringAsync(openApiPath);


/// <summary>
Expand All @@ -177,6 +188,7 @@ private static async Task<string> GetHttpContent(string openApiPath)
/// <returns>True if the path is a YAML file, otherwise false.</returns>
private static bool IsYaml(string path)
{
return path.EndsWith("yaml") || path.EndsWith("yml");
return path.EndsWith("yaml", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith("yml", StringComparison.OrdinalIgnoreCase);
}
}
35 changes: 22 additions & 13 deletions src/Refitter.Core/RefitGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
/// </summary>
public class RefitGenerator(RefitGeneratorSettings settings, OpenApiDocument document)
{
private static readonly Regex JsonStringEnumConverterAttributeRegex = new(
@"^\s*\[(System\.Text\.Json\.Serialization\.)?JsonConverter\(typeof\((System\.Text\.Json\.Serialization\.)?JsonStringEnumConverter\)\)\]\s*\r?\n?",
RegexOptions.Compiled | RegexOptions.Multiline,
TimeSpan.FromSeconds(1));

/// <summary>
/// OpenAPI specifications used to generate Refit clients and interfaces.
/// </summary>
Expand Down Expand Up @@ -94,9 +99,21 @@
}

// compile all expressions here once, as we will use them more than once
var regexes = pathMatchExpressions.Select(x => new Regex(x, RegexOptions.Compiled, TimeSpan.FromSeconds(1))).ToList();
var regexes = pathMatchExpressions
.Select(x => new Regex(x, RegexOptions.Compiled, TimeSpan.FromSeconds(1)))
.ToArray();
var paths = document.Paths.Keys
.Where(pathKey => regexes.TrueForAll(regex => !regex.IsMatch(pathKey)))
.Where(
pathKey =>
{
for (var i = 0; i < regexes.Length; i++)
{
if (regexes[i].IsMatch(pathKey))
return false;
}

return true;
})
.ToArray();

foreach (string pathKey in paths)
Expand Down Expand Up @@ -158,7 +175,7 @@

if (!string.IsNullOrWhiteSpace(settings.ContractTypeSuffix))
{
result = ContractTypeSuffixApplier.ApplySuffix(result, settings.ContractTypeSuffix);

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

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.

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

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.

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

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.

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

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.

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

View workflow job for this annotation

GitHub Actions / script

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.
}

return result;
Expand Down Expand Up @@ -229,7 +246,7 @@
generatedFiles = generatedFiles
.Select(f => f with
{
Content = ContractTypeSuffixApplier.ApplySuffix(f.Content, settings.ContractTypeSuffix)

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

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.

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

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.

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

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.

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

View workflow job for this annotation

GitHub Actions / 👌 Verify build

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.

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

View workflow job for this annotation

GitHub Actions / script

Possible null reference argument for parameter 'suffix' in 'string ContractTypeSuffixApplier.ApplySuffix(string generatedCode, string suffix)'.
})
.ToList();
}
Expand All @@ -244,17 +261,9 @@
return contracts;
}

const string pattern = @"^\s*\[(System\.Text\.Json\.Serialization\.)?JsonConverter\(typeof\((System\.Text\.Json\.Serialization\.)?JsonStringEnumConverter\)\)\]\s*$";
var lines = contracts.Split(["\r\n", "\r", "\n"], StringSplitOptions.None);
var filteredLines = lines
.Where(
line => !Regex.IsMatch(
line,
pattern,
RegexOptions.None,
TimeSpan.FromSeconds(1)))
.ToArray();
return string.Join(Environment.NewLine, filteredLines);
return JsonStringEnumConverterAttributeRegex
.Replace(contracts, string.Empty)
.TrimEnd();
}

/// <summary>
Expand Down
44 changes: 16 additions & 28 deletions src/Refitter.Core/RefitInterfaceGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ namespace Refitter.Core;
internal class RefitInterfaceGenerator : IRefitInterfaceGenerator
{
protected const string Separator = " ";
private static readonly Regex HttpResponseMessageTypeRegex = new("(Task|IObservable)<HttpResponseMessage>", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private static readonly Regex ApiResponseTypeRegex = new("(Task|IObservable)<(I)?ApiResponse(<[\\w<>]+>)?>", RegexOptions.Compiled, TimeSpan.FromSeconds(1));

protected readonly RefitGeneratorSettings settings;
protected readonly OpenApiDocument document;
Expand Down Expand Up @@ -73,15 +75,16 @@ private string GenerateInterfaceBody(out string? dynamicQuerystringParameters)
var parametersString = string.Join(", ", parameters);
var hasApizrRequestOptionsParameter = settings.ApizrSettings?.WithRequestOptions == true;
var hasCancellationToken = settings.UseCancellationTokens && !hasApizrRequestOptionsParameter;
var isApiResponseType = IsApiResponseType(returnType);

if (settings.GenerateXmlDocCodeComments)
{
this.docGenerator.AppendMethodDocumentation(operationModel, IsApiResponseType(returnType), hasDynamicQuerystringParameter, hasApizrRequestOptionsParameter, hasCancellationToken, code);
this.docGenerator.AppendMethodDocumentation(operationModel, isApiResponseType, hasDynamicQuerystringParameter, hasApizrRequestOptionsParameter, hasCancellationToken, code);
}

GenerateObsoleteAttribute(operation, code);
GenerateForMultipartFormData(operationModel, code);
GenerateHeaders(operations, operation, operationModel, code);
GenerateHeaders(operations, operationModel, code);

code.AppendLine($"{Separator}{Separator}[{verb}(\"{kv.Key}\")]")
.AppendLine($"{Separator}{Separator}{returnType} {operationName}({parametersString});")
Expand All @@ -91,11 +94,11 @@ private string GenerateInterfaceBody(out string? dynamicQuerystringParameters)
{
if (settings.GenerateXmlDocCodeComments)
{
this.docGenerator.AppendMethodDocumentation(operationModel, IsApiResponseType(returnType), false, hasApizrRequestOptionsParameter, hasCancellationToken, code);
this.docGenerator.AppendMethodDocumentation(operationModel, isApiResponseType, false, hasApizrRequestOptionsParameter, hasCancellationToken, code);
}
GenerateObsoleteAttribute(operation, code);
GenerateForMultipartFormData(operationModel, code);
GenerateHeaders(operations, operation, operationModel, code);
GenerateHeaders(operations, operationModel, code);

parametersString = string.Join(", ", parameters.Where(parameter => !parameter.Contains("?")));

Expand Down Expand Up @@ -245,7 +248,6 @@ protected static void GenerateForMultipartFormData(CSharpOperationModel operatio

protected void GenerateHeaders(
KeyValuePair<string, OpenApiOperation> operations,
OpenApiOperation operation,
CSharpOperationModel operationModel,
StringBuilder code)
{
Expand All @@ -254,14 +256,14 @@ protected void GenerateHeaders(
if (settings.AddAcceptHeaders && document.SchemaType is >= NJsonSchema.SchemaType.OpenApi3)
{
//Generate header "Accept"
var contentTypes = operations.Value.Responses.Select(pair => operation.Responses[pair.Key].Content.Keys);

//remove duplicates
var uniqueContentTypes = contentTypes
.GroupBy(x => x)
.SelectMany(y => y.First())
.Distinct()
.ToList();
var uniqueContentTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var response in operations.Value.Responses.Values)
{
foreach (var contentType in response.Content.Keys)
{
uniqueContentTypes.Add(contentType);
}
}

if (uniqueContentTypes.Any())
{
Expand Down Expand Up @@ -328,21 +330,7 @@ private string GetDefaultReturnType()
/// <returns>True if the type is an ApiResponse Task or similar, false otherwise.</returns>
protected static bool IsApiResponseType(string typeName)
{
// Check for HttpResponseMessage
if (Regex.IsMatch(
typeName,
"(Task|IObservable)<HttpResponseMessage>",
RegexOptions.None,
TimeSpan.FromSeconds(1)))
{
return true;
}

return Regex.IsMatch(
typeName,
"(Task|IObservable)<(I)?ApiResponse(<[\\w<>]+>)?>",
RegexOptions.None,
TimeSpan.FromSeconds(1));
return HttpResponseMessageTypeRegex.IsMatch(typeName) || ApiResponseTypeRegex.IsMatch(typeName);
}

private string GetConfiguredReturnType(string returnTypeParameter)
Expand Down
4 changes: 2 additions & 2 deletions src/Refitter.Core/RefitMultipleInterfaceByTagGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ public override IEnumerable<GeneratedCode> GenerateCode()
this.docGenerator.AppendMethodDocumentation(operationModel, IsApiResponseType(returnType), hasDynamicQuerystringParameter, hasApizrRequestOptionsParameter, hasCancellationToken, sb);
GenerateObsoleteAttribute(operation, sb);
GenerateForMultipartFormData(operationModel, sb);
GenerateHeaders(operations, operation, operationModel, sb);
GenerateHeaders(operations, operationModel, sb);

sb.AppendLine($"{Separator}{Separator}[{verb}(\"{op.PathItem.Key}\")]")
.AppendLine($"{Separator}{Separator}{returnType} {operationName}({parametersString});")
Expand All @@ -103,7 +103,7 @@ public override IEnumerable<GeneratedCode> GenerateCode()
this.docGenerator.AppendMethodDocumentation(operationModel, IsApiResponseType(returnType), false, hasApizrRequestOptionsParameter, hasCancellationToken, sb);
GenerateObsoleteAttribute(operation, sb);
GenerateForMultipartFormData(operationModel, sb);
GenerateHeaders(operations, operation, operationModel, sb);
GenerateHeaders(operations, operationModel, sb);

parametersString = string.Join(", ", parameters.Where(parameter => !parameter.Contains("?")));

Expand Down
4 changes: 2 additions & 2 deletions src/Refitter.Core/RefitMultipleInterfaceGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public override IEnumerable<GeneratedCode> GenerateCode()
this.docGenerator.AppendMethodDocumentation(operationModel, IsApiResponseType(returnType), hasDynamicQuerystringParameter, hasApizrRequestOptionsParameter, hasCancellationToken, code);
GenerateObsoleteAttribute(operation, code);
GenerateForMultipartFormData(operationModel, code);
GenerateHeaders(operations, operation, operationModel, code);
GenerateHeaders(operations, operationModel, code);

code.AppendLine($"{Separator}{Separator}[{verb}(\"{kv.Key}\")]")
.AppendLine($"{Separator}{Separator}{returnType} {methodName}({parametersString});")
Expand All @@ -72,7 +72,7 @@ public override IEnumerable<GeneratedCode> GenerateCode()
this.docGenerator.AppendMethodDocumentation(operationModel, IsApiResponseType(returnType), false, hasApizrRequestOptionsParameter, hasCancellationToken, code);
GenerateObsoleteAttribute(operation, code);
GenerateForMultipartFormData(operationModel, code);
GenerateHeaders(operations, operation, operationModel, code);
GenerateHeaders(operations, operationModel, code);

parametersString = string.Join(", ", parameters.Where(parameter => !parameter.Contains("?")));

Expand Down
12 changes: 5 additions & 7 deletions src/Refitter.Core/SchemaCleaner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace Refitter.Core;
public class SchemaCleaner
{
private readonly OpenApiDocument document;
private readonly string[] keepSchemaPatterns;
private readonly Regex[] keepSchemaRegexes;

/// <summary>
/// Gets or sets a value indicating whether to include inheritance hierarchy in the schema cleaning process.
Expand All @@ -25,7 +25,9 @@ public class SchemaCleaner
public SchemaCleaner(OpenApiDocument document, string[] keepSchemaPatterns)
{
this.document = document;
this.keepSchemaPatterns = keepSchemaPatterns;
keepSchemaRegexes = keepSchemaPatterns
.Select(x => new Regex(x, RegexOptions.Compiled, TimeSpan.FromSeconds(1)))
.ToArray();
}

/// <summary>
Expand Down Expand Up @@ -71,16 +73,12 @@ public void RemoveUnreferencedSchema()
.ToDictionary(x => x.Value,
x => x.Key);

var keepSchemaRegexes = keepSchemaPatterns
.Select(x => new Regex(x, RegexOptions.Compiled, TimeSpan.FromSeconds(1)))
.ToList();

if (doc.Components?.Schemas != null)
{
foreach (var kvp in doc.Components.Schemas)
{
var schema = kvp.Key;
if (keepSchemaRegexes.Exists(x => x.IsMatch(schema)))
if (keepSchemaRegexes.Any(x => x.IsMatch(schema)))
{
TryPush(kvp.Value, toProcess);
}
Expand Down
5 changes: 4 additions & 1 deletion src/Refitter.Tests/Examples/DuplicateOperationIdTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ public async Task Generated_Code_Handles_Duplicate_OperationIds_Gracefully()
public async Task Generated_Code_Contains_Methods()
{
string generatedCode = await GenerateCode();
generatedCode.Should().Contain("GetItems");
// When operationIds are duplicated, NSwag falls back to path-based method names
generatedCode.Should().Contain("Items");
generatedCode.Should().Contain("Products");
generatedCode.Should().Contain("Orders");
}

private static async Task<string> GenerateCode()
Expand Down
1 change: 1 addition & 0 deletions src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public async Task Generated_Code_Does_Not_Contain_JsonConverter_When_Disabled()
using (new AssertionScope())
{
generatedCode.Should().NotContain("[JsonConverter(typeof(JsonStringEnumConverter))]");
generatedCode.Should().NotContain("[JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]");
generatedCode.Should().Contain("Status { get; set; }");
generatedCode.Should().Contain("public enum PetStatus");
}
Expand Down
2 changes: 2 additions & 0 deletions src/Refitter.Tests/OpenApiDocumentFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public async Task Create_From_File_Returns_NotNull(SampleOpenSpecifications vers
[Arguments(SampleOpenSpecifications.SwaggerPetstoreJsonV3, "petstore.json")]
[Arguments(SampleOpenSpecifications.SwaggerPetstoreYamlV3, "petstore.yaml")]
[Arguments(SampleOpenSpecifications.SwaggerPetstoreYamlV3, "petstore.yml")]
[Arguments(SampleOpenSpecifications.SwaggerPetstoreYamlV3, "petstore.YAML")]
[Arguments(SampleOpenSpecifications.SwaggerPetstoreYamlV3, "petstore.YML")]
public async Task Create_From_File_Detects_Format_Correctly(SampleOpenSpecifications version, string filename)
{
var swaggerFile = await TestFile.CreateSwaggerFile(EmbeddedResources.GetSwaggerPetstore(version), filename);
Expand Down
Loading