-
Notifications
You must be signed in to change notification settings - Fork 188
fix: (.NET) Improve json De/serialization #1138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Replaces switch on JsonTokenType with a recursive method using JsonElement.ValueKind for more robust and accurate type inference. This improves handling of nested objects and arrays, and unifies the logic for converting JSON values to .NET types.
Simplifies and standardizes the deserialization of model properties from dictionaries, removing special handling for JsonElement and streamlining array and primitive type conversions. This improves code readability and maintainability in generated model classes.
Updated the From method in the model template to check for the existence of optional properties in the input map before assigning values. This prevents errors when optional properties are missing from the input dictionary. (for examle in model: User, :-/ )
templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR refactors JSON serialization and deserialization in the .NET SDK template to fix critical bugs and improve robustness, particularly addressing infinite recursion issues and type inference problems.
- Fixed infinite recursion bug in
ObjectToInferredTypesConverter
that causedStackOverflowException
- Improved JSON type inference by switching from
JsonTokenType
toJsonElement.ValueKind
- Streamlined model deserialization by removing special
JsonElement
handling and standardizing type conversions
Reviewed Changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
File | Description |
---|---|
templates/dotnet/Package/Role.cs.twig | Updated namespace to use dynamic spec title |
templates/dotnet/Package/Models/Model.cs.twig | Simplified model deserialization logic, removed JsonElement handling |
templates/dotnet/Package/Models/InputFile.cs.twig | Updated namespace import to use dynamic spec title |
templates/dotnet/Package/Exception.cs.twig | Added blank line formatting |
templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig | Complete rewrite to fix recursion bug and improve type inference |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
cf7eb57
to
b071cbc
Compare
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughThe ObjectToInferredTypesConverter.Read now returns object? and parses input via JsonDocument, delegating conversion to a new recursive ConvertElement that maps JsonElement kinds to .NET types (object → Dictionary<string, object?>, array → List<object?>, string → DateTime or string, number → long/double, bool → bool, null/undefined → null; unsupported kinds throw JsonException). A new exception constructor overload (string message, Exception inner) was added. Model generation moved to dictionary-centric casts with helper macros. Service template import of utils was removed. Client.PrepareRequest now skips parameters with null values. Several files received namespace templating and trailing-newline formatting fixes. Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches🧪 Generate unit tests
Tip 👮 Agentic pre-merge checks are now available in preview!Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.
Please see the documentation for more information. Example: reviews:
pre_merge_checks:
custom_checks:
- name: "Undocumented Breaking Changes"
mode: "warning"
instructions: |
Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal). Please share your feedback with us on this Discord post. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
templates/dotnet/Package/Query.cs.twig (1)
153-159
: Validate deserialization in Or/And to avoid nulls in ValuesJsonSerializer.Deserialize can return null on malformed input; currently this would silently serialize null entries. Fail fast with a clear error.
public static string Or(List<string> queries) { - return new Query("or", null, queries.Select(q => JsonSerializer.Deserialize<Query>(q, Client.DeserializerOptions)).ToList()).ToString(); + var parsed = queries + .Select(q => JsonSerializer.Deserialize<Query>(q, Client.DeserializerOptions) ?? throw new JsonException("Invalid query JSON in Or()")) + .ToList(); + return new Query("or", null, parsed).ToString(); } public static string And(List<string> queries) { - return new Query("and", null, queries.Select(q => JsonSerializer.Deserialize<Query>(q, Client.DeserializerOptions)).ToList()).ToString(); + var parsed = queries + .Select(q => JsonSerializer.Deserialize<Query>(q, Client.DeserializerOptions) ?? throw new JsonException("Invalid query JSON in And()")) + .ToList(); + return new Query("and", null, parsed).ToString(); }templates/dotnet/Package/Extensions/Extensions.cs.twig (1)
15-38
: Correct query encoding and culture-invariant formattingEncoding the entire string via Uri.EscapeUriString risks malformed queries and culture-specific number formats. Encode components with Uri.EscapeDataString and format numbers with InvariantCulture; normalize booleans to lowercase.
- public static string ToQueryString(this Dictionary<string, object?> parameters) - { - var query = new List<string>(); - - foreach (var kvp in parameters) - { - switch (kvp.Value) - { - case null: - continue; - case IList list: - foreach (var item in list) - { - query.Add($"{kvp.Key}[]={item}"); - } - break; - default: - query.Add($"{kvp.Key}={kvp.Value.ToString()}"); - break; - } - } - - return Uri.EscapeUriString(string.Join("&", query)); - } + public static string ToQueryString(this Dictionary<string, object?> parameters) + { + string Encode(object? v) => + v switch + { + null => string.Empty, + bool b => b ? "true" : "false", + IFormattable f => f.ToString(null, System.Globalization.CultureInfo.InvariantCulture), + _ => v.ToString() ?? string.Empty + }; + + var parts = new List<string>(); + + foreach (var kvp in parameters) + { + switch (kvp.Value) + { + case null: + continue; + case IList list when kvp.Key is not null: + foreach (var item in list) + { + parts.Add($"{kvp.Key}[]={Uri.EscapeDataString(Encode(item))}"); + } + break; + default: + parts.Add($"{kvp.Key}={Uri.EscapeDataString(Encode(kvp.Value))}"); + break; + } + } + + return string.Join("&", parts); + }
♻️ Duplicate comments (2)
templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig (1)
72-73
: EOF newline restored.Matches prior request to add terminal newline.
templates/dotnet/Package/Models/Model.cs.twig (1)
51-51
: Remove null-forgiving and tame the long LINQ chain.ToList() never returns null; the bang is unnecessary. Also, this line is hard to read—prior feedback still applies.
- ((IEnumerable<object>)map["{{ property.name }}"]).Select(x => {% if property.items.type == "string" %}x?.ToString(){% elseif property.items.type == "integer" %}{% if not property.required %}x == null ? (long?)null : {% endif %}Convert.ToInt64(x){% elseif property.items.type == "number" %}{% if not property.required %}x == null ? (double?)null : {% endif %}Convert.ToDouble(x){% elseif property.items.type == "boolean" %}{% if not property.required %}x == null ? (bool?)null : {% endif %}(bool)x{% else %}x{% endif %}).{% if property.items.type == "string" and property.required %}Where(x => x != null).{% endif %}ToList()! + ((IEnumerable<object>)map["{{ property.name }}"]).Select(x => {% if property.items.type == "string" %}x?.ToString(){% elseif property.items.type == "integer" %}{% if not property.required %}x == null ? (long?)null : {% endif %}Convert.ToInt64(x){% elseif property.items.type == "number" %}{% if not property.required %}x == null ? (double?)null : {% endif %}Convert.ToDouble(x){% elseif property.items.type == "boolean" %}{% if not property.required %}x == null ? (bool?)null : {% endif %}(bool)x{% else %}x{% endif %}).{% if property.items.type == "string" and property.required %}Where(x => x != null).{% endif %}ToList()Optional: mirror the safe-cast pattern from Lines 45-48 for optionals.
🧹 Nitpick comments (8)
templates/dotnet/Package/Query.cs.twig (1)
25-42
: Broaden handling of enumerable values in constructorToday only IList is expanded; IEnumerable (e.g., LINQ results, HashSet) are treated as a single value. Safely broaden to IEnumerable while avoiding strings.
public Query(string method, string? attribute, object? values) { this.Method = method; this.Attribute = attribute; - if (values is IList valuesList) + if (values is IList valuesList) { this.Values = new List<object>(); foreach (var value in valuesList) { this.Values.Add(value); // Automatically boxes if value is a value type } } - else if (values != null) + else if (values is IEnumerable enumerable && values is not string) + { + this.Values = new List<object>(); + foreach (var value in enumerable) + { + this.Values.Add(value!); + } + } + else if (values != null) { this.Values = new List<object> { values }; } }templates/dotnet/Package/Extensions/Extensions.cs.twig (3)
1-5
: Add missing using for CultureInfo (if not already imported elsewhere)Required by the proposed InvariantCulture formatting.
using System; using System.Collections; using System.Collections.Generic; using System.Text.Json; +using System.Globalization;
40-41
: Make mappings readonlyThis is a constant lookup table; prevent accidental mutation.
- private static IDictionary<string, string> _mappings = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) { + private static readonly IDictionary<string, string> _mappings = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) {
607-612
: Use nameof in ArgumentNullExceptionMinor clarity/readability improvement.
- if (extension == null) - { - throw new ArgumentNullException("extension"); - } + if (extension == null) + { + throw new ArgumentNullException(nameof(extension)); + }templates/dotnet/Package/Models/InputFile.cs.twig (1)
14-21
: Guard against invalid pathsOptional: validate input to avoid surprising runtime errors and provide clearer messages.
- public static InputFile FromPath(string path) => new InputFile + public static InputFile FromPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path must be a non-empty string.", nameof(path)); + return new InputFile { Path = path, Filename = System.IO.Path.GetFileName(path), MimeType = path.GetMimeType(), SourceType = "path" - }; + }; + }templates/dotnet/Package/Exception.cs.twig (1)
22-25
: Constructor overload looks good; consider serialization support and parity overloads.Nice addition. Two optional tweaks:
- Add [Serializable] + protected (SerializationInfo, StreamingContext) ctor for Exception best practices.
- Consider an overload that also accepts code/type/response with an inner exception if those are commonly set when wrapping.
templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig (2)
38-44
: Prefer DateTimeOffset (or skip auto date coercion) to preserve offsets.Parsing string → DateTime can lose timezone info or affect round-tripping when models expect strings. Either:
- Try DateTimeOffset first, then DateTime, else keep string.
- Or, keep all strings as strings (let models decide). First option shown below.
- case JsonValueKind.String: - if (element.TryGetDateTime(out DateTime datetime)) - { - return datetime; - } - return element.GetString(); + case JsonValueKind.String: + if (element.TryGetDateTimeOffset(out DateTimeOffset dto)) + { + return dto; + } + if (element.TryGetDateTime(out DateTime dt)) + { + return dt; + } + return element.GetString();
45-51
: Avoid precision loss for large/monetary numbers.Fall back to decimal before double to preserve precision; Convert.* in models will still handle decimal.
case JsonValueKind.Number: if (element.TryGetInt64(out long l)) { return l; } - return element.GetDouble(); + if (element.TryGetDecimal(out decimal dec)) + { + return dec; + } + return element.GetDouble();
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (8)
templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig
(1 hunks)templates/dotnet/Package/Exception.cs.twig
(1 hunks)templates/dotnet/Package/Extensions/Extensions.cs.twig
(1 hunks)templates/dotnet/Package/Models/InputFile.cs.twig
(2 hunks)templates/dotnet/Package/Models/Model.cs.twig
(1 hunks)templates/dotnet/Package/Models/UploadProgress.cs.twig
(1 hunks)templates/dotnet/Package/Query.cs.twig
(1 hunks)templates/dotnet/Package/Role.cs.twig
(2 hunks)
🔇 Additional comments (3)
templates/dotnet/Package/Models/UploadProgress.cs.twig (1)
1-26
: LGTM — formatting onlyNo functional changes. Safe.
templates/dotnet/Package/Role.cs.twig (1)
1-92
: LGTM — namespace templatingNamespace templating aligns with the rest of the PR; no functional changes to Role API.
templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig (1)
10-16
: Good fix: eliminates recursion/StackOverflow and handles nulls cleanly.Using JsonDocument + a single pass is safer and clearer. No issues here.
Improves the From() method in Model.cs.twig to handle nullable and array properties more robustly, using helper macros for parsing arrays and sub-schemas. This change ensures correct handling of optional fields and type conversions, reducing runtime errors and improving code maintainability. Also removes an unnecessary blank line in ServiceTemplate.cs.twig.
Hello @ChiragAgg5k, I made some minor adjustments, wrote some tests, and noticed that two arguments that are required in the code in the API are actually missing. It would be best to make them null-safe or see why the API is not returning them. ![]() ![]() ![]() |
Fields with null values in multipart are now omitted (so they don't turn into empty strings).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
templates/dotnet/Package/Client.cs.twig (1)
457-495
: Fix chunking: off‑by‑one and buffer misuse cause data corruption.
- bytes case drops 1 byte per chunk (ChunkSize-1).
- stream case ignores the actual bytes read and sends the entire buffer.
- Content-Range end and offset advance don’t reflect the actual bytes sent.
These bugs can corrupt uploads. Use the actual bytes to send per chunk.
- while (offset < size) + while (offset < size) { - switch(input.SourceType) - { - case "path": - case "stream": - var stream = input.Data as Stream; - if (stream == null) - throw new InvalidOperationException("Stream data is null"); - stream.Seek(offset, SeekOrigin.Begin); - await stream.ReadAsync(buffer, 0, ChunkSize); - break; - case "bytes": - buffer = ((byte[])input.Data) - .Skip((int)offset) - .Take((int)Math.Min(size - offset, ChunkSize - 1)) - .ToArray(); - break; - } - - var content = new MultipartFormDataContent { - { new ByteArrayContent(buffer), paramName, input.Filename } - }; + var remaining = (int)Math.Min(size - offset, ChunkSize); + int bytesToSend = remaining; + HttpContent filePart; + switch (input.SourceType) + { + case "path": + case "stream": + var stream = input.Data as Stream; + if (stream == null) + throw new InvalidOperationException("Stream data is null"); + stream.Seek(offset, SeekOrigin.Begin); + var bytesRead = 0; + while (bytesRead < remaining) + { + var read = await stream.ReadAsync(buffer, bytesRead, remaining - bytesRead); + if (read == 0) break; + bytesRead += read; + } + bytesToSend = bytesRead; + filePart = new ByteArrayContent(buffer, 0, bytesToSend); + break; + case "bytes": + var data = input.Data as byte[]; + if (data == null) + throw new InvalidOperationException("Byte array data is null"); + filePart = new ByteArrayContent(data, (int)offset, remaining); + break; + default: + throw new InvalidOperationException($"Unsupported source type: {input.SourceType}"); + } + + var content = new MultipartFormDataContent { + { filePart, paramName, input.Filename } + }; parameters[paramName] = content; - headers["Content-Range"] = - $"bytes {offset}-{Math.Min(offset + ChunkSize - 1, size - 1)}/{size}"; + var end = Math.Min(offset + bytesToSend - 1, size - 1); + headers["Content-Range"] = $"bytes {offset}-{end}/{size}"; result = await Call<Dictionary<string, object?>>( method: "POST", path, headers, parameters ); - offset += ChunkSize; + offset += bytesToSend;
🧹 Nitpick comments (3)
templates/dotnet/Package/Client.cs.twig (3)
157-176
: Skip-nulls is good; also support arbitrary file param names (not only "file").Current logic only treats key "file". If an API uses a different param name (e.g., "image"), the file won’t be added. Detect MultipartFormDataContent by type instead of key so uploads work regardless of parameter name.
foreach (var parameter in parameters) { - if (parameter.Value == null) continue; - if (parameter.Key == "file") - { - var fileContent = parameters["file"] as MultipartFormDataContent; - if (fileContent != null) - { - form.Add(fileContent.First()!); - } - } + if (parameter.Value == null) continue; + if (parameter.Value is MultipartFormDataContent fileContent) + { + var part = fileContent.FirstOrDefault(); + if (part != null) + { + form.Add(part); + } + continue; + } else if (parameter.Value is IEnumerable<object> enumerable) { var list = new List<object>(enumerable); for (int index = 0; index < list.Count; index++) { form.Add(new StringContent(list[index]?.ToString() ?? string.Empty), $"{parameter.Key}[{index}]"); } } else { form.Add(new StringContent(parameter.Value?.ToString() ?? string.Empty), parameter.Key); } }
189-203
: Avoid growing Accept headers on the shared HttpClient; set per-request instead.Each call adds another Accept value to DefaultRequestHeaders and never clears it. Prefer setting Accept only on the request (already done below) and skip touching DefaultRequestHeaders here.
foreach (var header in _headers) { - if (header.Key.Equals("content-type", StringComparison.OrdinalIgnoreCase)) - { - _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(header.Value)); - } - else + if (!header.Key.Equals("content-type", StringComparison.OrdinalIgnoreCase)) { if (_http.DefaultRequestHeaders.Contains(header.Key)) { _http.DefaultRequestHeaders.Remove(header.Key); } _http.DefaultRequestHeaders.Add(header.Key, header.Value); } }
142-144
: Avoid trailing “?” when no GET parameters
ToQueryString already skips nulls—only prefix with “?” if it returns non-empty:var rawQuery = parameters.ToQueryString(); var queryString = methodGet && rawQuery.Length > 0 ? "?" + rawQuery : string.Empty;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
templates/dotnet/Package/Models/Model.cs.twig (1)
107-109
:ToMap
nullifies optional sub-modelsOptional nested models (and arrays of models) can legitimately stay
null
afterFrom(...)
, butToMap()
invokes.Select(...)
/.ToMap()
unconditionally, so the first access on a null property throws aNullReferenceException
. Please add null-conditionals so optional sub-models remainnull
when re-serialized instead of crashing.- { "{{ property.name }}", {% if property.sub_schema %}{% if property.type == 'array' %}{{ _self.property_name(definition, property) | overrideProperty(definition.name) }}.Select(it => it.ToMap()){% else %}{{ _self.property_name(definition, property) | overrideProperty(definition.name) }}.ToMap(){% endif %}{% else %}{{ _self.property_name(definition, property) | overrideProperty(definition.name) }}{% endif %}{{ ' }' }}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} + { "{{ property.name }}", {% if property.sub_schema %}{% if property.type == 'array' %}{{ _self.property_name(definition, property) | overrideProperty(definition.name) }}{% if not property.required %}?{% endif %}.Select(it => it.ToMap()){% else %}{{ _self.property_name(definition, property) | overrideProperty(definition.name) }}{% if not property.required %}?{% endif %}.ToMap(){% endif %}{% else %}{{ _self.property_name(definition, property) | overrideProperty(definition.name) }}{% endif %}{{ ' }' }}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %}
{%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) ? {% endif %} | ||
{%- if property.sub_schema %} | ||
{%- if property.type == 'array' -%} | ||
map["{{ property.name }}"] is JsonElement jsonArray{{ loop.index }} ? jsonArray{{ loop.index }}.Deserialize<List<Dictionary<string, object>>>()!.Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() : ((IEnumerable<Dictionary<string, object>>)map["{{ property.name }}"]).Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() | ||
{%- if property.required -%} | ||
{{ _self.parse_subschema_array(property.sub_schema, mapAccess, true) }} | ||
{%- else -%} | ||
{{ _self.parse_subschema_array(property.sub_schema, v, false) }} | ||
{%- endif %} | ||
{%- else -%} | ||
{{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: map["{{ property.name }}"] is JsonElement jsonObj{{ loop.index }} ? jsonObj{{ loop.index }}.Deserialize<Dictionary<string, object>>()! : (Dictionary<string, object>)map["{{ property.name }}"]) | ||
{%- if property.required -%} | ||
{{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary<string, object>){{ mapAccess | raw }}) | ||
{%- else -%} | ||
({{ v }} as Dictionary<string, object>) is { } obj | ||
? {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: obj) | ||
: null | ||
{%- endif %} | ||
{%- endif %} | ||
{%- else %} | ||
{%- if property.type == 'array' -%} | ||
map["{{ property.name }}"] is JsonElement jsonArrayProp{{ loop.index }} ? jsonArrayProp{{ loop.index }}.Deserialize<{{ property | typeName }}>()! : ({{ property | typeName }})map["{{ property.name }}"] | ||
{%- if property.required -%} | ||
{{ _self.parse_primitive_array(property.items.type, mapAccess, true) }} | ||
{%- else -%} | ||
{{ _self.parse_primitive_array(property.items.type, v, false) }} | ||
{%- endif -%} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent null optionals from turning into defaults
map.TryGetValue
succeeds even when the value is explicitly null
, so the current branch still runs the conversion pipeline. That means nullable primitives deserialize to 0
/0.0
(Convert.ToInt64(null)
etc.) and nullable arrays walk through Array.Empty<object>()
, returning []
instead of null
. We lose the distinction between null
and “empty”, breaking the generated models’ semantics. Guard the conversion so we only execute it when the fetched value is non-null.
- {%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) ? {% endif %}
+ {%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) && {{ v }} is not null ? {% endif %}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
{%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) ? {% endif %} | |
{%- if property.sub_schema %} | |
{%- if property.type == 'array' -%} | |
map["{{ property.name }}"] is JsonElement jsonArray{{ loop.index }} ? jsonArray{{ loop.index }}.Deserialize<List<Dictionary<string, object>>>()!.Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() : ((IEnumerable<Dictionary<string, object>>)map["{{ property.name }}"]).Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() | |
{%- if property.required -%} | |
{{ _self.parse_subschema_array(property.sub_schema, mapAccess, true) }} | |
{%- else -%} | |
{{ _self.parse_subschema_array(property.sub_schema, v, false) }} | |
{%- endif %} | |
{%- else -%} | |
{{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: map["{{ property.name }}"] is JsonElement jsonObj{{ loop.index }} ? jsonObj{{ loop.index }}.Deserialize<Dictionary<string, object>>()! : (Dictionary<string, object>)map["{{ property.name }}"]) | |
{%- if property.required -%} | |
{{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary<string, object>){{ mapAccess | raw }}) | |
{%- else -%} | |
({{ v }} as Dictionary<string, object>) is { } obj | |
? {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: obj) | |
: null | |
{%- endif %} | |
{%- endif %} | |
{%- else %} | |
{%- if property.type == 'array' -%} | |
map["{{ property.name }}"] is JsonElement jsonArrayProp{{ loop.index }} ? jsonArrayProp{{ loop.index }}.Deserialize<{{ property | typeName }}>()! : ({{ property | typeName }})map["{{ property.name }}"] | |
{%- if property.required -%} | |
{{ _self.parse_primitive_array(property.items.type, mapAccess, true) }} | |
{%- else -%} | |
{{ _self.parse_primitive_array(property.items.type, v, false) }} | |
{%- endif -%} | |
{%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) && {{ v }} is not null ? {% endif %} | |
{%- if property.sub_schema %} | |
{%- if property.type == 'array' -%} | |
{%- if property.required -%} | |
{{ _self.parse_subschema_array(property.sub_schema, mapAccess, true) }} | |
{%- else -%} | |
{{ _self.parse_subschema_array(property.sub_schema, v, false) }} | |
{%- endif %} | |
{%- else -%} | |
{%- if property.required -%} | |
{{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary<string, object>){{ mapAccess | raw }}) | |
{%- else -%} | |
({{ v }} as Dictionary<string, object>) is { } obj | |
? {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: obj) | |
: null | |
{%- endif %} | |
{%- endif %} | |
{%- else %} | |
{%- if property.type == 'array' -%} | |
{%- if property.required -%} | |
{{ _self.parse_primitive_array(property.items.type, mapAccess, true) }} | |
{%- else -%} | |
{{ _self.parse_primitive_array(property.items.type, v, false) }} | |
{%- endif -%} |
🤖 Prompt for AI Agents
In templates/dotnet/Package/Models/Model.cs.twig around lines 52 to 75, guard
the optional-value conversion so it only runs when the fetched value is
non-null: change branches that currently rely on map.TryGetValue(...) alone to
require both TryGetValue(...) and a non-null value (e.g. map.TryGetValue("{{
property.name }}", out var {{ v }}) && {{ v }} is not null) before invoking the
parse/From conversion; for optional arrays and primitives return null when the
fetched value is null instead of running the conversion pipeline, while leaving
required-property code paths unchanged.
What does this PR do?
This PR refactors the JSON serialization and deserialization logic in the .NET SDK template to improve robustness and fix critical issues:
Key Changes:
Fixed infinite recursion bug in
ObjectToInferredTypesConverter
: Replaced the problematic approach of usingJsonSerializer.Deserialize
recursively within the converter itself, which causedStackOverflowException
for nested objects and arrays.Improved JSON type inference: Switched from
JsonTokenType
toJsonElement.ValueKind
for more accurate and reliable type detection, providing better handling of all JSON value types including null and undefined values.Eliminates the risk of leaking JsonElement instances into the resulting object graph, simplifying model deserialization and removing the need for special handling of JsonElement in generated code.
Streamlined model deserialization: Simplified the generated model deserialization logic by removing special handling for
JsonElement
objects and standardizing type conversions, making the generated code more readable and maintainable.Enhanced error handling: Added proper error handling with descriptive exceptions for unsupported JSON value kinds.
Test Plan
Testing the ObjectToInferredTypesConverter fix:
Testing model deserialization changes:
Related PRs and Issues
This PR addresses potential runtime crashes and improves the overall reliability of JSON handling in generated .NET SDKs. The changes are particularly important for applications that work with complex nested JSON structures from API responses.
Related to issues with incorrect type mapping, JsonElement leakage, and runtime errors during deserialization of complex/nested JSON structures.
Have you read the Contributing Guidelines on issues?
YES
Summary by CodeRabbit
New Features
Bug Fixes
Refactor
Style