diff --git a/src/Refitter.Core/EnumStringConverterInjector.cs b/src/Refitter.Core/EnumStringConverterInjector.cs new file mode 100644 index 000000000..351156554 --- /dev/null +++ b/src/Refitter.Core/EnumStringConverterInjector.cs @@ -0,0 +1,41 @@ +using System.Text.RegularExpressions; +using NSwag; + +namespace Refitter.Core; + +internal sealed class EnumStringConverterInjector : IContractsPostProcessor +{ + private static readonly Regex JsonStringEnumConverterAttributeRegex = new( + @"^\s*\[(System\.Text\.Json\.Serialization\.)?JsonConverter\(typeof\((System\.Text\.Json\.Serialization\.)?JsonStringEnumConverter(?:<[\w.]+>)?\)\)\]\s*\r?\n?", + RegexOptions.Compiled | RegexOptions.Multiline, + TimeSpan.FromSeconds(1)); + + private static readonly Regex EnumDeclarationRegex = new( + @"^(\s*)((?:public|internal)\s+(?:partial\s+)?enum\s+\w+\b)", + RegexOptions.Compiled | RegexOptions.Multiline, + TimeSpan.FromSeconds(1)); + + public string Process(OpenApiDocument document, RefitGeneratorSettings settings, string contracts) + { + if (settings.CodeGeneratorSettings is not { InlineJsonConverters: false }) + { + contracts = JsonStringEnumConverterAttributeRegex.Replace(contracts, string.Empty); + var newLine = GetPreferredNewLine(contracts); + return EnumDeclarationRegex + .Replace( + contracts, + 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(); + } + + return JsonStringEnumConverterAttributeRegex + .Replace(contracts, string.Empty) + .TrimEnd(); + } + + private static string GetPreferredNewLine(string content) => + content.Contains("\r\n", StringComparison.Ordinal) + ? "\r\n" + : "\n"; +} diff --git a/src/Refitter.Core/GeneratorPipeline.cs b/src/Refitter.Core/GeneratorPipeline.cs index a87c90b7a..f416943b1 100644 --- a/src/Refitter.Core/GeneratorPipeline.cs +++ b/src/Refitter.Core/GeneratorPipeline.cs @@ -1,40 +1,31 @@ using System.Text; -using System.Text.RegularExpressions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using NSwag; namespace Refitter.Core; internal sealed class GeneratorPipeline { - private static readonly Regex JsonStringEnumConverterAttributeRegex = new( - @"^\s*\[(System\.Text\.Json\.Serialization\.)?JsonConverter\(typeof\((System\.Text\.Json\.Serialization\.)?JsonStringEnumConverter(?:<[\w.]+>)?\)\)\]\s*\r?\n?", - RegexOptions.Compiled | RegexOptions.Multiline, - TimeSpan.FromSeconds(1)); + private readonly InterfaceGenerator interfaceGenerator; + private readonly IReadOnlyList contractsPostProcessors; - private static readonly Regex EnumDeclarationRegex = new( - @"^(\s*)((?:public|internal)\s+(?:partial\s+)?enum\s+\w+\b)", - RegexOptions.Compiled | RegexOptions.Multiline, - TimeSpan.FromSeconds(1)); + internal GeneratorPipeline( + XmlDocumentationGenerator docGenerator, + InterfaceGenerator interfaceGenerator, + IEnumerable contractsPostProcessors) + { + this.interfaceGenerator = interfaceGenerator; + this.contractsPostProcessors = contractsPostProcessors.ToArray(); + } public GenerationResult Run( OpenApiDocument document, RefitGeneratorSettings settings, CustomCSharpClientGenerator generator) { - var docGenerator = new XmlDocumentationGenerator(settings); - - // Create the interface generator before calling GenerateFile() so that - // OperationNameGenerator.CheckForDuplicateOperationIds() sees the original - // (pre-generation) operation IDs. GenerateFile() auto-populates operation IDs - // with globally unique names which would prevent the switch to the path segments - // generator, causing unnecessary numeric suffixes in ByTag mode. - var interfaceGenerator = new InterfaceGenerator(settings, document, generator, docGenerator); - var contracts = generator.GenerateFile(); - contracts = SanitizeGeneratedContracts(document, settings, contracts); + foreach (var postProcessor in contractsPostProcessors) + contracts = postProcessor.Process(document, settings, contracts); + var serializerContext = GenerateJsonSerializerContext(document, settings, contracts); var interfaces = GenerateClient(document, settings, interfaceGenerator); var interfaceNames = interfaces.Select(c => c.TypeName).ToArray(); @@ -58,51 +49,7 @@ private static IInterfacePartitioning GetInterfacePartitioning(OpenApiDocument d }; } - internal static string SanitizeGeneratedContracts(OpenApiDocument document, RefitGeneratorSettings settings, string contracts) - { - contracts = NormalizeSwagger2OptionalReferencePropertyNullability(document, settings, contracts); - - if (settings.CodeGeneratorSettings is not { InlineJsonConverters: false }) - { - contracts = JsonStringEnumConverterAttributeRegex.Replace(contracts, string.Empty); - var newLine = GetPreferredNewLine(contracts); - return EnumDeclarationRegex - .Replace( - contracts, - 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(); - } - - return JsonStringEnumConverterAttributeRegex - .Replace(contracts, string.Empty) - .TrimEnd(); - } - - private static string GetPreferredNewLine(string content) => - content.Contains("\r\n", StringComparison.Ordinal) - ? "\r\n" - : "\n"; - - internal static string NormalizeSwagger2OptionalReferencePropertyNullability( - OpenApiDocument document, - RefitGeneratorSettings settings, - string contracts) - { - if (document.SchemaType != NJsonSchema.SchemaType.Swagger2 || - settings.CodeGeneratorSettings?.GenerateNullableReferenceTypes != true || - settings.CodeGeneratorSettings.GenerateOptionalPropertiesAsNullable) - { - return contracts; - } - - var tree = CSharpSyntaxTree.ParseText(contracts); - var root = tree.GetCompilationUnitRoot(); - var rewrittenRoot = new Swagger2OptionalReferencePropertyNullabilityRewriter().Visit(root); - return rewrittenRoot!.ToFullString(); - } - - internal static string GenerateJsonSerializerContext( + private static string GenerateJsonSerializerContext( OpenApiDocument document, RefitGeneratorSettings settings, string contracts) => @@ -188,32 +135,6 @@ private static void GenerateAutoGeneratedHeader(RefitGeneratorSettings settings, """); } - - private sealed class Swagger2OptionalReferencePropertyNullabilityRewriter : CSharpSyntaxRewriter - { - public override SyntaxNode? VisitPropertyDeclaration(PropertyDeclarationSyntax node) - { - if (node.Type is NullableTypeSyntax nullableType && - IsReferenceType(nullableType.ElementType)) - { - node = node.WithType(nullableType.ElementType.WithTriviaFrom(node.Type)); - } - - return base.VisitPropertyDeclaration(node); - } - - private static bool IsReferenceType(TypeSyntax typeSyntax) => - typeSyntax switch - { - PredefinedTypeSyntax predefinedType => predefinedType.Keyword.Kind() is SyntaxKind.ObjectKeyword or SyntaxKind.StringKeyword, - ArrayTypeSyntax => true, - IdentifierNameSyntax => true, - GenericNameSyntax => true, - QualifiedNameSyntax => true, - AliasQualifiedNameSyntax => true, - _ => false, - }; - } } internal record GenerationResult( diff --git a/src/Refitter.Core/IContractsPostProcessor.cs b/src/Refitter.Core/IContractsPostProcessor.cs new file mode 100644 index 000000000..129394d37 --- /dev/null +++ b/src/Refitter.Core/IContractsPostProcessor.cs @@ -0,0 +1,9 @@ +using NSwag; +using Refitter.Core; + +namespace Refitter.Core; + +internal interface IContractsPostProcessor +{ + string Process(OpenApiDocument document, RefitGeneratorSettings settings, string contracts); +} diff --git a/src/Refitter.Core/RefitGenerator.cs b/src/Refitter.Core/RefitGenerator.cs index c6a0c256d..99997a218 100644 --- a/src/Refitter.Core/RefitGenerator.cs +++ b/src/Refitter.Core/RefitGenerator.cs @@ -9,7 +9,6 @@ namespace Refitter.Core; /// public class RefitGenerator(RefitGeneratorSettings settings, OpenApiDocument document) { - private readonly GeneratorPipeline pipeline = new(); /// /// OpenAPI specifications used to generate Refit clients and interfaces. @@ -157,18 +156,27 @@ private GenerationResult RunPipeline() { var factory = new CSharpClientGeneratorFactory(settings, document); var generator = factory.Create(); + var docGenerator = new XmlDocumentationGenerator(settings); + + // Create the interface generator before calling GenerateFile() so that + // OperationNameGenerator.CheckForDuplicateOperationIds() sees the original + // (pre-generation) operation IDs. GenerateFile() auto-populates operation IDs + // with globally unique names which would prevent the switch to the path segments + // generator, causing unnecessary numeric suffixes in ByTag mode. + var interfaceGenerator = new InterfaceGenerator(settings, document, generator, docGenerator); + + var pipeline = new GeneratorPipeline( + docGenerator, + interfaceGenerator, + new IContractsPostProcessor[] + { + new Swagger2OptionalReferenceNullabilityNormalizer(), + new EnumStringConverterInjector(), + }); + return pipeline.Run(document, settings, generator); } - private string SanitizeGeneratedContracts(string contracts) => - GeneratorPipeline.SanitizeGeneratedContracts(document, settings, contracts); - - private string NormalizeSwagger2OptionalReferencePropertyNullability(string contracts) => - GeneratorPipeline.NormalizeSwagger2OptionalReferencePropertyNullability(document, settings, contracts); - - private string GenerateJsonSerializerContext(string contracts) => - GeneratorPipeline.GenerateJsonSerializerContext(document, settings, contracts); - private string FormatSingleFile(GenerationResult result) { var contracts = settings.GenerateClients diff --git a/src/Refitter.Core/Swagger2OptionalReferenceNullabilityNormalizer.cs b/src/Refitter.Core/Swagger2OptionalReferenceNullabilityNormalizer.cs new file mode 100644 index 000000000..af6aa1892 --- /dev/null +++ b/src/Refitter.Core/Swagger2OptionalReferenceNullabilityNormalizer.cs @@ -0,0 +1,51 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NJsonSchema; +using NSwag; + +namespace Refitter.Core; + +internal sealed class Swagger2OptionalReferenceNullabilityNormalizer : IContractsPostProcessor +{ + public string Process(OpenApiDocument document, RefitGeneratorSettings settings, string contracts) + { + if (document.SchemaType != SchemaType.Swagger2 || + settings.CodeGeneratorSettings?.GenerateNullableReferenceTypes != true || + settings.CodeGeneratorSettings.GenerateOptionalPropertiesAsNullable) + { + return contracts; + } + + var tree = CSharpSyntaxTree.ParseText(contracts); + var root = tree.GetCompilationUnitRoot(); + var rewrittenRoot = new Swagger2OptionalReferencePropertyNullabilityRewriter().Visit(root); + return rewrittenRoot!.ToFullString(); + } + + private sealed class Swagger2OptionalReferencePropertyNullabilityRewriter : CSharpSyntaxRewriter + { + public override SyntaxNode? VisitPropertyDeclaration(PropertyDeclarationSyntax node) + { + if (node.Type is NullableTypeSyntax nullableType && + IsReferenceType(nullableType.ElementType)) + { + node = node.WithType(nullableType.ElementType.WithTriviaFrom(node.Type)); + } + + return base.VisitPropertyDeclaration(node); + } + + private static bool IsReferenceType(TypeSyntax typeSyntax) => + typeSyntax switch + { + PredefinedTypeSyntax predefinedType => predefinedType.Keyword.Kind() is SyntaxKind.ObjectKeyword or SyntaxKind.StringKeyword, + ArrayTypeSyntax => true, + IdentifierNameSyntax => true, + GenericNameSyntax => true, + QualifiedNameSyntax => true, + AliasQualifiedNameSyntax => true, + _ => false, + }; + } +} diff --git a/src/Refitter.Tests/GeneratorPipelineTests.cs b/src/Refitter.Tests/GeneratorPipelineTests.cs index cb7cfdb99..58a612afe 100644 --- a/src/Refitter.Tests/GeneratorPipelineTests.cs +++ b/src/Refitter.Tests/GeneratorPipelineTests.cs @@ -131,6 +131,12 @@ public async Task Run_ByTag_Preserves_PreGeneration_OperationIds_For_Interface_N private static GenerationResult RunPipeline(OpenApiDocument document, RefitGeneratorSettings settings) { var generator = new CSharpClientGeneratorFactory(settings, document).Create(); - return new GeneratorPipeline().Run(document, settings, generator); + var docGenerator = new XmlDocumentationGenerator(settings); + var interfaceGenerator = new InterfaceGenerator(settings, document, generator, docGenerator); + var pipeline = new GeneratorPipeline( + docGenerator, + interfaceGenerator, + Array.Empty()); + return pipeline.Run(document, settings, generator); } } diff --git a/src/Refitter.Tests/Regression/RefitGeneratorAdvancedTests.cs b/src/Refitter.Tests/Regression/RefitGeneratorAdvancedTests.cs index 1d859494e..dd43b13cb 100644 --- a/src/Refitter.Tests/Regression/RefitGeneratorAdvancedTests.cs +++ b/src/Refitter.Tests/Regression/RefitGeneratorAdvancedTests.cs @@ -1,4 +1,3 @@ -using System.Reflection; using FluentAssertions; using NJsonSchema; using NSwag; @@ -761,18 +760,18 @@ public partial class Response } """; - var generator = new RefitGenerator( - new RefitGeneratorSettings + var normalizer = new Swagger2OptionalReferenceNullabilityNormalizer(); + var document = new OpenApiDocument { SchemaType = SchemaType.Swagger2 }; + var settings = new RefitGeneratorSettings + { + CodeGeneratorSettings = new CodeGeneratorSettings { - CodeGeneratorSettings = new CodeGeneratorSettings - { - GenerateNullableReferenceTypes = true, - GenerateOptionalPropertiesAsNullable = false - } - }, - new OpenApiDocument { SchemaType = SchemaType.Swagger2 }); + GenerateNullableReferenceTypes = true, + GenerateOptionalPropertiesAsNullable = false + } + }; - var normalized = NormalizeSwagger2OptionalReferencePropertyNullability(generator, contracts); + var normalized = normalizer.Process(document, settings, contracts); normalized.Should().Contain("public Pet[] Pets { get; set; }"); normalized.Should().Contain("public List Values { get; set; }"); @@ -885,25 +884,20 @@ public partial class Product } """; - var generator = new RefitGenerator( - new RefitGeneratorSettings - { - GenerateJsonSerializerContext = true, - GenerateContracts = true, - Namespace = "Generated.Clients", - ContractsNamespace = "Generated.Contracts", - Naming = new NamingSettings - { - UseOpenApiTitle = true, - InterfaceName = "ITestApi" - } - }, - new OpenApiDocument + var settings = new RefitGeneratorSettings + { + GenerateJsonSerializerContext = true, + GenerateContracts = true, + Namespace = "Generated.Clients", + ContractsNamespace = "Generated.Contracts", + Naming = new NamingSettings { - Info = null! - }); + UseOpenApiTitle = true, + InterfaceName = "ITestApi" + } + }; - var serializerContext = GenerateJsonSerializerContext(generator, contracts); + var serializerContext = JsonSerializerContextGenerator.Generate(contracts, settings); serializerContext.Should().Contain("internal partial class TestApiSerializerContext"); } @@ -1033,26 +1027,6 @@ public async Task GenerateMultipleFiles_With_Whitespace_OpenApi_Title_Uses_Inter #endregion - private static string NormalizeSwagger2OptionalReferencePropertyNullability(RefitGenerator generator, string contracts) - { - var method = typeof(RefitGenerator).GetMethod( - "NormalizeSwagger2OptionalReferencePropertyNullability", - BindingFlags.Instance | BindingFlags.NonPublic); - - method.Should().NotBeNull(); - return method!.Invoke(generator, [contracts]).Should().BeOfType().Subject; - } - - private static string GenerateJsonSerializerContext(RefitGenerator generator, string contracts) - { - var method = typeof(RefitGenerator).GetMethod( - "GenerateJsonSerializerContext", - BindingFlags.Instance | BindingFlags.NonPublic); - - method.Should().NotBeNull(); - return method!.Invoke(generator, [contracts]).Should().BeOfType().Subject; - } - private static void CleanupSwaggerFile(string swaggerFile) { if (File.Exists(swaggerFile)) diff --git a/src/Refitter.Tests/Scenarios/InlineJsonConvertersTests.cs b/src/Refitter.Tests/Scenarios/InlineJsonConvertersTests.cs index 8a10d003e..d5ff8430d 100644 --- a/src/Refitter.Tests/Scenarios/InlineJsonConvertersTests.cs +++ b/src/Refitter.Tests/Scenarios/InlineJsonConvertersTests.cs @@ -1,4 +1,3 @@ -using System.Reflection; using System.Text.RegularExpressions; using FluentAssertions; using FluentAssertions.Execution; @@ -131,10 +130,8 @@ public void Generated_Code_Preserves_CRLF_When_Moving_JsonConverter_Attributes() } }; - var method = typeof(RefitGenerator).GetMethod("SanitizeGeneratedContracts", BindingFlags.Instance | BindingFlags.NonPublic); - method.Should().NotBeNull(); - - var result = (string)method!.Invoke(new RefitGenerator(settings, new OpenApiDocument()), new object[] { contracts })!; + var injector = new EnumStringConverterInjector(); + var result = injector.Process(new OpenApiDocument(), settings, contracts); result.Should().Contain("[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]\r\npublic enum PetStatus"); Regex.Matches(result, "(?