Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,4 +258,12 @@
->private()
->tag('nelmio_api_doc.type_describer', ['priority' => -1000])
;

if (class_exists(\Symfony\Component\TypeInfo\Type\ArrayShapeType::class)) {
$container->services()
->set('nelmio_api_doc.type_describer.array_shape', \Nelmio\ApiDocBundle\TypeDescriber\ArrayShapeDescriber::class)
->private()
->tag('nelmio_api_doc.type_describer', ['priority' => -990])
;
}
};
58 changes: 58 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,61 @@ parameters:
identifier: class.notFound
count: 2
path: src/Render/Html/HtmlOpenApiRenderer.php

-
message: '#^PHPDoc tag @implements has invalid type Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType\.$#'
count: 1
path: src/TypeDescriber/ArrayShapeDescriber.php

-
message: '#^Type Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType in generic type Nelmio\\ApiDocBundle\\TypeDescriber\\TypeDescriberInterface\<Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType\> in PHPDoc tag @implements is not subtype of template type T of Symfony\\Component\\TypeInfo\\Type of interface Nelmio\\ApiDocBundle\\TypeDescriber\\TypeDescriberInterface\.$#'
count: 1
path: src/TypeDescriber/ArrayShapeDescriber.php

-
message: '#^Parameter \$type of method Nelmio\\ApiDocBundle\\TypeDescriber\\ArrayShapeDescriber\:\:describe\(\) has invalid type Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType\.$#'
identifier: class.notFound
count: 1
path: src/TypeDescriber/ArrayShapeDescriber.php

-
message: '#^Call to an undefined method Symfony\\Component\\TypeInfo\\Type\:\:getShape\(\)\.$#'
identifier: method.notFound
count: 1
path: src/TypeDescriber/ArrayShapeDescriber.php

-
message: '#^Call to an undefined method Symfony\\Component\\TypeInfo\\Type\:\:isSealed\(\)\.$#'
identifier: method.notFound
count: 1
path: src/TypeDescriber/ArrayShapeDescriber.php

-
message: '#^Call to an undefined method Symfony\\Component\\TypeInfo\\Type\:\:getExtraValueType\(\)\.$#'
identifier: method.notFound
count: 1
path: src/TypeDescriber/ArrayShapeDescriber.php

-
message: '#^Parameter \$type of method Nelmio\\ApiDocBundle\\TypeDescriber\\ArrayShapeDescriber\:\:supports\(\) has invalid type Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType\.$#'
identifier: class.notFound
count: 1
path: src/TypeDescriber/ArrayShapeDescriber.php

-
message: '#^Class Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType not found\.$#'
identifier: class.notFound
count: 1
path: src/TypeDescriber/ArrayShapeDescriber.php

-
message: '#^Class Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType not found\.$#'
identifier: class.notFound
count: 1
path: src/TypeDescriber/DictionaryDescriber.php

-
message: '#^Class Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType not found\.$#'
identifier: class.notFound
count: 1
path: src/TypeDescriber/ListDescriber.php
Comment on lines +90 to +101
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

The new baseline entries for DictionaryDescriber/ListDescriber are currently required only because the code hard-references Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType. Once the describers use a safe string-based/guarded check, these ignores should be removed to avoid permanently masking real analysis issues in those files.

Suggested change
-
message: '#^Class Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType not found\.$#'
identifier: class.notFound
count: 1
path: src/TypeDescriber/DictionaryDescriber.php
-
message: '#^Class Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType not found\.$#'
identifier: class.notFound
count: 1
path: src/TypeDescriber/ListDescriber.php

Copilot uses AI. Check for mistakes.
59 changes: 59 additions & 0 deletions src/TypeDescriber/ArrayShapeDescriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Nelmio\ApiDocBundle\TypeDescriber;

use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Annotations\Schema;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\ArrayShapeType;

