Skip to content

Commit c7c0bf7

Browse files
alexaka1Copilotcursoragent
authored
fix(csharp): use numeric JSON operations for numeric enum converters (OpenAPITools#22917)
* fix(csharp): use numeric JSON operations for integer enum converters 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> * fix: handle double/float/decimal enums correctly in JSON converters 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> * test: use actual int64 values for LongEnum test spec 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> * fix: eliminate extra blank lines in generated enum converters 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> * fix: validate integer/long enum values during deserialization 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> * fix: remove InvariantCulture from enum ToString calls in Read methods 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> * fix: nullable enum converters - use WriteNullValue() and handle null 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> * fix: emit `: long` for int64 enum declarations and add test assertions 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> * fix: add space before colon in enum type declaration per C# convention 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> * fix: delegate enum property deserialization to JsonSerializer.Deserialize Model-level enum property reading now delegates to the enum's own registered JsonConverter via JsonSerializer.Deserialize<EnumType>() instead of inlining reader.GetString()/GetInt32() calls. This ensures the already-fixed enum converters handle all type branching correctly. Also propagate isNumeric/isInteger/isLong/isFloat/isDouble/isDecimal flags from referenced enum models to properties in patchProperty(), so the Write path correctly uses WriteNumber vs WriteString. Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com> * fix: use ValueConverter.ToJsonValue for non-inner numeric enum properties The WriteNumber path in JsonConverter.mustache was missing the 'ValueConverter.' prefix for non-inner enum properties, generating broken calls like `EnumTypeToJsonValue(...)` instead of `EnumTypeValueConverter.ToJsonValue(...)`. This caused CS0103 compile errors in CI for models with numeric enum properties defined as $ref (not inline). Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com> * fix: don't propagate isString from enum models to properties Setting property.isString on enum ref properties caused duplicate WriteString statements in anyOf models (e.g., IconsSizeParameter). String enum properties are already handled through the isEnum template path, not the isString path, so propagating isString is both unnecessary and harmful. Co-authored-by: alexaka1 <22166651+alexaka1@users.noreply.github.com> * remove AI comment * fix(csharp): refresh numeric enum converter templates Co-authored-by: Alex Martossy <github@mail.martossy.hu> * test(csharp): cover numeric enum formats Co-authored-by: Alex Martossy <github@mail.martossy.hu> * fix(csharp): classify decimal enum models Co-authored-by: Alex Martossy <github@mail.martossy.hu> * fix(csharp): preserve numeric enum precision Co-authored-by: Alex Martossy <github@mail.martossy.hu> * chore(csharp): regenerate generichost enum samples Co-authored-by: Alex Martossy <github@mail.martossy.hu> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+copilot@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent f7191ab commit c7c0bf7

385 files changed

Lines changed: 5614 additions & 3668 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,11 @@ public ModelsMap postProcessModels(ModelsMap objs) {
594594
}
595595
}
596596
}
597+
598+
// C# maps an unformatted number schema to decimal, but CodegenModel does not set isDecimal.
599+
if (cm.isEnum && cm.isNumeric && !cm.isInteger && !cm.isLong && !cm.isFloat && !cm.isDouble) {
600+
cm.isDecimal = true;
601+
}
597602
}
598603
// process enum in models
599604
return postProcessModelsEnum(objs);
@@ -853,6 +858,15 @@ protected void patchProperty(Map<String, CodegenModel> enumRefs, CodegenModel mo
853858

854859
// We do these after updateCodegenPropertyEnum to avoid generalities that don't mesh with C#.
855860
property.isPrimitiveType = true;
861+
862+
// Propagate numeric type flags from the referenced enum model so templates
863+
// can branch on isNumeric/isInteger/isLong/isFloat/isDouble/isDecimal.
864+
property.isNumeric = refModel.isNumeric;
865+
property.isInteger = refModel.isInteger;
866+
property.isLong = refModel.isLong;
867+
property.isFloat = refModel.isFloat;
868+
property.isDouble = refModel.isDouble;
869+
property.isDecimal = refModel.isDecimal;
856870
}
857871

