Skip to content

Conversation

Fellmonkey
Copy link
Contributor

@Fellmonkey Fellmonkey commented Aug 2, 2025

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:

  1. Fixed infinite recursion bug in ObjectToInferredTypesConverter: Replaced the problematic approach of using JsonSerializer.Deserialize recursively within the converter itself, which caused StackOverflowException for nested objects and arrays.

  2. Improved JSON type inference: Switched from JsonTokenType to JsonElement.ValueKind for more accurate and reliable type detection, providing better handling of all JSON value types including null and undefined values.

  3. 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.

  4. 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.

  5. Enhanced error handling: Added proper error handling with descriptive exceptions for unsupported JSON value kinds.

Test Plan

Testing the ObjectToInferredTypesConverter fix:

  • Create a test with nested JSON objects and arrays to verify no StackOverflowException occurs
  • Test deserialization of various JSON types (strings, numbers, booleans, null, objects, arrays)
  • Test edge cases with deeply nested structures

Testing model deserialization changes:

  • Generate SDK models with the updated template
  • Test deserialization of models with:
    • Array properties containing primitives and objects
    • Optional and required properties
    • Nested model objects
    • Various data types (string, integer, number, boolean)

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

    • Added an exception constructor that accepts an inner exception.
  • Bug Fixes

    • JSON deserialization now returns nullable results and explicitly handles null/undefined; improved date/number/string/bool inference.
    • Request preparation now skips null parameters to avoid errors.
    • Better handling for optional and array fields during deserialization.
  • Refactor

    • Generated models and value-parsing logic redesigned; templates now use a templated namespace and a dedicated parsing helper.
  • Style

    • Minor formatting fixes (trailing newlines).

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.
@Fellmonkey Fellmonkey changed the title fix: (.NET) Improve json serialization fix: (.NET) Improve json De/serialization Aug 2, 2025
@Fellmonkey Fellmonkey mentioned this pull request Aug 2, 2025
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, :-/ )
Copy link
Contributor

@Copilot Copilot AI left a 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 caused StackOverflowException
  • Improved JSON type inference by switching from JsonTokenType to JsonElement.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.

@Fellmonkey Fellmonkey force-pushed the improve-json-serialization branch from cf7eb57 to b071cbc Compare September 2, 2025 08:21
Copy link
Contributor

coderabbitai bot commented Sep 2, 2025

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

ObjectToInferredTypesConverter.Read now returns object?, parses input with JsonDocument, and delegates conversion to a new recursive ConvertElement(JsonElement) which maps JsonElement kinds to .NET types (Object → Dictionary<string, object?>, Array → List<object?>, String → DateTime if parseable else string, Number → long/double, True/False → bool, Null/Undefined → null; unsupported kinds throw JsonException). A new exception constructor public <SpecTitle>Exception(string message, Exception inner) was added. Model generation was refactored to use a DefinitionClass alias and dictionary-centric From(Dictionary<string, object>) driven by a new parse_value Twig function. ServiceTemplate removed a utils import. Client multipart preparation now skips null parameters. Several files received namespace templating and trailing-newline formatting changes.

Possibly related PRs

Suggested reviewers

  • abnegate

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly conveys that the pull request fixes and improves JSON serialization and deserialization in the .NET SDK, which aligns directly with the extensive refactoring and enhancements described in the changeset. It is clear, specific enough for a collaborator to understand the primary intent, and avoids extraneous detail. Thus, it meets the criteria for summarizing the main change.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 Values

JsonSerializer.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 formatting

Encoding 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 constructor

Today 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 readonly

This 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 ArgumentNullException

Minor 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 paths

Optional: 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 3ad8053 and b071cbc.

📒 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 only

No functional changes. Safe.

templates/dotnet/Package/Role.cs.twig (1)

1-92: LGTM — namespace templating

Namespace 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.
@Fellmonkey
Copy link
Contributor Author

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.
{7CF5D045-1E68-46F6-A2E3-486785466DD8}

image {CB367DEA-584F-4434-BE90-4C4C135E57E3} {3BB6BE36-2545-4C44-8963-86834212B236}

