Skip to content

JSON Schema: description, default, example, readOnly and constraint-derived keys (maxItems, …) are dropped from properties that reference another schema ($ref / anyOf) — regression vs 4.1 #8453

Description

@marepust

API Platform version(s) affected: 4.3.17 (4.1.30 behaves correctly; observed regression somewhere in 4.2/4.3)

Description
Any property whose type is another class — a single reference, a nullable reference, or a collection of references — loses all property-level metadata in the generated OpenAPI/JSON Schema document:

  • description (from #[ApiProperty(description: ...)] and from the property docblock)
  • default / example (from #[ApiProperty] or its openapiContext)
  • readOnly (from openapiContext)
  • validator-derived restrictions, e.g. #[Assert\Count(max: 1)]maxItems

Scalar properties are unaffected; only reference-shaped properties lose their metadata.

OpenAPI 3.1 / JSON Schema 2020-12 explicitly allow annotation keywords next to $ref, so this metadata was meaningful in the produced document (docs renderers such as ReDoc display it). readOnly and maxItems are also behavioral, not just prose: code generators consuming the spec now offer read-only fields as writable input and no longer enforce collection limits.

How to reproduce

Self-contained, version-agnostic PHPUnit test (attached; no kernel — real SchemaFactory, real SchemaPropertyMetadataFactory, and api-platform's own attribute + property-info extraction chain, so the #[ApiProperty] attributes on the fixtures are read exactly as in a real app). Run against both versions with nothing changed but the api-platform/core version:

api-platform/core 4.1.30:
OK (5 tests, 8 assertions)

api-platform/core 4.3.17:
1) SchemaFactoryRefAnnotationsTest::testDescriptionIsKeptOnNullableObjectReference
2) SchemaFactoryRefAnnotationsTest::testDescriptionIsKeptOnArrayOfReferences
3) SchemaFactoryRefAnnotationsTest::testOpenapiContextReadOnlyIsKeptOnArrayOfReferences
4) SchemaFactoryRefAnnotationsTest::testCountConstraintRestrictionsAreKeptOnArrayOfReferences
   (minItems from Assert\Count(min: 2) is lost)
FAILURES! Tests: 5, Assertions: 7, Failures: 4.

(The sanity test confirming the anyOf/$ref/array structures themselves passes on both versions — only the annotations go missing on 4.3.)

SchemaFactoryRefAnnotationsTest.php (drop anywhere, run with vendor/bin/phpunit path/to/file)
<?php

declare(strict_types=1);

/*
 * Standalone reproducer for an api-platform/core bug report
 * Property-level annotations (description, readOnly, ...) are dropped from the generated
 * JSON Schema for properties that reference another class ($ref / anyOf / array of $ref).
 *
 *   - api-platform/core 4.1.30: PASSES
 *   - api-platform/core 4.3.17: FAILS
 *
 * The test is version-agnostic: it uses api-platform's own metadata extraction
 * (AttributePropertyMetadataFactory + PropertyInfoPropertyMetadataFactory), so the
 * #[ApiProperty] attributes on the fixture classes below are read exactly as in a real app.
 *
 * Run:  vendor/bin/phpunit <path-to-this-file>
 */

namespace App\Tests\Reproducer;

use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory;
use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactory;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\AttributePropertyMetadataFactory;
use ApiPlatform\Metadata\Property\Factory\PropertyInfoPropertyMetadataFactory;
use ApiPlatform\Metadata\Property\Factory\PropertyInfoPropertyNameCollectionFactory;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction;
use ApiPlatform\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory;
use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;

class SchemaFactoryRefAnnotationsTest extends TestCase
{
    public function testReferenceStructuresAreGenerated(): void
    {
        [$pickupContact, $items] = $this->buildOrderProperties();

        // sanity — passes on every version; only the annotations below go missing
        self::assertNotEmpty(
            $pickupContact['anyOf'] ?? $pickupContact['$ref'] ?? null,
            'nullable object property should reference the Contact schema'
        );
        self::assertSame('array', $items['type'] ?? null);
        self::assertArrayHasKey('$ref', (array) ($items['items'] ?? []));
    }

