From 16a55fc42cc3178658e521cddf021c276867fedb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:29:24 +0000 Subject: [PATCH 1/4] Initial plan From fbe3ebd3998648de9fed1ffb99d83ebf8d652cc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:45:49 +0000 Subject: [PATCH 2/4] Optimize core generation hot paths Co-authored-by: christianhelle <710400+christianhelle@users.noreply.github.com> --- src/Refitter.Core/OpenApiDocumentFactory.cs | 31 +++++++++---- src/Refitter.Core/RefitGenerator.cs | 35 +++++++++------ src/Refitter.Core/RefitInterfaceGenerator.cs | 44 +++++++------------ .../RefitMultipleInterfaceByTagGenerator.cs | 4 +- .../RefitMultipleInterfaceGenerator.cs | 4 +- src/Refitter.Core/SchemaCleaner.cs | 12 +++-- .../Examples/InlineJsonConvertersTests.cs | 1 + .../OpenApiDocumentFactoryTests.cs | 2 + 8 files changed, 73 insertions(+), 60 deletions(-) diff --git a/src/Refitter.Core/OpenApiDocumentFactory.cs b/src/Refitter.Core/OpenApiDocumentFactory.cs index 5d6e6988f..18c0f9721 100644 --- a/src/Refitter.Core/OpenApiDocumentFactory.cs +++ b/src/Refitter.Core/OpenApiDocumentFactory.cs @@ -11,6 +11,12 @@ namespace Refitter.Core; /// public static class OpenApiDocumentFactory { + private static readonly HttpClient HttpClient = new( + new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }); + /// /// Creates a merged from multiple paths or URLs. /// The first document serves as the base; paths and schemas from subsequent documents are merged in. @@ -37,8 +43,17 @@ public static async Task CreateAsync(IEnumerable openAp private static OpenApiDocument Merge(OpenApiDocument[] documents) { var baseDocument = documents[0]; - foreach (var document in documents.Skip(1)) + var tags = baseDocument.Tags; + HashSet? tagNames = null; + + if (tags != null) + { + tagNames = new HashSet(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)) @@ -67,9 +82,10 @@ private static OpenApiDocument Merge(OpenApiDocument[] documents) if (document.Tags != null) { baseDocument.Tags ??= []; + tagNames ??= new HashSet(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); } } @@ -152,7 +168,8 @@ private static void PopulateMissingRequiredFields( /// True if the path is an HTTP URL, otherwise false. private static bool IsHttp(string path) { - return path.StartsWith("http://") || path.StartsWith("https://"); + return path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase); } /// @@ -162,10 +179,7 @@ private static bool IsHttp(string path) /// The content of the HTTP request. private static async Task 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); + var content = await HttpClient.GetStringAsync(openApiPath); return content; } @@ -177,6 +191,7 @@ private static async Task GetHttpContent(string openApiPath) /// True if the path is a YAML file, otherwise false. private static bool IsYaml(string path) { - return path.EndsWith("yaml") || path.EndsWith("yml"); + return path.EndsWith("yaml", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("yml", StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Refitter.Core/RefitGenerator.cs b/src/Refitter.Core/RefitGenerator.cs index e81aff54f..e715b3875 100644 --- a/src/Refitter.Core/RefitGenerator.cs +++ b/src/Refitter.Core/RefitGenerator.cs @@ -9,6 +9,11 @@ namespace Refitter.Core; /// 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)); + /// /// OpenAPI specifications used to generate Refit clients and interfaces. /// @@ -94,9 +99,21 @@ private static void ProcessPathFilters(OpenApiDocument document, } // 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) @@ -244,17 +261,9 @@ private string SanitizeGeneratedContracts(string contracts) 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(); } /// diff --git a/src/Refitter.Core/RefitInterfaceGenerator.cs b/src/Refitter.Core/RefitInterfaceGenerator.cs index 16e09fe46..faebb2aec 100644 --- a/src/Refitter.Core/RefitInterfaceGenerator.cs +++ b/src/Refitter.Core/RefitInterfaceGenerator.cs @@ -8,6 +8,8 @@ namespace Refitter.Core; internal class RefitInterfaceGenerator : IRefitInterfaceGenerator { protected const string Separator = " "; + private static readonly Regex HttpResponseMessageTypeRegex = new("(Task|IObservable)", 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; @@ -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});") @@ -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("?"))); @@ -245,7 +248,6 @@ protected static void GenerateForMultipartFormData(CSharpOperationModel operatio protected void GenerateHeaders( KeyValuePair operations, - OpenApiOperation operation, CSharpOperationModel operationModel, StringBuilder code) { @@ -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(StringComparer.OrdinalIgnoreCase); + foreach (var response in operations.Value.Responses.Values) + { + foreach (var contentType in response.Content.Keys) + { + uniqueContentTypes.Add(contentType); + } + } if (uniqueContentTypes.Any()) { @@ -328,21 +330,7 @@ private string GetDefaultReturnType() /// True if the type is an ApiResponse Task or similar, false otherwise. protected static bool IsApiResponseType(string typeName) { - // Check for HttpResponseMessage - if (Regex.IsMatch( - typeName, - "(Task|IObservable)", - 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) diff --git a/src/Refitter.Core/RefitMultipleInterfaceByTagGenerator.cs b/src/Refitter.Core/RefitMultipleInterfaceByTagGenerator.cs index 0b9958411..bb398548b 100644 --- a/src/Refitter.Core/RefitMultipleInterfaceByTagGenerator.cs +++ b/src/Refitter.Core/RefitMultipleInterfaceByTagGenerator.cs @@ -92,7 +92,7 @@ public override IEnumerable 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});") @@ -103,7 +103,7 @@ public override IEnumerable 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("?"))); diff --git a/src/Refitter.Core/RefitMultipleInterfaceGenerator.cs b/src/Refitter.Core/RefitMultipleInterfaceGenerator.cs index 224356a30..8f165fa16 100644 --- a/src/Refitter.Core/RefitMultipleInterfaceGenerator.cs +++ b/src/Refitter.Core/RefitMultipleInterfaceGenerator.cs @@ -61,7 +61,7 @@ public override IEnumerable 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});") @@ -72,7 +72,7 @@ public override IEnumerable 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("?"))); diff --git a/src/Refitter.Core/SchemaCleaner.cs b/src/Refitter.Core/SchemaCleaner.cs index 96d2bf70f..6c358d350 100644 --- a/src/Refitter.Core/SchemaCleaner.cs +++ b/src/Refitter.Core/SchemaCleaner.cs @@ -10,7 +10,7 @@ namespace Refitter.Core; public class SchemaCleaner { private readonly OpenApiDocument document; - private readonly string[] keepSchemaPatterns; + private readonly Regex[] keepSchemaRegexes; /// /// Gets or sets a value indicating whether to include inheritance hierarchy in the schema cleaning process. @@ -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(); } /// @@ -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); } diff --git a/src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs b/src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs index 74fcc5d58..334269419 100644 --- a/src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs +++ b/src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs @@ -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"); } diff --git a/src/Refitter.Tests/OpenApiDocumentFactoryTests.cs b/src/Refitter.Tests/OpenApiDocumentFactoryTests.cs index 003ab3a70..4e21bf6c1 100644 --- a/src/Refitter.Tests/OpenApiDocumentFactoryTests.cs +++ b/src/Refitter.Tests/OpenApiDocumentFactoryTests.cs @@ -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); From 534dded9464cc3b24eb502eda7be870aefd355c2 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 5 Mar 2026 13:37:28 +0100 Subject: [PATCH 3/4] Simplify GetHttpContent to remove redundant local variable --- src/Refitter.Core/OpenApiDocumentFactory.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Refitter.Core/OpenApiDocumentFactory.cs b/src/Refitter.Core/OpenApiDocumentFactory.cs index 18c0f9721..2cb5dde48 100644 --- a/src/Refitter.Core/OpenApiDocumentFactory.cs +++ b/src/Refitter.Core/OpenApiDocumentFactory.cs @@ -177,11 +177,8 @@ private static bool IsHttp(string path) /// /// The path to the OpenAPI document. /// The content of the HTTP request. - private static async Task GetHttpContent(string openApiPath) - { - var content = await HttpClient.GetStringAsync(openApiPath); - return content; - } + private static Task GetHttpContent(string openApiPath) + => HttpClient.GetStringAsync(openApiPath); /// From c97f994b895eedb92992c0cdf66fd24f8448a9c6 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 5 Mar 2026 13:37:35 +0100 Subject: [PATCH 4/4] Fix DuplicateOperationIdTests assertion to match path-based method names --- src/Refitter.Tests/Examples/DuplicateOperationIdTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Refitter.Tests/Examples/DuplicateOperationIdTests.cs b/src/Refitter.Tests/Examples/DuplicateOperationIdTests.cs index bbb17580b..37228f4a4 100644 --- a/src/Refitter.Tests/Examples/DuplicateOperationIdTests.cs +++ b/src/Refitter.Tests/Examples/DuplicateOperationIdTests.cs @@ -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 GenerateCode()