Skip to content

fix(json): give on_start the object's own writer and pass the hooks to nested parse nodes - #807

Merged
Vincent Biret (baywet) merged 2 commits into
microsoft:mainfrom
HardMax71:fix/json-hooks-for-nested-objects
Oct 15, 2026
Merged

Vincent Biret (baywet) merged 2 commits into
microsoft:mainfrom
HardMax71:fix/json-hooks-for-nested-objects

Conversation

@HardMax71

@HardMax71 Max Azatian (HardMax71) commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Two bugs in how the JSON package runs the serialization and parsing hooks. Both repros use this backed model:

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Optional

from kiota_abstractions.serialization import Parsable
from kiota_abstractions.store import (
    BackedModel,
    BackingStore,
    BackingStoreFactorySingleton,
    BackingStoreSerializationWriterProxyFactory,
)
from kiota_serialization_json.json_parse_node import JsonParseNode
from kiota_serialization_json.json_serialization_writer_factory import JsonSerializationWriterFactory


@dataclass
class Item(BackedModel, Parsable):
    backing_store: BackingStore = field(
        default_factory=BackingStoreFactorySingleton(backing_store_factory=None).backing_store_factory.create_backing_store,
        repr=False,
    )
    name: Optional[str] = None
    child: Optional[Item] = None

    @staticmethod
    def create_from_discriminator_value(parse_node):
        return Item()

    def get_field_deserializers(self):
        return {
            "name": lambda n: setattr(self, "name", n.get_str_value()),
            "child": lambda n: setattr(self, "child", n.get_object_value(Item)),
        }

    def serialize(self, writer):
        writer.write_str_value("name", self.name)
        writer.write_object_value("child", self.child)

JsonSerializationWriter gave on_start_object_serialization the outer writer, but each object is written into a temp writer. The backing store uses on_start to write null for properties set to None, so those nulls land in the wrong object:

factory = BackingStoreSerializationWriterProxyFactory(JsonSerializationWriterFactory())

item = Item(name="a")
item.name = None
writer = factory.get_serialization_writer("application/json")
writer.write_object_value(None, item)
writer.get_serialized_content()
# main: ValueError: Invalid Json output
# now:  b'{"name": null}'

parent = Item(name="p", child=Item(name="c"))
parent.child.name = None
writer = factory.get_serialization_writer("application/json")
writer.write_object_value(None, parent)
writer.get_serialized_content()
# main: b'{"name": null, "child": {}}'
# now:  b'{"name": "p", "child": {"name": null}}'

on_start now gets the writer the object is written to, like in C#. msgraph-sdk-python turns on the backing store in its client constructor, so a PATCH that clears a property goes through this.

JsonParseNode built each field's node without the before and after hooks, so they ran only for the root object:

seen = []
node = JsonParseNode(json.loads('{"name": "p", "child": {"name": "c"}}'))
node.on_before_assign_field_values = seen.append
node.get_object_value(Item)
len(seen)
# main: 1
# now:  2

_create_new_node copies the hooks, like in C#, so field nodes now get them.

Related Issue

No issue. Found while typing the proxy factories for #722 (#806).

Testing Instructions

  • pytest in packages/serialization/json passes. The three new tests fail without the source change.
  • yapf -dr kiota_serialization_json, pylint and mypy pass.
  • The repros above give the "main" output on main and the "now" output on this branch.

…o nested parse nodes

JsonSerializationWriter called on_start_object_serialization with the outer writer, but the object is written into a temp writer. The backing store uses on_start to write null for properties set to None. So clearing a property on the root model raised "Invalid Json output", and on a nested model the null went into the parent object. on_start now gets the temp writer, which is the writer the object is written to, like in C#.

JsonParseNode built each field's node without the before and after hooks, so they only ran for the root object. Field nodes now come from _create_new_node, which copies them, like the C# parse node.
@HardMax71
Max Azatian (HardMax71) requested a review from a team as a code owner October 15, 2026 16:04
Copilot AI balanced review requested due to automatic review settings October 15, 2026 16:04

This comment was marked as off-topic.

@baywet Vincent Biret (baywet) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the contribution!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

Both focused fixes match the hook contracts and have targeted regression coverage, with no unresolved findings.

0 open findings

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 15, 2026 17:03

This comment was marked as off-topic.

@sonarqubecloud

Copy link
Copy Markdown

@baywet
Vincent Biret (baywet) merged commit a94454e into microsoft:main Oct 15, 2026
122 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress 🚧 to Done ✔️ in Kiota Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done ✔️

Development

Successfully merging this pull request may close these issues.

3 participants