    public function testDescriptionIsKeptOnNullableObjectReference(): void
    {
        [$pickupContact,] = $this->buildOrderProperties();

        self::assertSame(
            'Pickup contact, required for order placement.',
            $pickupContact['description'] ?? null,
        );
    }

    public function testDescriptionIsKeptOnArrayOfReferences(): void
    {
        [, $items] = $this->buildOrderProperties();

        self::assertSame(
            'Only one allowed per order.',
            $items['description'] ?? null,
        );
    }

    public function testOpenapiContextReadOnlyIsKeptOnArrayOfReferences(): void
    {
        [, $items] = $this->buildOrderProperties();

        self::assertTrue($items['readOnly'] ?? false);
    }

    public function testCountConstraintRestrictionsAreKeptOnArrayOfReferences(): void
    {
        [, $items] = $this->buildOrderProperties();

        self::assertSame(2, $items['minItems'] ?? null, 'minItems from Assert\\Count(min: 2) is lost');
        self::assertSame(5, $items['maxItems'] ?? null, 'maxItems from Assert\\Count(max: 5) is lost');
    }

    /**
     * @return array{0: array, 1: array} the pickupContact and items property schemas of ReproducerOrder
     */
    private function buildOrderProperties(): array
    {
        // OpenAPI flavor — the same call the OpenAPI factory makes when building /docs.json
        $schema = $this->buildSchemaFactory()->buildSchema(
            ReproducerOrder::class,
            'json',
            Schema::TYPE_OUTPUT,
            null,
            new Schema(Schema::VERSION_OPENAPI),
        );

        $order = $schema->getDefinitions()[$this->definitionNameFor($schema, 'pickupContact')];

        return [(array) $order['properties']['pickupContact'], (array) $order['properties']['items']];
    }

    private function buildSchemaFactory(): SchemaFactory
    {
        $reflection = new ReflectionExtractor();
        $phpDoc = new PhpDocExtractor();
        $propertyInfo = new PropertyInfoExtractor(
            [$reflection],
            [$phpDoc, $reflection],
            [$phpDoc],
            [$reflection],
            [$reflection],
        );

        $resourceClassResolver = new class implements ResourceClassResolverInterface {
            public function getResourceClass(mixed $value, ?string $resourceClass = null, bool $strict = false): string
            {
                return \is_object($value) ? $value::class : (string) $resourceClass;
            }

            public function isResourceClass(string $type): bool
            {
                return false; // plain (non-resource) classes, like embedded DTOs
            }
        };

        $resourceMetadataFactory = new class implements ResourceMetadataCollectionFactoryInterface {
            public function create(string $resourceClass): ResourceMetadataCollection
            {
                return new ResourceMetadataCollection($resourceClass);
            }
        };

        // api-platform's real extraction chain: attributes -> property-info -> validator restrictions -> schema
        $propertyMetadataFactory = new SchemaPropertyMetadataFactory(
            $resourceClassResolver,
            new ValidatorPropertyMetadataFactory(
                new LazyLoadingMetadataFactory(new AttributeLoader()),
                new PropertyInfoPropertyMetadataFactory(
                    $propertyInfo,
                    new AttributePropertyMetadataFactory(),
                ),
                [new PropertySchemaCountRestriction()],
            ),
        );

        return new SchemaFactory(
            $resourceMetadataFactory,
            new PropertyInfoPropertyNameCollectionFactory($propertyInfo),
            $propertyMetadataFactory,
            null,
            $resourceClassResolver,
        );
    }

