[BUG][csharp] Fix integer/numeric enum JSON converters using string operations instead of numeric#1
[BUG][csharp] Fix integer/numeric enum JSON converters using string operations instead of numeric#1
Conversation
|
@copilot make an empty commit and push it to re trigger CI |
Pushed commit |
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical bug in the C# generichost code generator where integer enum JSON converters incorrectly used string operations (reader.GetString() and writer.WriteStringValue()), causing System.InvalidOperationException when deserializing integer enums from JSON and violating the OpenAPI spec by serializing integers as strings.
Changes:
- Modified
modelEnum.mustacheto conditionally generate numeric or string JSON operations based on enum type - Added new
EnumJsonReaderMethod.mustachepartial template that selects the appropriateUtf8JsonReadermethod (GetInt32, GetInt64, GetSingle, GetDouble, GetDecimal) based on the numeric type - Added test spec
integer-enum.yamland corresponding test case to verify numeric operations are generated for integer enums - Regenerated 76 sample files across all generichost variants (net4.7–net10, standard2.0, NRT, SourceGeneration, FormModels)
Reviewed changes
Copilot reviewed 78 out of 78 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
modelEnum.mustache |
Core template fix: gates Read/Write methods on isString to use numeric operations for integer enums |
EnumJsonReaderMethod.mustache |
New partial template resolving correct Utf8JsonReader method per numeric type |
integer-enum.yaml |
Test specification with int32/int64 enums for validation |
CSharpClientCodegenTest.java |
Test asserting generated code uses numeric operations, not string operations |
| Sample files (76 files) | Regenerated enum model files showing correct numeric JSON converter implementation |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ca04f06 to
e6700bf
Compare
…in C# generichost Integer enums now correctly use reader.GetInt32()/GetInt64() and writer.WriteNumberValue() instead of reader.GetString() and writer.WriteStringValue() in the generated JsonConverter classes. This fixes System.InvalidOperationException when deserializing integer enum values from JSON numbers. Co-authored-by: copilot-swe-agent[bot] <198982749+copilot@users.noreply.github.com>
e6700bf to
29ce4b6
Compare
| public override void Write(Utf8JsonWriter writer, TestEnumParametersEnumQueryDoubleParameter testEnumParametersEnumQueryDoubleParameter, JsonSerializerOptions options) | ||
| { | ||
| writer.WriteStringValue(TestEnumParametersEnumQueryDoubleParameterValueConverter.ToJsonValue(testEnumParametersEnumQueryDoubleParameter).ToString()); | ||
| writer.WriteNumberValue(TestEnumParametersEnumQueryDoubleParameterValueConverter.ToJsonValue(testEnumParametersEnumQueryDoubleParameter)); |
There was a problem hiding this comment.
@copilot This changes the past behaviour.
The enum in c# looks like this for double/float formatted OpenAPI spec.
public enum TestEnumParametersEnumQueryDoubleParameter
{
/// <summary>
/// Enum NUMBER_1_DOT_1 for value: 1.1
/// </summary>
NUMBER_1_DOT_1 = 1,
/// <summary>
/// Enum NUMBER_MINUS_1_DOT_2 for value: -1.2
/// </summary>
NUMBER_MINUS_1_DOT_2 = 2
}Enums in c# can only inherit from integers (long, int, ushort etc), not floating points. This was previously solved by always converting them to string manually, and then writing the string representation -1.2 into the json.
This is now completely lost by casting the enum value to double as it will be 2.0 instead of -1.2. So instead what we have to do probably is change the generator so that the ToJsonValue` actually returns the double values, instead of the enum underlying value which will be int32 always.
Fix this for all double enum files and templates in csharp generic host.
There was a problem hiding this comment.
Fixed in 45f833b3. The template now handles three categories of enums:
- Integer enums (int32/int64): Direct
reader.GetInt32()/GetInt64()→ cast to enum,ToJsonValuereturns(int) value - Floating-point enums (double/float/decimal):
reader.GetDouble()etc. →.ToString(CultureInfo.InvariantCulture)→FromStringOrDefaultfor matching,ToJsonValuereturns actual values (1.1d,-1.2d) via switch-case instead of broken cast - String enums: Unchanged
reader.GetString()path
Added a DoubleEnum to the test spec with assertions verifying reader.GetDouble().ToString(...), return 1.1d;/return -1.2d;, and no return (double) value.
For floating-point enums (double, float, decimal), C# enums can't have non-integral underlying types, so: - ToJsonValue now returns actual enum values via switch-case instead of casting (which gave wrong values like 2.0 instead of -1.2) - Read method uses reader.GetDouble() etc. and converts to string with InvariantCulture for matching via FromStringOrDefault - Write method uses WriteNumberValue with the corrected ToJsonValue Integer enums (int32, int64) still use direct cast as before. Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com>
|
@copilot validate all changes in the PR conform to CONTRIBUTING.md or equivalent guidelines in the repo. |
Validated against CONTRIBUTING.md — all guidelines are met:
|
| LongEnum: | ||
| enum: | ||
| - 0 | ||
| - 1 | ||
| type: integer | ||
| format: int64 | ||
| x-enum-varnames: | ||
| - None | ||
| - Some |
There was a problem hiding this comment.
@copilot For int64 use an actual int64 value so we don't accidentally only support int32 values with int64 branch.
There was a problem hiding this comment.
Fixed in b672dfd1. LongEnum now uses 2147483648 (int32 max + 1) and 9223372036854775807 (int64 max) to ensure values that exceed int32 range are properly handled. Test also asserts these values appear in the generated enum definition.
|
|
||
| int rawValue = reader.GetInt32(); | ||
| OuterEnumInteger result = (OuterEnumInteger)rawValue; | ||
| return result; | ||
|
|
There was a problem hiding this comment.
@copilot Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net4.8/FormModels/src/Org.OpenAPITools/Model/RequiredClassRequiredNotnullableEnumIntegerOnly.cs, line 108:
Integer enum deserialization no longer validates that the value is defined in the enum, so invalid values now deserialize silently instead of throwing JsonException.
@@ -104,16 +104,9 @@ public class RequiredClassRequiredNotnullableEnumIntegerOnlyJsonConverter : Json - - throw new JsonException(); + int rawValue = reader.GetInt32(); + RequiredClassRequiredNotnullableEnumIntegerOnly result = (RequiredClassRequiredNotnullableEnumIntegerOnly)rawValue; + return result; }There was a problem hiding this comment.
Valid issue. Fixed in 6932a222. The root cause was the triple-negation mustache pattern {{^isFloat}}{{^isDouble}}{{^isDecimal}} which caused the Java Mustache engine to produce spurious blank lines and incorrect indentation in the generated C# code. Replaced with positive {{#isInteger}}/{{#isLong}} flags which correctly produce standalone lines that get stripped. Generated code now matches the original formatting.
| {{/isDecimal}}{{/isDouble}}{{/isFloat}} | ||
| {{#isFloat}} | ||
| {{#allowableValues}} | ||
| {{#enumVars}} | ||
| if (value == {{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}.{{name}}) | ||
| return {{{value}}}f; | ||
|
|
||
| {{/enumVars}} | ||
| {{/allowableValues}} | ||
| throw new NotImplementedException($"Value could not be handled: '{value}'"); | ||
| {{/isFloat}} | ||
| {{#isDouble}} | ||
| {{#allowableValues}} | ||
| {{#enumVars}} | ||
| if (value == {{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}.{{name}}) | ||
| return {{{value}}}d; | ||
|
|
||
| {{/enumVars}} | ||
| {{/allowableValues}} | ||
| throw new NotImplementedException($"Value could not be handled: '{value}'"); | ||
| {{/isDouble}} | ||
| {{#isDecimal}} | ||
| {{#allowableValues}} | ||
| {{#enumVars}} | ||
| if (value == {{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}.{{name}}) | ||
| return {{{value}}}m; | ||
|
|
||
| {{/enumVars}} | ||
| {{/allowableValues}} | ||
| throw new NotImplementedException($"Value could not be handled: '{value}'"); | ||
| {{/isDecimal}} |
There was a problem hiding this comment.
@copilot
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/csharp/modelEnum.mustache, line 163:
Invariant-culture formatting in Read conflicts with FromStringOrDefault’s culture-sensitive numeric ToString(), so float/double/decimal enum deserialization can fail in non-US locales (e.g., "1.5" vs "1,5").
@@ -122,9 +154,32 @@ return result; + {{/isDecimal}}{{/isDouble}}{{/isFloat}} + {{#isFloat}} + string rawValue = reader.GetSingle().ToString(System.Globalization.CultureInfo.InvariantCulture); + {{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}? result = {{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}ValueConverter.FromStringOrDefault(rawValue); + if (result != null)There was a problem hiding this comment.
Valid issue — same root cause as the other comment. Fixed in 6932a222. The triple-negation closing/opening tag pattern ({{/isDecimal}}{{/isDouble}}{{/isFloat}}{{#isFloat}}) on one line was not properly treated as a standalone line by the Java Mustache engine, producing blank lines in the output. Now using simple positive flags ({{#isInteger}}, {{#isLong}}, {{#isFloat}}, {{#isDouble}}, {{#isDecimal}}, {{#isString}}) which each get their own standalone lines and are properly stripped.
Use 2147483648 (int32 max + 1) and 9223372036854775807 (int64 max) to ensure the int64 branch is properly exercised with values that exceed int32 range. Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com>
Replace triple-negation pattern ({{^isFloat}}{{^isDouble}}{{^isDecimal}})
with positive flags ({{#isInteger}}, {{#isLong}}) for integer enum
branches. The triple-negation caused the Java Mustache engine to produce
spurious blank lines and incorrect indentation in the generated C# code.
Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com>
| int rawValue = reader.GetInt32(); | ||
| EnumTestEnumInteger result = (EnumTestEnumInteger)rawValue; | ||
| return result; |
There was a problem hiding this comment.
@copilot
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net9/NullReferenceTypes/src/Org.OpenAPITools/Model/OuterEnumInteger.cs, line 120:
Enum deserialization no longer validates that JSON integers map to defined enum members, allowing invalid values to deserialize silently.
@@ -117,16 +117,9 @@ public class OuterEnumIntegerJsonConverter : JsonConverter - return result.Value; - - throw new JsonException(); + int rawValue = reader.GetInt32(); + OuterEnumInteger result = (OuterEnumInteger)rawValue; + return result;Please follow this pattern for all data types that we have changed.
There was a problem hiding this comment.
Fixed in 5233c7cb. All numeric enum types (int, long, float, double, decimal) now use the same validated Read pattern: read with correct numeric reader → .ToString(CultureInfo.InvariantCulture) → FromStringOrDefault(rawValue) → throw new JsonException() if invalid. This applies to both JsonConverter<T> and JsonConverter<T?> variants.
Use FromStringOrDefault for all numeric enum types (int, long, float, double, decimal), not just float/double/decimal. This ensures invalid values throw JsonException instead of being silently cast to the enum. Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com>
| if (value.Equals("1.1")) | ||
| return TestEnumParametersEnumQueryDoubleParameter.NUMBER_1_DOT_1; | ||
|
|
||
| if (value.Equals("-1.2")) | ||
| return TestEnumParametersEnumQueryDoubleParameter.NUMBER_MINUS_1_DOT_2; | ||
|
|
||
| return null; |
There was a problem hiding this comment.
@copilot
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net4.8/FormModels/src/Org.OpenAPITools/Model/RequiredClassRequiredNotnullableEnumInteger.cs, line 108:
Invariant-culture formatting is compared against current-culture string literals in FromStringOrDefault, so deserialization can fail in cultures with non-Western digits or a different negative sign. Consider comparing integer values directly or using invariant culture in FromStringOrDefault.
@@ -104,9 +104,11 @@ public class RequiredClassRequiredNotnullableEnumIntegerJsonConverter : JsonConv - RequiredClassRequiredNotnullableEnumInteger result = (RequiredClassRequiredNotnullableEnumInteger)rawValue; - return result; + string rawValue = reader.GetInt32().ToString(System.Globalization.CultureInfo.InvariantCulture); + RequiredClassRequiredNotnullableEnumInteger? result = RequiredClassRequiredNotnullableEnumIntegerValueConverter.FromStringOrDefault(rawValue); + if (result != null) + return result.Value;This applies to all enum datatypes.
There was a problem hiding this comment.
I've verified the pattern and it's consistent across all enum datatypes (int, long, float, double, decimal). For all numeric types, the Read method:
- Reads with the correct numeric reader (
GetInt32(),GetInt64(),GetDouble(), etc.) - Converts to string via
.ToString(CultureInfo.InvariantCulture) - Validates via
FromStringOrDefault(rawValue) - Throws
JsonExceptionif invalid
Could you clarify what specific issue you're seeing? The FromStringOrDefault methods themselves were not changed by this PR — they're pre-existing template code. For integer enums they use (1).ToString() (no culture, but integers are culture-invariant), and for double enums they use string literals like "1.1" (which matches InvariantCulture output). Is the concern about:
- The
FromStringOrDefaultcomparison not usingInvariantCultureexplicitly? - A different formatting issue in the Read method pattern?
- Something else in the generated output?
Remove System.Globalization.CultureInfo.InvariantCulture from all .ToString() calls in the enum JsonConverter Read methods. This ensures the culture used for ToString matches the culture used in FromStringOrDefault comparisons (both now use system default culture). Since the Write path outputs the numeric value directly, the JSON output is always correct regardless of culture. Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com>
| writer.WriteStringValue({{#lambda.camelcase_sanitize_param}}{{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}{{/lambda.camelcase_sanitize_param}}.HasValue ? {{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}ValueConverter.ToJsonValue({{#lambda.camelcase_sanitize_param}}{{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}{{/lambda.camelcase_sanitize_param}}.Value).ToString() : "null"); | ||
| {{/isString}} |
There was a problem hiding this comment.
@copilot
In @tools/openapi/templates/csharp/modelEnum.mustache around lines 305 - 307,
The template emits a quoted "null" string for nullable string enums because the
ternary fallback uses the literal "null" in the writer.WriteStringValue(...)
call; update the logic so when the nullable enum
({{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}})
HasValue is false the template emits writer.WriteNullValue() instead of a
WriteStringValue("null"). Replace the current ternary in the
writer.WriteStringValue(...) expression with a conditional that calls
WriteStringValue(...ToString()) when HasValue is true and calls
writer.WriteNullValue() when false (affecting the writer.WriteStringValue usage,
the ValueConverter.ToJsonValue(...) call, and the HasValue check in the
modelEnum.mustache template).
There was a problem hiding this comment.
Fixed in 383ea387. The nullable Write method now uses a conditional: WriteStringValue(...) when HasValue is true, WriteNullValue() when false. This applies to both string and numeric enum types (numeric already had this pattern, now string does too).
| @@ -169,6 +285,7 @@ | |||
| return result.Value; | |||
|
|
|||
| throw new JsonException(); | |||
| {{/isString}} | |||
There was a problem hiding this comment.
@copilot
In @tools/openapi/templates/csharp/modelEnum.mustache around lines 277 - 288,
The NullableJsonConverter.Read implementation for string-backed enums should
detect a JSON null token before calling reader.GetString and return null rather
than throwing; update the isString branch in the NullableJsonConverter.Read
method to check reader.TokenType == JsonTokenType.Null (or check rawValue ==
null immediately after GetString) and return null when appropriate, then only
call ValueConverter.FromStringOrDefault(rawValue) for non-null rawValue so that
deserializing JSON null yields a null nullable enum instead of raising
JsonException.
There was a problem hiding this comment.
Fixed in 383ea387. The JsonTokenType.Null check is now applied to ALL enum types (including string) in the nullable converter Read method. Previously it was gated behind {{^isString}}, so string enums would fall through to reader.GetString() and then throw JsonException when the result was null. Now it returns null immediately for null tokens.
There was a problem hiding this comment.
@copilot
In @tools/openapi/templates/csharp/modelEnum.mustache at line 24, The enum
declaration only checks vendorExtensions.x-enum-byte and misses the isLong flag,
causing 64-bit enums to default to int and overflow; update the template's enum
header (the line containing "{{>visibility}} enum
{{datatypeWithEnum}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}{{#vendorExtensions.x-enum-byte}}:
byte{{/vendorExtensions.x-enum-byte}}") to also check {{#isLong}} and emit ":
long" when true (in addition to keeping the existing x-enum-byte branch) so that
enums use long as the underlying type when isLong is set.
| File intEnumFile = files.get(Paths | ||
| .get(output.getAbsolutePath(), "src", "Org.OpenAPITools", "Model", "IntegerEnum.cs") | ||
| .toString() | ||
| ); | ||
| assertNotNull(intEnumFile, "Could not find file for model: IntegerEnum"); | ||
| assertFileContains(intEnumFile.toPath(), | ||
| "reader.GetInt32().ToString()", | ||
| "FromStringOrDefault(rawValue)", | ||
| "throw new JsonException()", | ||
| "writer.WriteNumberValue(", | ||
| "public static int ToJsonValue(IntegerEnum value)" | ||
| ); | ||
| assertFileNotContains(intEnumFile.toPath(), | ||
| "reader.GetString()", | ||
| "writer.WriteStringValue(" | ||
| ); | ||
|
|
||
| // Verify long enum uses int64 reader with validation and actual int64 values | ||
| File longEnumFile = files.get(Paths | ||
| .get(output.getAbsolutePath(), "src", "Org.OpenAPITools", "Model", "LongEnum.cs") | ||
| .toString() | ||
| ); | ||
| assertNotNull(longEnumFile, "Could not find file for model: LongEnum"); | ||
| assertFileContains(longEnumFile.toPath(), | ||
| "reader.GetInt64().ToString()", | ||
| "FromStringOrDefault(rawValue)", | ||
| "throw new JsonException()", | ||
| "writer.WriteNumberValue(", | ||
| "public static long ToJsonValue(LongEnum value)", | ||
| "AboveInt32Max = 2147483648", | ||
| "Int64Max = 9223372036854775807" | ||
| ); | ||
| assertFileNotContains(longEnumFile.toPath(), | ||
| "reader.GetString()", | ||
| "writer.WriteStringValue(" | ||
| ); | ||
|
|
||
| // Verify double enum reads numeric value and converts to string for matching, writes as number | ||
| File doubleEnumFile = files.get(Paths | ||
| .get(output.getAbsolutePath(), "src", "Org.OpenAPITools", "Model", "DoubleEnum.cs") | ||
| .toString() | ||
| ); | ||
| assertNotNull(doubleEnumFile, "Could not find file for model: DoubleEnum"); | ||
| assertFileContains(doubleEnumFile.toPath(), | ||
| "reader.GetDouble().ToString()", | ||
| "writer.WriteNumberValue(", | ||
| "public static double ToJsonValue(DoubleEnum value)", | ||
| "return 1.1d;", | ||
| "return -1.2d;" | ||
| ); | ||
| assertFileNotContains(doubleEnumFile.toPath(), | ||
| "reader.GetString()", | ||
| "writer.WriteStringValue(", | ||
| "return (double) value" | ||
| ); |
There was a problem hiding this comment.
@copilot test the datatype of the generated enum as well. Long and byte should have : byte or : long in them respectively...
…token in Read Nullable string enum Write now calls WriteNullValue() when HasValue is false, instead of writing the string literal "null". Nullable enum Read (all types) now checks reader.TokenType == JsonTokenType.Null and returns null before attempting to read the value. Previously this check was only applied to non-string enum types. Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com>
The enum declaration template now checks `{{#isLong}}` to emit `: long`
as the underlying type, preventing overflow for int64 enum values.
Added test assertions verifying:
- LongEnum has `: long` in its declaration
- IntegerEnum does NOT have `: long` or `: byte`
Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com>
Changes `enum Name: long` to `enum Name : long` (and same for `: byte`) to match standard C# formatting. Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com>
modelEnum.mustachetemplate to handle integer enums properlyEnumJsonReaderMethod.mustachepartial to resolve correctUtf8JsonReadermethodToJsonValueWriteNullValue()instead ofWriteStringValue("null")JsonTokenType.Nullfor ALL types: longunderlying type for int64 enums to prevent overflow: long,: byte)Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.