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

Aperta
#8,819 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 2 giorni

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
48/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
php, symfony

Direzione di ricerca

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.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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

Lingua principale
PHP
Stelle
2.6k
Fork
988
Merge medio
4g 10h
PR unite (30g)
114

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di api-platform/core

Tutte le issue di api-platform/core

Issue simili

Altre issue su PHP

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.