858872
this.patchPropertyIsInherited(model, property);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{#vendorExtensions.x-enum-byte}}GetByte{{/vendorExtensions.x-enum-byte}}{{^vendorExtensions.x-enum-byte}}{{#allowableValues}}{{#enumVars}}{{#-first}}{{#isNumeric}}{{#isLong}}GetInt64{{/isLong}}{{#isFloat}}GetSingle{{/isFloat}}{{#isDouble}}GetDouble{{/isDouble}}{{#isDecimal}}GetDecimal{{/isDecimal}}{{^isLong}}{{^isFloat}}{{^isDouble}}{{^isDecimal}}GetInt32{{/isDecimal}}{{/isDouble}}{{/isFloat}}{{/isLong}}{{/isNumeric}}{{/-first}}{{/enumVars}}{{/allowableValues}}{{/vendorExtensions.x-enum-byte}}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{{#allowableValues}}{{#enumVars}}{{#-first}}{{#isString}}{{^isNumeric}}string{{/isNumeric}}{{/isString}}{{#isNumeric}}{{#isLong}}long{{/isLong}}{{#isFloat}}float{{/isFloat}}{{#isDouble}}double{{/isDouble}}{{#isDecimal}}decimal{{/isDecimal}}{{^isLong}}{{^isFloat}}{{^isDouble}}{{^isDecimal}}int{{/isDecimal}}{{/isDouble}}{{/isFloat}}{{/isLong}}{{/isNumeric}}{{/-first}}{{/enumVars}}{{/allowableValues}}
1+
{{#vendorExtensions.x-enum-byte}}byte{{/vendorExtensions.x-enum-byte}}{{^vendorExtensions.x-enum-byte}}{{#allowableValues}}{{#enumVars}}{{#-first}}{{#isString}}{{^isNumeric}}string{{/isNumeric}}{{/isString}}{{#isNumeric}}{{#isLong}}long{{/isLong}}{{#isFloat}}float{{/isFloat}}{{#isDouble}}double{{/isDouble}}{{#isDecimal}}decimal{{/isDecimal}}{{^isLong}}{{^isFloat}}{{^isDouble}}{{^isDecimal}}int{{/isDecimal}}{{/isDouble}}{{/isFloat}}{{/isLong}}{{/isNumeric}}{{/-first}}{{/enumVars}}{{/allowableValues}}{{/vendorExtensions.x-enum-byte}}

modules/openapi-generator/src/main/resources/csharp/libraries/generichost/JsonConverter.mustache

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -235,31 +235,48 @@
235235
{{/isDateTime}}
236236
{{#isEnum}}
237237
{{^isMap}}
238+
{{#isInnerEnum}}
239+
{{#vendorExtensions.x-enum-byte}}
240+
if (utf8JsonReader.TokenType == JsonTokenType.Null)
241+
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}null);
242+
else
243+
{
244+
string {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue = utf8JsonReader.{{>EnumJsonReaderMethod}}().ToString(System.Globalization.CultureInfo.InvariantCulture);
245+
{{classname}}.{{{datatypeWithEnum}}}? {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}Value = {{classname}}.{{{datatypeWithEnum}}}FromStringOrDefault({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue);
246+
if ({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}Value == null)
247+
throw new JsonException();
248+
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}Value);
249+
}
250+
{{/vendorExtensions.x-enum-byte}}
251+
{{^vendorExtensions.x-enum-byte}}
238252
{{#isNumeric}}
239-
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}utf8JsonReader.TokenType == JsonTokenType.Null ? ({{#isInnerEnum}}{{classname}}.{{/isInnerEnum}}{{{datatypeWithEnum}}}?)null : ({{#isInnerEnum}}{{classname}}.{{/isInnerEnum}}{{{datatypeWithEnum}}})utf8JsonReader.Get{{#vendorExtensions.x-unsigned}}U{{/vendorExtensions.x-unsigned}}Int32());
253+
if (utf8JsonReader.TokenType == JsonTokenType.Null)
254+
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}null);
255+
else
256+
{
257+
string {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue = utf8JsonReader.{{>EnumJsonReaderMethod}}().ToString(System.Globalization.CultureInfo.InvariantCulture);
258+
{{classname}}.{{{datatypeWithEnum}}}? {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}Value = {{classname}}.{{{datatypeWithEnum}}}FromStringOrDefault({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue);
259+
if ({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}Value == null)
260+
throw new JsonException();
261+
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}Value);
262+
}
240263
{{/isNumeric}}
241264
{{^isNumeric}}
242265
string{{nrt?}} {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue = utf8JsonReader.GetString();
243266
{{! A nullable enum may be explicitly null in the payload; still mark the Option as set so a present-but-null value is not rejected as missing. }}
244-
{{^isInnerEnum}}
245-
{{#isNullable}}
246-
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue == null ? null : {{{datatypeWithEnum}}}ValueConverter.FromStringOrDefault({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue));
247-
{{/isNullable}}
248-
{{^isNullable}}
249-
if ({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue != null)
250-
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}{{{datatypeWithEnum}}}ValueConverter.FromStringOrDefault({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue));
251-
{{/isNullable}}
252-
{{/isInnerEnum}}
253-
{{#isInnerEnum}}
254267
{{#isNullable}}
255268
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue == null ? null : {{classname}}.{{{datatypeWithEnum}}}FromStringOrDefault({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue));
256269
{{/isNullable}}
257270
{{^isNullable}}
258271
if ({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue != null)
259272
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}{{classname}}.{{{datatypeWithEnum}}}FromStringOrDefault({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}RawValue));
260273
{{/isNullable}}
261-
{{/isInnerEnum}}
262274
{{/isNumeric}}
275+
{{/vendorExtensions.x-enum-byte}}
276+
{{/isInnerEnum}}
277+
{{^isInnerEnum}}
278+
{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{>OptionProperty}}JsonSerializer.Deserialize<{{{datatypeWithEnum}}}?>(ref utf8JsonReader, jsonSerializerOptions));
279+
{{/isInnerEnum}}
263280
{{/isMap}}
264281
{{/isEnum}}
265282
{{#isUuid}}
@@ -524,7 +541,7 @@
524541
{{#isEnum}}
525542
{{#isNumeric}}
526543
{{#lambda.copyText}}
527-
writer.WriteNumber("{{baseName}}", {{#isInnerEnum}}{{classname}}.{{/isInnerEnum}}{{{datatypeWithEnum}}}ToJsonValue({{#lambda.camelcase_sanitize_param}}{{classname}}{{/lambda.camelcase_sanitize_param}}.{{name}}{{^required}}Option.Value{{#vendorExtensions.x-is-value-type}}{{nrt!}}.Value{{/vendorExtensions.x-is-value-type}}{{/required}}{{#required}}{{#isNullable}}.Value{{/isNullable}}{{/required}}));
544+
writer.WriteNumber("{{baseName}}", {{#isInnerEnum}}{{classname}}.{{{datatypeWithEnum}}}ToJsonValue{{/isInnerEnum}}{{^isInnerEnum}}{{{datatypeWithEnum}}}ValueConverter.ToJsonValue{{/isInnerEnum}}({{#lambda.camelcase_sanitize_param}}{{classname}}{{/lambda.camelcase_sanitize_param}}.{{name}}{{^required}}Option.Value{{#vendorExtensions.x-is-value-type}}{{nrt!}}.Value{{/vendorExtensions.x-is-value-type}}{{/required}}{{#required}}{{#isNullable}}.Value{{/isNullable}}{{/required}}));
528545
{{/lambda.copyText}}
529546
{{#lambda.indent3}}
530547
{{>WriteProperty}}{{! prevent indent}}

0 commit comments

Comments
 (0)