Fields with null values in multipart are now omitted (so they don't turn into empty strings).
@Fellmonkey
Copy link
Contributor Author

Fields with null values in multipart are now omitted (so they don't turn into empty strings).
image
{861587C0-66F7-4B57-BD7B-3DF4766EF3D3}

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e9586d2 and 5ad9a4b.

📒 Files selected for processing (1)
  • templates/dotnet/Package/Client.cs.twig (1 hunks)

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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-models

Optional nested models (and arrays of models) can legitimately stay null after From(...), but ToMap() invokes .Select(...) / .ToMap() unconditionally, so the first access on a null property throws a NullReferenceException. Please add null-conditionals so optional sub-models remain null 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 %}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5ad9a4b and 953600d.

📒 Files selected for processing (1)
  • templates/dotnet/Package/Models/Model.cs.twig (4 hunks)

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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

♻️ Duplicate comments (1)
templates/dotnet/Package/Models/Model.cs.twig (1)

43-43: Critical: TryGetValue guard still missing null check.

Past reviews flagged that TryGetValue succeeds even when the value is explicitly null. The current guard will execute conversion logic on null values, causing:

  • Convert.ToInt64(null)0 (not null)
  • Optional arrays become [] instead of null
  • Loss of distinction between null and default values

This breaks nullable property semantics.

Apply this diff to add the null guard:

-                {%- 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 %}

This ensures conversion logic only runs when the value is present AND non-null.

🧹 Nitpick comments (1)
templates/dotnet/Package/Models/Model.cs.twig (1)

89-93: Consider ISO 8601 format for DateTime values.

Past reviews noted that if the converter produces DateTime or DateTimeOffset objects, calling ToString() is culture-dependent and may not round-trip correctly.

For string properties that might contain DateTime values, consider using ToString("O") for ISO 8601 format:

{%- if not property.required -%}
{{ v }} switch
{
    DateTimeOffset dto => dto.ToString("O"),
    DateTime dt => dt.ToUniversalTime().ToString("O"),
    _ => {{ v }}?.ToString()
}
{%- else -%}
{{ mapAccess | raw }} switch
{
    DateTimeOffset dto => dto.ToString("O"),
    DateTime dt => dt.ToUniversalTime().ToString("O"),
    _ => {{ mapAccess | raw }}.ToString()
}
{%- endif %}

However, this may not be necessary if the converter always produces strings for string properties. Verify converter behavior.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 953600d and d9f6d71.

📒 Files selected for processing (4)
  • templates/dotnet/Package/Client.cs.twig (1 hunks)
  • templates/dotnet/Package/Models/Model.cs.twig (4 hunks)
  • templates/dotnet/Package/Query.cs.twig (1 hunks)
  • templates/dotnet/Package/Services/ServiceTemplate.cs.twig (0 hunks)
💤 Files with no reviewable changes (1)
  • templates/dotnet/Package/Services/ServiceTemplate.cs.twig
🚧 Files skipped from review as they are similar to previous changes (2)
  • templates/dotnet/Package/Client.cs.twig
  • templates/dotnet/Package/Query.cs.twig
🔇 Additional comments (7)
templates/dotnet/Package/Models/Model.cs.twig (7)

10-10: LGTM: Consistent use of DefinitionClass.

The class declaration, constructor, and static From method correctly use the {{ DefinitionClass }} template variable consistently.

Also applies to: 21-21, 38-38


52-58: LGTM: Safe casting for optional sub-schemas.

The optional branch correctly uses safe casting with pattern matching:

  • as Dictionary<string, object> prevents InvalidCastException
  • Pattern match is { } obj ensures non-null before calling From
  • Returns null when value is missing or wrong type

The required branch uses direct casting, which will fail fast on malformed data—acceptable for required properties.


60-70: LGTM: Enum handling correctly checks for null.

The optional enum branch correctly guards against null values:

  • Uses TryGetValue
  • Explicitly checks enumRaw{{ loop.index }} == null
  • Returns null if key missing or value is null

Note: This branch uses its own variable (enumRaw{{ loop.index }}) instead of the {{ v }} pattern from line 40, which is slightly inconsistent but functionally correct.


98-98: LGTM: Correct null coalescing for optional properties.

The : null suffix correctly completes the ternary operator for optional properties. Once the null guard on line 43 is fixed, this will properly return null for:

  • Missing keys
  • Keys with null values

72-77: Ensure parse_primitive_array handles null safely.

The parse_primitive_array macro definition wasn’t found in the templates—please locate and verify it:

  • accepts null input without throwing
  • uses safe casts (e.g., as IEnumerable<object>)
  • returns null for optional properties when input is null

82-87: Boolean cast safety verified
The ObjectToInferredTypesConverter returns true/false for JsonValueKind.True/JsonValueKind.False and null for JsonValueKind.Null, so (bool?)v will only ever receive a bool or null. No changes required.


46-50: Verify parse_subschema_array is null-safe and uses safe casts

Optional branch passes v (may be null); confirm the macro:

  • accepts null and returns null for optional properties,
  • uses safe casts (e.g., as + pattern-match like (... as IEnumerable<object>) is { } arr ? ...) instead of direct casts,
  • or, if the macro can't be changed, add a guard at templates/dotnet/Package/Models/Model.cs.twig (lines 46–50) to check/cast v before calling the macro and return null when absent.

Introduces a new parse_value Twig function in DotNet.php to centralize and simplify value parsing logic for model properties. Updates Model.cs.twig to use this function, reducing template complexity and improving maintainability.
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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

♻️ Duplicate comments (1)
templates/dotnet/Package/Models/Model.cs.twig (1)

44-46: Guard “present-but-null” optionals; avoid running conversion on null.

TryGetValue(...) ? ... : null still executes the conversion when the key exists with a null value, which then NREs in array branches. Require non-null before parsing.

-                {%- 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 -%}
 {{ parse_value(property, mapAccess, v) }}
-                {%- if not property.required %} : null{% endif -%}
+                {%- if not property.required %} : null{% endif -%}
🧹 Nitpick comments (5)
src/SDK/Language/DotNet.php (4)

470-470: Doc typo: “sub_scheme” → “sub_schema”.

Fix the comment to match the function name and usage.

-     * get sub_scheme, property_name and parse_value functions
+     * get sub_schema, property_name and parse_value functions

568-575: Use Convert for booleans; avoid invalid casts.

Direct casts can throw if the backend returns “true”/“false” strings. Use Convert.ToBoolean for both required and optional.

-                    if ($required) {
-                        return "({$typeName}){$mapAccess}";
-                    }
-                    return "({$typeName}?){$v}";
+                    if ($required) {
+                        return "Convert.ToBoolean({$mapAccess})";
+                    }
+                    return "{$v} is null ? (bool?)null : (bool?)Convert.ToBoolean({$v})";

532-536: Enum optional: minor nullability/culture nit.

ToString() on non-string may be culture-sensitive (numbers/dates). If enums are string-backed, consider Convert.ToString(..., CultureInfo.InvariantCulture) for consistency.

-                    return "{$v} == null ? null : new {$enumClass}({$v}.ToString())";
+                    return "{$v} == null ? null : new {$enumClass}(Convert.ToString({$v}, System.Globalization.CultureInfo.InvariantCulture))";

578-582: String required: avoid “type name” strings for complex values.

{$mapAccess}.ToString() will produce type names for objects/arrays (e.g., System.Collections.Generic.Dictionary...). If the backend can return non-strings here, consider Convert.ToString(..., InvariantCulture) or validate type before ToString.

-                    return "{$mapAccess}.ToString()";
+                    return "Convert.ToString({$mapAccess}, System.Globalization.CultureInfo.InvariantCulture)";
templates/dotnet/Package/Models/Model.cs.twig (1)

39-46: Constructor init via From(...): consider consistent Nullable flow for arrays.

This relies on parse_value to return null for optionals. Ensure the array branch in parse_value is updated to return null (not []) when v is null or not an IEnumerable<object>, to preserve semantics and avoid silent defaulting.

Run the scan in my earlier comment and re-generate a sample model to validate that:

  • Optional arrays missing or null in JSON produce null properties.
  • Optional arrays with null elements don’t throw and omit nulls.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d9f6d71 and cc2cd62.

📒 Files selected for processing (2)
  • src/SDK/Language/DotNet.php (3 hunks)
  • templates/dotnet/Package/Models/Model.cs.twig (3 hunks)

Remove null-forgiving operator (!) from optional array mappings and use null-safe casting to preserve null vs empty semantics in generated models.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (2)
src/SDK/Language/DotNet.php (2)

538-555: Critical: Null elements silently converted to defaults or cause exceptions.

Past reviews identified that this code:

  1. Required arrays: Direct cast throws if source is null.
  2. Null elements: Convert.ToInt64(null) returns 0 and Convert.ToDouble(null) returns 0.0, silently converting nulls to default values.
  3. Boolean/string: (bool)x and .ToString() throw when x is null.

This can introduce incorrect data or runtime exceptions.

Filter null elements and add safe conversions:

                 if ($property['type'] === 'array') {
                     $itemsType = $property['items']['type'] ?? 'object';
-                    $src = $required ? $mapAccess : $v;
-                    $arraySource = $required 
-                        ? "((IEnumerable<object>){$src})" 
-                        : "({$src} as IEnumerable<object>)?";
                     
                     $selectExpression = match($itemsType) {
-                        'string' => 'x.ToString()',
-                        'integer' => 'Convert.ToInt64(x)',
-                        'number' => 'Convert.ToDouble(x)',
-                        'boolean' => '(bool)x',
+                        'string' => 'x?.ToString() ?? string.Empty',
+                        'integer' => 'Convert.ToInt64(x)',
+                        'number' => 'Convert.ToDouble(x)',
+                        'boolean' => 'Convert.ToBoolean(x)',
                         default => 'x'
                     };
                     
-                    return "{$arraySource}.Select(x => {$selectExpression}).ToList()";
+                    if ($required) {
+                        return "((IEnumerable<object>){$mapAccess})"
+                            . ".Where(x => x != null)"
+                            . ".Select(x => {$selectExpression})"
+                            . ".ToList()";
+                    }
+                    return "({$v} as IEnumerable<object>)?"
+                        . ".Where(x => x != null)"
+                        . ".Select(x => {$selectExpression})"
+                        . ".ToList()";
                 }

Based on past review comments.


514-519: Critical: Unsafe dictionary cast and missing null guards in sub-schema array mapping.

This code has three issues flagged in previous reviews:

  1. Required arrays: Direct cast ((IEnumerable<object>){$mapAccess}) will throw if source is null.
  2. Element casting: (Dictionary<string, object>)it throws InvalidCastException if any element is null or not a dictionary.
  3. Optional arrays: While ?.Select handles null source correctly, null elements inside the array are not filtered.

Apply a safe mapping pattern that filters null elements and validates types:

-                    if ($property['type'] === 'array') {
-                        $arraySource = $required 
-                            ? "((IEnumerable<object>){$mapAccess})" 
-                            : "({$v} as IEnumerable<object>)?";
-                        return "{$arraySource}.Select(it => {$subSchema}.From(map: (Dictionary<string, object>)it)).ToList()";
+                    if ($property['type'] === 'array') {
+                        if ($required) {
+                            return "((IEnumerable<object>){$mapAccess})"
+                                . ".Where(it => it is Dictionary<string, object>)"
+                                . ".Select(it => {$subSchema}.From(map: (Dictionary<string, object>)it))"
+                                . ".ToList()";
+                        }
+                        return "({$v} as IEnumerable<object>)?"
+                            . ".Where(it => it is Dictionary<string, object>)"
+                            . ".Select(it => {$subSchema}.From(map: (Dictionary<string, object>)it))"
+                            . ".ToList()";

Based on past review comments.

🧹 Nitpick comments (1)
src/SDK/Language/DotNet.php (1)

557-565: Use TryParse for numeric conversions to avoid runtime exceptions.
At src/SDK/Language/DotNet.php lines 557–565, the generated Convert.ToInt64({var})/Convert.ToDouble({var}) calls will throw FormatException or InvalidCastException on non-numeric input. Consider emitting Int64.TryParse/Double.TryParse patterns to validate and handle invalid values with clearer logic.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cc2cd62 and ebbad72.

📒 Files selected for processing (1)
  • src/SDK/Language/DotNet.php (3 hunks)
🔇 Additional comments (2)
src/SDK/Language/DotNet.php (2)

470-470: LGTM!

Documentation correctly updated to reflect the addition of the parse_value function.


497-497: LGTM!

Correctly marks the function output as safe HTML for Twig template rendering.

Adds conditional import of the Enums namespace in the Model.cs.twig template only when the model definition contains enum properties. This prevents unnecessary imports and improves template clarity.
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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/Models/Model.cs.twig (1)

53-53: Use safe cast for additionalProperties to prevent InvalidCastException.

The direct cast (Dictionary<string, object>)dataValue will throw InvalidCastException if dataValue is null or not a dictionary. Use a safe cast with null handling.

Apply this diff to add safe casting:

-            data: map.TryGetValue("data", out var dataValue) ? (Dictionary<string, object>)dataValue : map
+            data: map.TryGetValue("data", out var dataValue) && dataValue is Dictionary<string, object> dict ? dict : map
🧹 Nitpick comments (1)
templates/dotnet/Package/Models/Model.cs.twig (1)

1-85: Architecture: Dictionary-based deserialization improves robustness.

The refactoring from JsonElement-based to dictionary-based deserialization aligns well with the PR objectives of eliminating JsonElement leakage and streamlining model deserialization. The introduction of the DefinitionClass alias and the parse_value function-based approach improves template maintainability and consistency.

Key improvements:

  • Centralized value parsing logic via parse_value
  • Safer optional property handling with TryGetValue
  • Elimination of JsonElement special-casing
  • Consistent class naming via DefinitionClass alias

However, ensure that:

  1. The parse_value function correctly handles null values for optional properties (see verification request in lines 43-48)
  2. Safe casting is applied for additionalProperties deserialization (see issue flagged in line 53)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ff2545a and faad585.

📒 Files selected for processing (1)
  • templates/dotnet/Package/Models/Model.cs.twig (2 hunks)
🔇 Additional comments (6)
templates/dotnet/Package/Models/Model.cs.twig (6)

1-1: LGTM: Clean alias for maintainability.

Introducing the DefinitionClass alias improves template readability and ensures consistent class naming throughout the generated code.


7-9: LGTM: Conditional import reduces clutter.

The conditional import of the Enums namespace only when needed is a good practice that keeps generated code clean.


13-13: LGTM: Consistent use of DefinitionClass alias.

The class declaration, property declarations, and constructor signature correctly use the DefinitionClass alias, maintaining consistency throughout the template.

Also applies to: 17-17, 24-26


57-66: LGTM: ToMap method handles all property types correctly.

The ToMap method correctly handles sub-schemas (both objects and arrays), enums, and regular properties with appropriate transformations and null-safety operators.


67-84: LGTM: ConvertTo methods enable flexible type conversion.

The ConvertTo methods correctly delegate to user-provided conversion functions, enabling flexible handling of additional properties and nested structures.


43-48: Confirm null preservation in parse_value
The map.TryGetValue(...) guard still invokes parse_value if the stored value is null, which may yield default primitives instead of null. parse_value is implemented outside this template—please verify it returns null for null inputs or add an explicit null check around the call.

@Fellmonkey
Copy link
Contributor Author

@ChiragAgg5k, good evening.
It might be worth checking the PR, there are cases when de/serialization breaks.

@ChiragAgg5k
Copy link
Member

@Fellmonkey waiting on jake's review, will merge then 👍🏻

@Fellmonkey
Copy link
Contributor Author

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. {7CF5D045-1E68-46F6-A2E3-486785466DD8}

image {CB367DEA-584F-4434-BE90-4C4C135E57E3} {3BB6BE36-2545-4C44-8963-86834212B236}

It's strange here.

I checked GetDB and GetCache server definitely doesn't return any keys to the HealthStatus model.

We need to either edit the model specifications or see why the sever isn't returning this data.

@ChiragAgg5k
Copy link
Member

@Fellmonkey oh sorry missed your comment there, best to check in the appwrite codebase on why those values are not being returned, or if the model requires some adjustments

i did some testing myself and it seems to work just fine for me:

Screenshot 2025-10-04 at 9 17 05 PM

@Fellmonkey
Copy link
Contributor Author

@ChiragAgg5k It really works
{C7065359-2D91-4F62-9E56-8C6AA1C151C7}

But I don't understand what the problem is with the SDK; the Dart server has the same problem.

{95741ADA-17E0-429D-A83D-03E51F7393FB}

I made some changes in Dart, because otherwise an error would occur: type 'Null' is not a subtype of type 'int'
{834C72F4-FC45-49B5-B9A9-21D8C097CEE1}

@ChiragAgg5k
Copy link
Member

@Fellmonkey interesting it doesnt work in dart, would be great to figure out why. can you please create a seperate issue for tracking this 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants