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

Fermée
#8,819 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Les mainteneurs répondent en général sous 2 jours

Personne n'a encore pris cette issue.

Évaluation

Difficulté
4/5
Temps estimé
3-5 jours
Accessibilité débutants
48/100
Type d'issue
Bug
Clarté
Clairement spécifiée
Activité
Active
Stack technique
php, symfony

Piste de recherche

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.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Description

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

Langage dominant
PHP
Étoiles
2.6k
Forks
988
Merge moyen
4 j 10 h
PR mergées (30 j)
109

Préparer son environnement

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de api-platform/core

Toutes les issues de api-platform/core

Issues similaires

Plus d'issues PHP

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.