From 1b9a76c834845ce3d337b05270b4de6f506b2191 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 5 Mar 2026 15:11:46 +0100 Subject: [PATCH] Move [JsonConverter] from enum properties to enum types When InlineJsonConverters = true (default), the generated [JsonConverter(typeof(JsonStringEnumConverter))] attribute is now placed on the enum TYPE declaration instead of on each property that uses the enum. This fixes two issues: - #178: A converter in JsonSerializerOptions.Converters beats [JsonConverter] on the type but NOT on a property. Moving the attribute to the type restores user ability to override the converter. - #300: Enums with hyphenated [EnumMember] values (e.g. 'allegro-pl') require a converter that respects [EnumMember] (e.g. JsonStringEnumMemberConverter). The property-level [JsonConverter] was blocking this substitution via JsonSerializerOptions. When InlineJsonConverters = false, behavior is unchanged: all [JsonConverter] attributes are removed --- README.md | 2 +- src/Refitter.Core/RefitGenerator.cs | 18 +- .../Settings/CodeGeneratorSettings.cs | 12 +- .../Examples/HyphenatedEnumValuesTests.cs | 170 ++++++++++++++++++ .../Examples/InlineJsonConvertersTests.cs | 20 ++- 5 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 src/Refitter.Tests/Examples/HyphenatedEnumValuesTests.cs diff --git a/README.md b/README.md index e86fd96d..baf654d2 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ OPTIONS: payloads with (yet) unknown types are offered by newer versions of an API See https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism for more information --disposable Generate refit clients that implement IDisposable - --no-inline-json-converters Don't inline JsonConverter attributes for enum properties. When disabled, enum properties will not have [JsonConverter(typeof(JsonStringEnumConverter))] attributes + --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 --generate-authentication-header None Controls generation of Authorization header support. diff --git a/src/Refitter.Core/RefitGenerator.cs b/src/Refitter.Core/RefitGenerator.cs index e715b387..661fd325 100644 --- a/src/Refitter.Core/RefitGenerator.cs +++ b/src/Refitter.Core/RefitGenerator.cs @@ -10,7 +10,12 @@ 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?", + @"^\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\s+(?:partial\s+)?enum\s+\w+\b)", RegexOptions.Compiled | RegexOptions.Multiline, TimeSpan.FromSeconds(1)); @@ -258,9 +263,18 @@ private string SanitizeGeneratedContracts(string contracts) { if (settings.CodeGeneratorSettings is not { InlineJsonConverters: false }) { - return contracts; + // InlineJsonConverters = true (default): move [JsonConverter] from enum properties to enum type declarations. + // 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); + return EnumDeclarationRegex + .Replace( + contracts, + "$1[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]\n$1$2") + .TrimEnd(); } + // InlineJsonConverters = false: remove all [JsonConverter(typeof(JsonStringEnumConverter))] attributes. return JsonStringEnumConverterAttributeRegex .Replace(contracts, string.Empty) .TrimEnd(); diff --git a/src/Refitter.Core/Settings/CodeGeneratorSettings.cs b/src/Refitter.Core/Settings/CodeGeneratorSettings.cs index b917d012..aa0788a7 100644 --- a/src/Refitter.Core/Settings/CodeGeneratorSettings.cs +++ b/src/Refitter.Core/Settings/CodeGeneratorSettings.cs @@ -266,11 +266,17 @@ Gets or sets a value indicating whether named/referenced any schemas should be i public IPropertyNameGenerator? PropertyNameGenerator { get; set; } /// - /// Gets or sets a value indicating whether to inline JsonConverter attributes for enum properties (default: true). - /// When set to false, enum properties will not have [JsonConverter(typeof(JsonStringEnumConverter))] attributes. + /// Gets or sets a value indicating whether to inline JsonConverter attributes for enum types (default: true). + /// When set to true (default), the [JsonConverter(typeof(JsonStringEnumConverter))] attribute is placed + /// on the enum type declaration instead of on individual enum properties, allowing users to override the + /// converter via JsonSerializerOptions.Converters (e.g. to use JsonStringEnumMemberConverter + /// for enums whose values contain hyphens or other special characters via [EnumMember]). + /// When set to false, no [JsonConverter] attribute is emitted at all. /// [Description( - "Gets or sets a value indicating whether to inline JsonConverter attributes for enum properties (default: true). When set to false, enum properties will not have [JsonConverter(typeof(JsonStringEnumConverter))] attributes." + "Gets or sets a value indicating whether to inline JsonConverter attributes for enum types (default: true). " + + "When set to true (default), [JsonConverter(typeof(JsonStringEnumConverter))] is placed on the enum type declaration " + + "so users can override it via JsonSerializerOptions.Converters. When set to false, no [JsonConverter] is emitted." )] public bool InlineJsonConverters { get; set; } = true; diff --git a/src/Refitter.Tests/Examples/HyphenatedEnumValuesTests.cs b/src/Refitter.Tests/Examples/HyphenatedEnumValuesTests.cs new file mode 100644 index 00000000..42cc138b --- /dev/null +++ b/src/Refitter.Tests/Examples/HyphenatedEnumValuesTests.cs @@ -0,0 +1,170 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Refitter.Core; +using Refitter.Tests.Build; +using Refitter.Tests.TestUtilities; +using TUnit.Core; + +namespace Refitter.Tests.Examples; + +/// +/// Tests for enum types with hyphenated or otherwise special-character values (issue #300). +/// When enum values contain characters that require [EnumMember(Value = "...")] attributes +/// (e.g. "allegro-pl", "AC_1_PHASE"), JsonStringEnumConverter cannot deserialize them because +/// it uses the C# identifier name, not the [EnumMember] value. +/// The fix is to place [JsonConverter(typeof(JsonStringEnumConverter))] on the enum TYPE +/// so users can override it via JsonSerializerOptions.Converters with a converter that +/// respects [EnumMember] (e.g. JsonStringEnumMemberConverter). +/// +public class HyphenatedEnumValuesTests +{ + private const string OpenApiSpec = @" +{ + ""swagger"": ""2.0"", + ""info"": { + ""title"": ""Hyphenated Enum API"", + ""version"": ""1.0.0"" + }, + ""host"": ""example.com"", + ""basePath"": ""/"", + ""schemes"": [ + ""https"" + ], + ""paths"": { + ""/api/offers"": { + ""get"": { + ""summary"": ""Get offers"", + ""operationId"": ""getOffers"", + ""parameters"": [ + { + ""name"": ""marketplaceId"", + ""in"": ""query"", + ""required"": false, + ""type"": ""string"", + ""enum"": [""allegro-pl"", ""allegro-cz""] + } + ], + ""responses"": { + ""200"": { + ""description"": ""Success"", + ""schema"": { + ""$ref"": ""#/definitions/MarketplaceReference"" + } + } + } + } + } + }, + ""definitions"": { + ""MarketplaceReference"": { + ""type"": ""object"", + ""properties"": { + ""id"": { + ""type"": ""string"", + ""enum"": [""allegro-pl"", ""allegro-cz""] + } + } + } + } +} +"; + + [Test] + public async Task Can_Generate_Code() + { + string generatedCode = await GenerateCode(); + generatedCode.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task Can_Build_Generated_Code() + { + string generatedCode = await GenerateCode(); + BuildHelper + .BuildCSharp(generatedCode) + .Should() + .BeTrue(); + } + + [Test] + public async Task Generated_Code_Places_JsonConverter_On_Enum_Type_Not_Property() + { + string generatedCode = await GenerateCode(); + + using (new AssertionScope()) + { + // [JsonConverter] should appear immediately before the enum type declaration + generatedCode.Should().MatchRegex( + @"\[JsonConverter\(typeof\(JsonStringEnumConverter\)\)\][\r\n\s]+public enum"); + // Enum properties should NOT have [JsonConverter] directly on them + generatedCode.Should().NotMatchRegex( + @"\[JsonConverter\(typeof\(JsonStringEnumConverter[^)]*\)\)\][\r\n\s]+public \w+Id\b"); + } + } + + [Test] + public async Task Generated_Code_Contains_EnumMember_Attributes() + { + string generatedCode = await GenerateCode(); + + using (new AssertionScope()) + { + generatedCode.Should().Contain(@"[System.Runtime.Serialization.EnumMember(Value = @""allegro-pl"")]"); + generatedCode.Should().Contain(@"[System.Runtime.Serialization.EnumMember(Value = @""allegro-cz"")]"); + } + } + + [Test] + public async Task Generated_Code_Without_InlineJsonConverters_Builds() + { + string generatedCode = await GenerateCode(inlineJsonConverters: false); + BuildHelper + .BuildCSharp(generatedCode) + .Should() + .BeTrue(); + } + + [Test] + public async Task Generated_Code_Without_InlineJsonConverters_Has_No_JsonConverter() + { + string generatedCode = await GenerateCode(inlineJsonConverters: false); + + using (new AssertionScope()) + { + generatedCode.Should().NotContain("[JsonConverter(typeof(JsonStringEnumConverter))]"); + generatedCode.Should().NotContain("[JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]"); + generatedCode.Should().NotContain("[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]"); + } + } + + private static async Task GenerateCode(bool inlineJsonConverters = true) + { + string swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(OpenApiSpec); + try + { + var settings = new RefitGeneratorSettings + { + OpenApiPath = swaggerFile, + CodeGeneratorSettings = new CodeGeneratorSettings + { + InlineJsonConverters = inlineJsonConverters + } + }; + var generator = await RefitGenerator.CreateAsync(settings); + return generator.Generate(); + } + finally + { + if (File.Exists(swaggerFile)) + { + File.Delete(swaggerFile); + } + + var directory = Path.GetDirectoryName(swaggerFile); + if (directory != null && Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } +} diff --git a/src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs b/src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs index 33426941..d06ca7c5 100644 --- a/src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs +++ b/src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs @@ -94,11 +94,28 @@ public async Task Generated_Code_Contains_JsonConverter_By_Default() using (new AssertionScope()) { - generatedCode.Should().Contain("[JsonConverter(typeof(JsonStringEnumConverter"); + generatedCode.Should().Contain("[JsonConverter(typeof(JsonStringEnumConverter))]"); + generatedCode.Should().Contain("public enum PetStatus"); generatedCode.Should().Contain("Status { get; set; }"); } } + [Test] + public async Task Generated_Code_Places_JsonConverter_On_Enum_Type_Not_Property() + { + string generatedCode = await GenerateCode(inlineJsonConverters: true); + + using (new AssertionScope()) + { + // [JsonConverter] should appear immediately before the enum type declaration + generatedCode.Should().MatchRegex( + @"\[JsonConverter\(typeof\(JsonStringEnumConverter\)\)\][\r\n\s]+public enum PetStatus"); + // The property line itself should NOT be preceded by [JsonConverter] + generatedCode.Should().NotMatchRegex( + @"\[JsonConverter\(typeof\(JsonStringEnumConverter[^)]*\)\)\][\r\n\s]+public PetStatus"); + } + } + [Test] public async Task Generated_Code_Does_Not_Contain_JsonConverter_When_Disabled() { @@ -108,6 +125,7 @@ public async Task Generated_Code_Does_Not_Contain_JsonConverter_When_Disabled() { generatedCode.Should().NotContain("[JsonConverter(typeof(JsonStringEnumConverter))]"); generatedCode.Should().NotContain("[JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]"); + generatedCode.Should().NotContain("[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]"); generatedCode.Should().Contain("Status { get; set; }"); generatedCode.Should().Contain("public enum PetStatus"); }