    private function definitionNameFor(Schema $schema, string $propertyName): string
    {
        foreach ($schema->getDefinitions() as $name => $definition) {
            if (isset($definition['properties'][$propertyName])) {
                return $name;
            }
        }

        self::fail(\sprintf('No definition with property "%s" was generated.', $propertyName));
    }
}

class ReproducerOrder
{
    #[ApiProperty(description: 'Pickup contact, required for order placement.')]
    public ?ReproducerContact $pickupContact = null;

    /** @var ReproducerItem[] */
    #[ApiProperty(description: 'Only one allowed per order.', openapiContext: ['readOnly' => true])]
    #[Assert\Count(min: 2, max: 5)]
    public array $items = [];
}

class ReproducerContact
{
    public string $name;
}

class ReproducerItem
{
    public string $sku;
}
#[ApiResource(operations: [new Post()])]
class Order
{
    /** Pickup contact, required for order placement. */
    #[ApiProperty(description: 'Pickup contact, required for order placement.')]
    public ?Contact $pickupContact = null;

    /**
     * Only one allowed per order.
     * @var Item[]
     */
    #[Assert\Count(max: 1)]
    #[ApiProperty(openapiContext: ['readOnly' => true])]
    public array $items = [];
}

class Contact
{
    public string $name;
}

class Item
{
    public string $sku;
}

Generated Order schema with 4.1.30:

{
  "pickupContact": {
    "anyOf": [
      { "$ref": "#/components/schemas/Contact" },
      { "type": "null" }
    ],
    "description": "Pickup contact, required for order placement."
  },
  "items": {
    "type": "array",
    "items": { "$ref": "#/components/schemas/Item" },
    "description": "Only one allowed per order.",
    "maxItems": 1,
    "readOnly": true
  }
}

Generated Order schema with 4.3.17 (same code):

{
  "pickupContact": {
    "anyOf": [
      { "$ref": "#/components/schemas/Contact" },
      { "type": "null" }
    ]
  },
  "items": {
    "type": "array",
    "items": { "$ref": "#/components/schemas/Item" }
  }
}

description, maxItems and readOnly are gone.

Possible Solution
The metadata is discarded in three spots of ApiPlatform\JsonSchema\SchemaFactory::buildPropertySchema():

  1. For single/nullable object references, the property schema built so far (which still carries the metadata) is replaced instead of merged:
if (($c = \count($refs)) > 1) {
    $propertySchema = ['anyOf' => $refs];          // <-- metadata discarded
} elseif (1 === $c) {
    $propertySchema = ['$ref' => $refs[0]['$ref']]; // <-- metadata discarded
}
  1. For collections, it is lost earlier — a property schema considered "unknown" is reset wholesale before the items/$ref structure is added:
if (Schema::UNKNOWN_TYPE === $propertySchemaType) {
    $propertySchema = [];                           // <-- metadata discarded
}
  1. The readOnly tail condition explicitly skips $ref-shaped schemas (... && !isset($propertySchema['$ref'])).

Suggested fix: preserve the annotation keys when building the reference structure, e.g. merge instead of replace. Sibling annotations next to $ref are valid in JSON Schema 2020-12 / OpenAPI 3.1; if draft-4 output must stay sibling-free, the existing BackwardCompatibleSchemaFactory would be a natural place to strip them for that version only.

I'm happy to turn this into a PR if the merge-instead-of-replace direction is acceptable.

Additional Context
Found while upgrading a production API from 4.1.30 to 4.3.17: a snapshot test on the generated OpenAPI document flagged ~25 property descriptions/defaults/constraints silently disappearing — all of them on properties typed as embedded DTOs. Downstream we currently work around it with a SchemaFactoryInterface decorator that re-applies ApiProperty::getOpenapiContext(), ApiProperty::getSchema(), getDescription(), getDefault() and getExample() onto reference-shaped property schemas (filling missing keys only, never overwriting generated values).

Environment: symfony/* 7.4.16, PHP 8.3, formats json only (no JSON-LD/Hydra).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions