`json_stream`: objects inside a list stay uninitialized lazy ghosts, so `#[Assert\Valid]` validates them as blank

未关闭
#8,819 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 2 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
48/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
php, symfony

调研方向

Start in JsonStreamerProvider and trace how streamed objects in list properties reach ValidateProvider; compare this path with the non-streaming Serializer path. Check the relevant provider and validation tests, then add a regression case for a DTO list with #[Assert\Valid] and #[Assert\NotBlank]. Done means valid list items pass validation and invalid ones still produce violations.

由索引模型根据 Issue 内容生成。

描述

API Platform version(s) affected: 5.0.5 (api-platform/symfony, api-platform/serializer); the same code is on main

Environment: PHP 8.5.4, symfony/json-streamer 8.1.10, symfony/validator 8.1.11

Description

When an operation uses jsonStream: true, ApiPlatform\Serializer\State\JsonStreamerProvider reads the request body with StreamReaderInterface::read($request->getContent(true), …). Because the input is a stream, JsonStreamer builds the objects through its LazyInstantiator (PHP lazy ghosts). The provider then initializes only the top-level object:

if (\PHP_VERSION_ID > 80400) {
    $refl = new \ReflectionClass($data);
    if ($refl->isUninitializedLazyObject($data)) {
        $refl->initializeLazyObject($data);
    }
}

Objects inside a list<…> property are still uninitialized ghosts when ValidateProvider runs. The Validator reads properties through PropertyMetadata::getPropertyValue(), which returns null when ReflectionProperty::isInitialized() is false. On an uninitialized ghost, isInitialized() returns false and does not trigger initialization. So every constraint on a list item sees an empty value, and a valid payload is rejected with 422.

A single nested object property (public ?Line $line) is not affected in my tests: only objects that are list items are.

How to reproduce

<?php

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(operations: [new Post(uriTemplate: '/repro/orders', processor: [self::class, 'echo'])], jsonStream: true)]
final class ReproOrder
{
    /** @var list<ReproLine> */
    #[Assert\Valid]
    public array $lines = [];

    public static function echo(self $data): self
    {
        return $data;
    }
}

final class ReproLine
{
    #[Assert\NotBlank]
    public string $label = '';
}

with formats: { json: ['application/json'] } in api_platform.yaml, then:

curl -X POST http://localhost/api/repro/orders \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"lines":[{"label":"A"},{"label":"B"}]}'

Expected: 201 {"lines":[{"label":"A"},{"label":"B"}]}

Actual:

422 {"status":422,"violations":[
  {"propertyPath":"lines[0].label","message":"This value should not be blank.", …},
  {"propertyPath":"lines[1].label","message":"This value should not be blank.", …}
], …}

The same request without jsonStream: true (Serializer path) returns 201.

Possible Solution

Initialize the whole object graph in JsonStreamerProvider after reading, not just the root. Here is a userland workaround that fixes it: a decorator placed between the JSON streamer provider (priority 310) and deserialize/validate (300/200):

#[AsDecorator('api_platform.state_provider.main', priority: 305)]
final readonly class InitializeStreamedObjects implements ProviderInterface
{
    public function __construct(#[AutowireDecorated] private ProviderInterface $inner)
    {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $data = $this->inner->provide($operation, $uriVariables, $context);

        if (($context['request'] ?? null)?->attributes->get('deserialized')) {
            self::initialize($data);
        }

        return $data;
    }

    private static function initialize(mixed $value): void
    {
        if (is_array($value)) {
            array_walk($value, static fn (mixed $item) => self::initialize($item));

            return;
        }

        if (!is_object($value)) {
            return;
        }

        $class = new \ReflectionClass($value);
        $class->initializeLazyObject($value);
        array_map(static fn (\ReflectionProperty $property) => self::initialize($property->isInitialized($value) ? $property->getValue($value) : null), $class->getProperties(\ReflectionProperty::IS_PUBLIC));
    }
}

Alternatively, the Validator could treat uninitialized lazy objects specially, though that seems a wider change than fixing it where API Platform hands the object over.

Additional Context

Found while moving an app's write endpoints to json_stream: true with nested DTO lists (a check-list editor PUTting lists of items).

主要语言
PHP
星标
2.6k
派生
988
平均合并
4 天 10 小时
30 天内合并 PR
114

环境准备

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

api-platform/core 的其他 Issue

查看 api-platform/core 的全部 Issue

相似的 Issue

更多 PHP Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。