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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/RicoSuter/NSwag/wiki/Templates>
--generate-authentication-header None Controls generation of Authorization header support.
Expand Down
18 changes: 16 additions & 2 deletions src/Refitter.Core/RefitGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
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));

Expand Down Expand Up @@ -175,7 +180,7 @@

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

Check warning on line 183 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 183 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 183 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 183 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 183 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 @@ -246,7 +251,7 @@
generatedFiles = generatedFiles
.Select(f => f with
{
Content = ContractTypeSuffixApplier.ApplySuffix(f.Content, settings.ContractTypeSuffix)

Check warning on line 254 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 254 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 254 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 254 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 254 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 @@ -258,9 +263,18 @@
{
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")

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enum-attribute insertion uses a hard-coded "\n" and a fully-qualified attribute name. This can introduce mixed line endings in otherwise CRLF output, and it also makes contracts-only / GenerateMultipleFiles output noisier because the later namespace-stripping step doesn’t run there. Consider emitting the attribute using the same line ending as the input (or Environment.NewLine) and using the non-qualified attribute/type names when the relevant using is already present.

Suggested change
"$1[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]\n$1$2")
"$1[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]" +
Environment.NewLine +
"$1$2")

Copilot uses AI. Check for mistakes.
.TrimEnd();
}

// InlineJsonConverters = false: remove all [JsonConverter(typeof(JsonStringEnumConverter))] attributes.
return JsonStringEnumConverterAttributeRegex
.Replace(contracts, string.Empty)
.TrimEnd();
Expand Down
12 changes: 9 additions & 3 deletions src/Refitter.Core/Settings/CodeGeneratorSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,17 @@ Gets or sets a value indicating whether named/referenced any schemas should be i
public IPropertyNameGenerator? PropertyNameGenerator { get; set; }

/// <summary>
/// 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 <c>[JsonConverter(typeof(JsonStringEnumConverter))]</c> attribute is placed
/// on the enum type declaration instead of on individual enum properties, allowing users to override the
/// converter via <c>JsonSerializerOptions.Converters</c> (e.g. to use <c>JsonStringEnumMemberConverter</c>
/// for enums whose values contain hyphens or other special characters via <c>[EnumMember]</c>).
/// When set to false, no <c>[JsonConverter]</c> attribute is emitted at all.
/// </summary>
[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;

Expand Down
170 changes: 170 additions & 0 deletions src/Refitter.Tests/Examples/HyphenatedEnumValuesTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
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");
Comment on lines +99 to +101

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex used to assert that properties don’t have [JsonConverter] is very specific (it assumes the attribute is directly adjacent to the property declaration). If other attributes are emitted between them, the test may pass even though the property-level converter is still present. Consider broadening the regex to allow intervening attribute lines or asserting globally that no JsonStringEnumConverter attribute is applied to properties in the generated output.

Suggested change
// Enum properties should NOT have [JsonConverter] directly on them
generatedCode.Should().NotMatchRegex(
@"\[JsonConverter\(typeof\(JsonStringEnumConverter[^)]*\)\)\][\r\n\s]+public \w+Id\b");
// Enum properties should NOT have [JsonConverter] applied to them (only enum types may have it)
generatedCode.Should().NotMatchRegex(
@"\[JsonConverter\(typeof\(JsonStringEnumConverter[^)]*\)\)\)[\s\S]*?public\s+(?!enum)\w+\s+\w*Id\b");

Copilot uses AI. Check for mistakes.
}
}

[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<string> 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);
}
}
}
}
20 changes: 19 additions & 1 deletion src/Refitter.Tests/Examples/InlineJsonConvertersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment on lines +113 to +115

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative regex assertion can miss property-level [JsonConverter] if NSwag ever emits additional attributes between [JsonConverter] and the property declaration. To make this test robust, consider matching the full attribute block for the specific property (allowing other attributes in between) or asserting that no JsonStringEnumConverter attribute appears within any property’s attribute list.

Suggested change
// The property line itself should NOT be preceded by [JsonConverter]
generatedCode.Should().NotMatchRegex(
@"\[JsonConverter\(typeof\(JsonStringEnumConverter[^)]*\)\)\][\r\n\s]+public PetStatus");
// The property line itself should NOT be preceded by [JsonConverter], even if other attributes are present
generatedCode.Should().NotMatchRegex(
@"\[JsonConverter\(typeof\(JsonStringEnumConverter[^)]*\)\)\](?:[\r\n\s]*\[[^\]]*\])*[\r\n\s]*public PetStatus");

Copilot uses AI. Check for mistakes.
}
}

[Test]
public async Task Generated_Code_Does_Not_Contain_JsonConverter_When_Disabled()
{
Expand All @@ -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");
}
Expand Down
Loading