/**
* @implements TypeDescriberInterface<ArrayShapeType>
*
* @internal
*/
final class ArrayShapeDescriber implements TypeDescriberInterface, TypeDescriberAwareInterface
{
use TypeDescriberAwareTrait;

public function describe(Type $type, Schema $schema, array $context = []): void
{
$schema->type = 'object';
$required = [];

foreach ($type->getShape() as $key => $shapeValue) {
$property = Util::getProperty($schema, (string) $key);
$this->describer->describe($shapeValue['type'], $property, $context);

if (!$shapeValue['optional']) {
$required[] = (string) $key;
}
}

if ([] !== $required) {
$schema->required = $required;
}

if (!$type->isSealed()) {
$additionalProperties = Util::getChild($schema, OA\AdditionalProperties::class);
$this->describer->describe($type->getExtraValueType(), $additionalProperties, $context);
} else {
$schema->additionalProperties = false;
}
}

public function supports(Type $type, array $context = []): bool
{
return $type instanceof ArrayShapeType;
}
}
1 change: 1 addition & 0 deletions src/TypeDescriber/DictionaryDescriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public function describe(Type $type, Schema $schema, array $context = []): void
public function supports(Type $type, array $context = []): bool
{
return $type instanceof CollectionType
&& !$type instanceof Type\ArrayShapeType
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

The instanceof Type\ArrayShapeType exclusion is a runtime-fatal check when the ArrayShapeType class isn't present (Symfony < 7.3). Replace it with a guarded class_exists + is_a(...) check, or use the fully-qualified class name string, so the describer remains compatible across supported Symfony versions.

Suggested change
&& !$type instanceof Type\ArrayShapeType
&& (
!class_exists(Type\ArrayShapeType::class)
|| !is_a($type, Type\ArrayShapeType::class)
)

Copilot uses AI. Check for mistakes.
&& $type->getCollectionKeyType() instanceof Type\BuiltinType
&& TypeIdentifier::STRING === $type->getCollectionKeyType()->getTypeIdentifier();
}
Expand Down
1 change: 1 addition & 0 deletions src/TypeDescriber/ListDescriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public function describe(Type $type, Schema $schema, array $context = []): void
public function supports(Type $type, array $context = []): bool
{
return $type instanceof CollectionType
&& !$type instanceof Type\ArrayShapeType
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

The instanceof Type\ArrayShapeType check will throw a runtime Error on Symfony versions where Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType doesn't exist (because instanceof against a missing class is fatal). Use a safe check (e.g., class_exists(...) && is_a($type, 'Symfony\\Component\\TypeInfo\\Type\\ArrayShapeType')) or compare against a string class name to avoid hard-referencing a non-existent class.

Suggested change
&& !$type instanceof Type\ArrayShapeType
&& !(class_exists(Type\ArrayShapeType::class)
&& is_a($type, Type\ArrayShapeType::class))

Copilot uses AI. Check for mistakes.
&& $type->getCollectionKeyType() instanceof Type\BuiltinType
&& TypeIdentifier::INT === $type->getCollectionKeyType()->getTypeIdentifier();
}
Expand Down
32 changes: 32 additions & 0 deletions tests/Functional/Controller/ArrayShapeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;

use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\ArrayShapeTypes;
use OpenApi\Attributes as OA;
use Symfony\Component\Routing\Attribute\Route;

class ArrayShapeController
{
#[OA\Response(
response: '200',
description: 'Success',
content: new Model(type: ArrayShapeTypes::class),
)]
#[Route('/array-shapes', methods: ['GET'])]
public function arrayShapesAction(): void
{
}
}
13 changes: 13 additions & 0 deletions tests/Functional/ControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,19 @@ static function (RoutingConfigurator $routes) {
];
}

if (version_compare(Kernel::VERSION, '7.3.0', '>=') && class_exists(TypeInfoType\ArrayShapeType::class)) {
yield 'Array shape types' => [
'ArrayShapeController',
version_compare(Kernel::VERSION, '7.4.0', '>=') ? null : 'symfony-7.3',
[],
[
'nelmio_api_doc' => [
'type_info' => true,
],
],
];
}

yield 'Custom model names' => [
'CustomModelNameController',
];
Expand Down
32 changes: 32 additions & 0 deletions tests/Functional/Entity/ArrayShapeTypes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Nelmio\ApiDocBundle\Tests\Functional\Entity;

class ArrayShapeTypes
{
/** @var array{name: string, age: int, email?: string} */
public array $sealed;

/** @var array{id: int, label: string, ...} */
public array $unsealed;

/** @var array{user: array{name: string, age: int}, active: bool} */
public array $nested;

/** @var array{0: string, 1: int} */
public array $numericKeys;

/** @var array{name: ?string, email: ?string} */
public array $nullableValues;
}
142 changes: 142 additions & 0 deletions tests/Functional/Fixtures/ArrayShapeController.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
{
"openapi": "3.0.0",
"info": {
"title": "",
"version": "0.0.0"
},
"paths": {
"/array-shapes": {
"get": {
"operationId": "get_nelmio_apidoc_tests_functional_arrayshape_arrayshapes",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArrayShapeTypes"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ArrayShapeTypes": {
"required": [
"sealed",
"unsealed",
"nested",
"numericKeys",
"nullableValues"
],
"properties": {
"sealed": {
"required": [
"age",
"name"
],
"properties": {
"age": {
"type": "integer"
},
"email": {
"type": "string"
},
"name": {
"type": "string"
}
},
"type": "object",
"additionalProperties": false
},
"unsealed": {
"required": [
"id",
"label"
],
"properties": {
"id": {
"type": "integer"
},
"label": {
"type": "string"
}
},
"type": "object",
"additionalProperties": {
"nullable": true
}
},
"nested": {
"required": [
"active",
"user"
],
"properties": {
"active": {
"type": "boolean"
},
"user": {
"required": [
"age",
"name"
],
"properties": {
"age": {
"type": "integer"
},
"name": {
"type": "string"
}
},
"type": "object",
"additionalProperties": false
}
},
"type": "object",
"additionalProperties": false
},
"numericKeys": {
"required": [
"0",
"1"
],
"properties": {
"0": {
"type": "string"
},
"1": {
"type": "integer"
}
},
"type": "object",
"additionalProperties": false
},
"nullableValues": {
"required": [
"email",
"name"
],
"properties": {
"email": {
"type": "string",
"nullable": true
},
"name": {
"type": "string",
"nullable": true
}
},
"type": "object",
"additionalProperties": false
}
},
"type": "object"
}
}
}
}
Loading