From c847eeee74a0854d8fb3f895d3ef77055f69d954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20W=C3=B3jcik?= Date: Wed, 22 Apr 2026 09:39:58 +0200 Subject: [PATCH 1/2] Changes in copy-constants.php & regeneration of SDK + tests --- sdks/dotnet/bin/copy-constants.php | 180 ++++++++++++++++-- .../Model/SubFormFieldRuleAction.cs | 59 +++++- .../SubFormFieldsPerDocumentDateSigned.cs | 2 +- .../Model/SubFormFieldsPerDocumentDropdown.cs | 2 +- .../SubFormFieldsPerDocumentHyperlink.cs | 2 +- .../Model/SubFormFieldsPerDocumentText.cs | 2 +- .../SubFormFieldsPerDocumentTextMerge.cs | 2 +- 7 files changed, 222 insertions(+), 27 deletions(-) diff --git a/sdks/dotnet/bin/copy-constants.php b/sdks/dotnet/bin/copy-constants.php index 885380501..d99fb7960 100755 --- a/sdks/dotnet/bin/copy-constants.php +++ b/sdks/dotnet/bin/copy-constants.php @@ -13,17 +13,26 @@ }); /** - * Between openapi-generator v7.8.0 and v7.12.0 a change was made to the way - * a few generators create constant names from values. The original way was - * actually broken. For example "change-field-visibility" would generate a - * constant name of "TYPE_FIELD_VISIBILITY", dropping the "change" part. + * Post-generation patch for SubFormFieldRuleAction.cs. * - * The fix now generates the correct name, "TYPE_CHANGE_FIELD_VISIBILITY". - * However, the fix also gets rid of the previous (incorrect) constant names, - * making the fix a BC break. + * Between openapi-generator v7.8.0 and v7.12.0 a change was made to the + * way a few generators create constant names from values. The original + * way was broken: "change-field-visibility" generated a constant named + * "TYPE_FIELD_VISIBILITY", dropping the "change" part. The fix generates + * the correct name, "TYPE_CHANGE_FIELD_VISIBILITY", but also removes the + * previous (incorrect) names, which is a BC break. * - * This simple script just adds the old constant names back, alongside the new - * ones. + * To preserve source compatibility this script adds the old names back + * as aliases (FieldVisibility = ChangeFieldVisibility, etc.). The aliases + * share a numeric value with their canonical counterparts, which breaks + * StringEnumConverter: Enum.GetName is not guaranteed to pick the + * canonical name for ties, and Newtonsoft rejects duplicate + * [EnumMember] values on the same enum. To make the aliases serialize + * to the correct OpenAPI wire values under all circumstances, this + * script also: + * - swaps StringEnumConverter for a dedicated TypeEnumJsonConverter + * that maps by underlying numeric value, and + * - injects that nested converter class into SubFormFieldRuleAction. */ class CopyConstants { @@ -32,6 +41,57 @@ public function run(): void $file = __DIR__ . '/../src/Dropbox.Sign/Model/SubFormFieldRuleAction.cs'; $contents = file_get_contents($file); + $contents = $this->addAliases($contents); + $contents = $this->swapConverter($contents); + $contents = $this->injectConverterClass($contents); + + file_put_contents($file, $contents); + + $this->stopEmittingDefaultIntValues(__DIR__ . '/../src/Dropbox.Sign/Model'); + } + + /** + * For optional non-nullable int properties on request-side models + * openapi-generator emits + * [DataMember(Name = "foo", EmitDefaultValue = true)] + * public int Foo { get; set; } + * which forces `"foo": 0` onto the wire whenever the caller never + * set a value (C# default for int is 0). For fields like font_size + * the server rejects the default with a 400 ("'font_size' must be + * between 7 and 49"), and more generally any optional int whose + * server-side default is not 0 is misrepresented. + * + * Scope: + * - Only request-side models are patched. Response models keep + * EmitDefaultValue = true so that round-trip serialization + * preserves explicit zero values that the server sent (tests + * under Dropbox.Sign.Test.Api rely on this fidelity). Response + * models are identified by the "Response" substring in the + * file name, which is the convention for all generated + * response types in this SDK. + * - Required int properties are left alone: openapi-generator + * inserts `IsRequired = true,` between the Name and + * EmitDefaultValue tokens, which this regex does not match. + */ + private function stopEmittingDefaultIntValues(string $modelDir): void + { + $pattern = '/(\[DataMember\(Name = "[^"]+", EmitDefaultValue = )true(\)\]\s*\r?\n\s+public int [A-Za-z_][A-Za-z0-9_]* \{ get; set; \})/'; + + foreach (glob($modelDir . '/*.cs') as $path) { + if (strpos(basename($path), 'Response') !== false) { + continue; + } + + $contents = file_get_contents($path); + $patched = preg_replace($pattern, '$1false$2', $contents); + if ($patched !== null && $patched !== $contents) { + file_put_contents($path, $patched); + } + } + } + + private function addAliases(string $contents): string + { $constant_1 = " ChangeFieldVisibility = 1,"; $replace_1 = implode("\n", [ $constant_1, @@ -44,21 +104,99 @@ public function run(): void ' GroupVisibility = ChangeGroupVisibility', ]); - $contents = str_replace( - $constant_1, - $replace_1, - $contents, - ); + $contents = str_replace($constant_1, $replace_1, $contents); + $contents = str_replace($constant_2, $replace_2, $contents); - $contents = str_replace( - $constant_2, - $replace_2, - $contents, - ); + return $contents; + } - file_put_contents($file, $contents); + private function swapConverter(string $contents): string + { + $needle = " [JsonConverter(typeof(StringEnumConverter))]\n" + . " public enum TypeEnum"; + + $replacement = <<<'CS' + // A dedicated converter is used instead of StringEnumConverter + // because this enum intentionally carries alias members + // (FieldVisibility, GroupVisibility) that share a numeric value + // with their canonical counterparts. StringEnumConverter goes + // through Enum.GetName, which is not guaranteed to pick the + // canonical name when multiple members share a value, and + // Newtonsoft rejects duplicate [EnumMember] values on the same + // enum. Mapping by numeric value here is unambiguous: both the + // canonical name and its alias produce the same wire string. + [JsonConverter(typeof(TypeEnumJsonConverter))] + public enum TypeEnum +CS; + + return str_replace($needle, $replacement, $contents); + } + + private function injectConverterClass(string $contents): string + { + // Inject the nested converter immediately after the enum's + // closing brace. Anchor on the enum's tail, which is unique in + // the file. + $needle = " GroupVisibility = ChangeGroupVisibility\n" + . " }\n"; + + $converter = <<<'CS' + GroupVisibility = ChangeGroupVisibility + } + + /// + /// Serializes SubFormFieldRuleAction.TypeEnum to and from the + /// OpenAPI wire values (change-field-visibility, + /// change-group-visibility). The switch is driven by the + /// underlying numeric value so that legacy alias members + /// (FieldVisibility, GroupVisibility) serialize identically to + /// their canonical counterparts. + /// + public class TypeEnumJsonConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, TypeEnum value, JsonSerializer serializer) + { + switch (value) + { + case TypeEnum.ChangeFieldVisibility: + writer.WriteValue("change-field-visibility"); + return; + case TypeEnum.ChangeGroupVisibility: + writer.WriteValue("change-group-visibility"); + return; + default: + throw new JsonSerializationException( + $"Unknown value for SubFormFieldRuleAction.TypeEnum: {(int)value}"); + } + } + + public override TypeEnum ReadJson(JsonReader reader, Type objectType, TypeEnum existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + throw new JsonSerializationException( + "Cannot deserialize null into SubFormFieldRuleAction.TypeEnum"); + } + + var raw = reader.Value?.ToString(); + switch (raw) + { + case "change-field-visibility": + return TypeEnum.ChangeFieldVisibility; + case "change-group-visibility": + return TypeEnum.ChangeGroupVisibility; + default: + throw new JsonSerializationException( + $"Unknown wire value for SubFormFieldRuleAction.TypeEnum: {raw}"); + } + } + } + +CS; + + return str_replace($needle, $converter, $contents); } } $copier = new CopyConstants(); -$copier->run(); \ No newline at end of file +$copier->run(); diff --git a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldRuleAction.cs b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldRuleAction.cs index b6af36b3d..34634d199 100644 --- a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldRuleAction.cs +++ b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldRuleAction.cs @@ -36,7 +36,16 @@ public partial class SubFormFieldRuleAction : IEquatable /// /// Defines Type /// - [JsonConverter(typeof(StringEnumConverter))] + // A dedicated converter is used instead of StringEnumConverter + // because this enum intentionally carries alias members + // (FieldVisibility, GroupVisibility) that share a numeric value + // with their canonical counterparts. StringEnumConverter goes + // through Enum.GetName, which is not guaranteed to pick the + // canonical name when multiple members share a value, and + // Newtonsoft rejects duplicate [EnumMember] values on the same + // enum. Mapping by numeric value here is unambiguous: both the + // canonical name and its alias produce the same wire string. + [JsonConverter(typeof(TypeEnumJsonConverter))] public enum TypeEnum { /// @@ -54,6 +63,54 @@ public enum TypeEnum GroupVisibility = ChangeGroupVisibility } + /// + /// Serializes SubFormFieldRuleAction.TypeEnum to and from the + /// OpenAPI wire values (change-field-visibility, + /// change-group-visibility). The switch is driven by the + /// underlying numeric value so that legacy alias members + /// (FieldVisibility, GroupVisibility) serialize identically to + /// their canonical counterparts. + /// + public class TypeEnumJsonConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, TypeEnum value, JsonSerializer serializer) + { + switch (value) + { + case TypeEnum.ChangeFieldVisibility: + writer.WriteValue("change-field-visibility"); + return; + case TypeEnum.ChangeGroupVisibility: + writer.WriteValue("change-group-visibility"); + return; + default: + throw new JsonSerializationException( + $"Unknown value for SubFormFieldRuleAction.TypeEnum: {(int)value}"); + } + } + + public override TypeEnum ReadJson(JsonReader reader, Type objectType, TypeEnum existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + throw new JsonSerializationException( + "Cannot deserialize null into SubFormFieldRuleAction.TypeEnum"); + } + + var raw = reader.Value?.ToString(); + switch (raw) + { + case "change-field-visibility": + return TypeEnum.ChangeFieldVisibility; + case "change-group-visibility": + return TypeEnum.ChangeGroupVisibility; + default: + throw new JsonSerializationException( + $"Unknown wire value for SubFormFieldRuleAction.TypeEnum: {raw}"); + } + } + } + /// /// Gets or Sets Type diff --git a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentDateSigned.cs b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentDateSigned.cs index 485c02d9a..83cfccf2e 100644 --- a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentDateSigned.cs +++ b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentDateSigned.cs @@ -215,7 +215,7 @@ public static SubFormFieldsPerDocumentDateSigned Init(string jsonData) /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. /// /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. - [DataMember(Name = "font_size", EmitDefaultValue = true)] + [DataMember(Name = "font_size", EmitDefaultValue = false)] public int FontSize { get; set; } /// diff --git a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentDropdown.cs b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentDropdown.cs index 467103717..91869c0dc 100644 --- a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentDropdown.cs +++ b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentDropdown.cs @@ -238,7 +238,7 @@ public static SubFormFieldsPerDocumentDropdown Init(string jsonData) /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. /// /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. - [DataMember(Name = "font_size", EmitDefaultValue = true)] + [DataMember(Name = "font_size", EmitDefaultValue = false)] public int FontSize { get; set; } /// diff --git a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentHyperlink.cs b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentHyperlink.cs index d766865ca..7ab930ec9 100644 --- a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentHyperlink.cs +++ b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentHyperlink.cs @@ -243,7 +243,7 @@ public static SubFormFieldsPerDocumentHyperlink Init(string jsonData) /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. /// /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. - [DataMember(Name = "font_size", EmitDefaultValue = true)] + [DataMember(Name = "font_size", EmitDefaultValue = false)] public int FontSize { get; set; } /// diff --git a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentText.cs b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentText.cs index c514d0f50..429b9c711 100644 --- a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentText.cs +++ b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentText.cs @@ -353,7 +353,7 @@ public static SubFormFieldsPerDocumentText Init(string jsonData) /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. /// /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. - [DataMember(Name = "font_size", EmitDefaultValue = true)] + [DataMember(Name = "font_size", EmitDefaultValue = false)] public int FontSize { get; set; } /// diff --git a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentTextMerge.cs b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentTextMerge.cs index d2d0c6412..ac96ff2cc 100644 --- a/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentTextMerge.cs +++ b/sdks/dotnet/src/Dropbox.Sign/Model/SubFormFieldsPerDocumentTextMerge.cs @@ -215,7 +215,7 @@ public static SubFormFieldsPerDocumentTextMerge Init(string jsonData) /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. /// /// The initial px font size for the field contents. Can be any integer value between `7` and `49`. **NOTE:** Font size may be reduced during processing in order to fit the contents within the dimensions of the field. - [DataMember(Name = "font_size", EmitDefaultValue = true)] + [DataMember(Name = "font_size", EmitDefaultValue = false)] public int FontSize { get; set; } /// From 9e1263764cd556c7248fe3b64cbeb8cb855f6c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20W=C3=B3jcik?= Date: Wed, 22 Apr 2026 09:54:56 +0200 Subject: [PATCH 2/2] Add test --- .../Model/SubFormFieldRuleActionTests.cs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 sdks/dotnet/src/Dropbox.Sign.Test/Model/SubFormFieldRuleActionTests.cs diff --git a/sdks/dotnet/src/Dropbox.Sign.Test/Model/SubFormFieldRuleActionTests.cs b/sdks/dotnet/src/Dropbox.Sign.Test/Model/SubFormFieldRuleActionTests.cs new file mode 100644 index 000000000..6e5a65cd6 --- /dev/null +++ b/sdks/dotnet/src/Dropbox.Sign.Test/Model/SubFormFieldRuleActionTests.cs @@ -0,0 +1,84 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using Dropbox.Sign.Model; + +namespace Dropbox.Sign.Test.Model +{ + public class SubFormFieldRuleActionTests + { + // SubFormFieldRuleAction.TypeEnum must serialize to the exact + // wire values accepted by the Dropbox Sign API + // (change-field-visibility, change-group-visibility). The enum + // intentionally carries legacy alias members (FieldVisibility, + // GroupVisibility) that share a numeric value with the canonical + // members, so at runtime they are indistinguishable (xUnit also + // dedups the alias rows because [InlineData] compares enums by + // their underlying int). The dedicated TypeEnumJsonConverter + // maps by underlying value so both names produce the same wire + // string; pinning the canonical members is therefore sufficient. + [Theory] + [InlineData(SubFormFieldRuleAction.TypeEnum.ChangeFieldVisibility, "change-field-visibility")] + [InlineData(SubFormFieldRuleAction.TypeEnum.ChangeGroupVisibility, "change-group-visibility")] + public void TypeEnum_Serializes_To_EnumMember_Value( + SubFormFieldRuleAction.TypeEnum value, + string expected) + { + var json = JsonConvert.SerializeObject(value); + + Assert.Equal($"\"{expected}\"", json); + } + + [Theory] + [InlineData(SubFormFieldRuleAction.TypeEnum.ChangeFieldVisibility, "change-field-visibility")] + [InlineData(SubFormFieldRuleAction.TypeEnum.ChangeGroupVisibility, "change-group-visibility")] + public void Action_Payload_Uses_EnumMember_Value_For_Type( + SubFormFieldRuleAction.TypeEnum value, + string expected) + { + var action = new SubFormFieldRuleAction( + fieldId: "api_id_2", + hidden: true, + type: value + ); + + var json = JObject.Parse(action.ToJson()); + + Assert.Equal(expected, (string)json["type"]); + } + + [Theory] + [InlineData("change-field-visibility", SubFormFieldRuleAction.TypeEnum.ChangeFieldVisibility)] + [InlineData("change-group-visibility", SubFormFieldRuleAction.TypeEnum.ChangeGroupVisibility)] + public void Action_Payload_Deserializes_EnumMember_Value_For_Type( + string wireValue, + SubFormFieldRuleAction.TypeEnum expected) + { + var payload = new JObject( + new JProperty("field_id", "api_id_2"), + new JProperty("hidden", true), + new JProperty("type", wireValue) + ).ToString(); + + var action = SubFormFieldRuleAction.Init(payload); + + Assert.Equal(expected, action.Type); + } + + [Theory] + [InlineData("FieldVisibility")] + [InlineData("GroupVisibility")] + [InlineData("change-unknown-thing")] + [InlineData("")] + public void Action_Payload_Rejects_Unknown_Type_Values(string wireValue) + { + var payload = new JObject( + new JProperty("field_id", "api_id_2"), + new JProperty("hidden", true), + new JProperty("type", wireValue) + ).ToString(); + + Assert.ThrowsAny(() => SubFormFieldRuleAction.Init(payload)); + } + } +}