From 36f5bb44f0cb79b4a4b6c32b12fd950b3ce3be3e Mon Sep 17 00:00:00 2001 From: HaKIM Date: Sun, 14 Sep 2025 14:02:51 +0200 Subject: [PATCH] [Agent][Platform] Add support for native union types and list of polymporphic* types by using `DiscriminatorMap` --- ...tured-output-list-of-polymorphic-items.php | 33 ++++++ .../openai/structured-output-union-types.php | 36 ++++++ .../PolymorphicType/ListItemAge.php | 24 ++++ .../PolymorphicType/ListItemDiscriminator.php | 33 ++++++ .../PolymorphicType/ListItemName.php | 24 ++++ .../ListOfPolymorphicTypesDto.php | 26 +++++ .../UnionType/HumanReadableTimeUnion.php | 19 ++++ .../UnionType/UnionTypeDto.php | 20 ++++ .../UnionType/UnixTimestampUnion.php | 19 ++++ src/agent/CHANGELOG.md | 1 + .../src/StructuredOutput/AgentProcessor.php | 21 +++- .../StructuredOutput/AgentProcessorTest.php | 104 ++++++++++++++++++ .../src/Contract/JsonSchema/Factory.php | 71 +++++++++++- .../tests/Contract/JsonSchema/FactoryTest.php | 96 ++++++++++++++++ 14 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 examples/openai/structured-output-list-of-polymorphic-items.php create mode 100644 examples/openai/structured-output-union-types.php create mode 100644 fixtures/StructuredOutput/PolymorphicType/ListItemAge.php create mode 100644 fixtures/StructuredOutput/PolymorphicType/ListItemDiscriminator.php create mode 100644 fixtures/StructuredOutput/PolymorphicType/ListItemName.php create mode 100644 fixtures/StructuredOutput/PolymorphicType/ListOfPolymorphicTypesDto.php create mode 100644 fixtures/StructuredOutput/UnionType/HumanReadableTimeUnion.php create mode 100644 fixtures/StructuredOutput/UnionType/UnionTypeDto.php create mode 100644 fixtures/StructuredOutput/UnionType/UnixTimestampUnion.php diff --git a/examples/openai/structured-output-list-of-polymorphic-items.php b/examples/openai/structured-output-list-of-polymorphic-items.php new file mode 100644 index 000000000..eba131e5f --- /dev/null +++ b/examples/openai/structured-output-list-of-polymorphic-items.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\StructuredOutput\AgentProcessor; +use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListOfPolymorphicTypesDto; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$model = new Gpt(Gpt::GPT_4O_MINI); + +$processor = new AgentProcessor(); +$agent = new Agent($platform, $model, [$processor], [$processor], logger: logger()); +$messages = new MessageBag( + Message::forSystem('You are a persona data collector! Return all the data you can gather from the user input.'), + Message::ofUser('Hi! My name is John Doe, I am 30 years old and I live in Paris.'), +); +$result = $agent->call($messages, ['output_structure' => ListOfPolymorphicTypesDto::class]); + +dump($result->getContent()); diff --git a/examples/openai/structured-output-union-types.php b/examples/openai/structured-output-union-types.php new file mode 100644 index 000000000..7cee79984 --- /dev/null +++ b/examples/openai/structured-output-union-types.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\StructuredOutput\AgentProcessor; +use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnionTypeDto; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$model = new Gpt(Gpt::GPT_4O_MINI); + +$processor = new AgentProcessor(); +$agent = new Agent($platform, $model, [$processor], [$processor], logger: logger()); +$messages = new MessageBag( + Message::forSystem(<<call($messages, ['output_structure' => UnionTypeDto::class]); + +dump($result->getContent()); diff --git a/fixtures/StructuredOutput/PolymorphicType/ListItemAge.php b/fixtures/StructuredOutput/PolymorphicType/ListItemAge.php new file mode 100644 index 000000000..309157cad --- /dev/null +++ b/fixtures/StructuredOutput/PolymorphicType/ListItemAge.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput\PolymorphicType; + +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; + +final class ListItemAge implements ListItemDiscriminator +{ + public function __construct( + public int $age, + #[With(pattern: '^age$')] + public string $type = 'age', + ) { + } +} diff --git a/fixtures/StructuredOutput/PolymorphicType/ListItemDiscriminator.php b/fixtures/StructuredOutput/PolymorphicType/ListItemDiscriminator.php new file mode 100644 index 000000000..4f42e9caa --- /dev/null +++ b/fixtures/StructuredOutput/PolymorphicType/ListItemDiscriminator.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput\PolymorphicType; + +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; + +#[DiscriminatorMap( + typeProperty: 'type', + mapping: [ + 'name' => ListItemName::class, + 'age' => ListItemAge::class, + ] +)] +/** + * @property string $type + * + * With the PHP 8.4^ you can replace the property annotation with a property hook: + * public string $type { + * get; + * } + */ +interface ListItemDiscriminator +{ +} diff --git a/fixtures/StructuredOutput/PolymorphicType/ListItemName.php b/fixtures/StructuredOutput/PolymorphicType/ListItemName.php new file mode 100644 index 000000000..4270c7b0c --- /dev/null +++ b/fixtures/StructuredOutput/PolymorphicType/ListItemName.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput\PolymorphicType; + +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; + +class ListItemName implements ListItemDiscriminator +{ + public function __construct( + public string $name, + #[With(pattern: '^name$')] + public string $type = 'name', + ) { + } +} diff --git a/fixtures/StructuredOutput/PolymorphicType/ListOfPolymorphicTypesDto.php b/fixtures/StructuredOutput/PolymorphicType/ListOfPolymorphicTypesDto.php new file mode 100644 index 000000000..59e935cfd --- /dev/null +++ b/fixtures/StructuredOutput/PolymorphicType/ListOfPolymorphicTypesDto.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput\PolymorphicType; + +/** + * Useful when you need to tell an agent that any of the items are acceptable types. + * Real life example could be a list of possible analytical data visualization like charts or tables. + */ +final class ListOfPolymorphicTypesDto +{ + /** + * @param list $items + */ + public function __construct(public array $items) + { + } +} diff --git a/fixtures/StructuredOutput/UnionType/HumanReadableTimeUnion.php b/fixtures/StructuredOutput/UnionType/HumanReadableTimeUnion.php new file mode 100644 index 000000000..874ae5f54 --- /dev/null +++ b/fixtures/StructuredOutput/UnionType/HumanReadableTimeUnion.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput\UnionType; + +final class HumanReadableTimeUnion +{ + public function __construct(public string $readableTime) + { + } +} diff --git a/fixtures/StructuredOutput/UnionType/UnionTypeDto.php b/fixtures/StructuredOutput/UnionType/UnionTypeDto.php new file mode 100644 index 000000000..f626d7087 --- /dev/null +++ b/fixtures/StructuredOutput/UnionType/UnionTypeDto.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput\UnionType; + +final class UnionTypeDto +{ + public function __construct( + public UnixTimestampUnion|HumanReadableTimeUnion|null $time, + ) { + } +} diff --git a/fixtures/StructuredOutput/UnionType/UnixTimestampUnion.php b/fixtures/StructuredOutput/UnionType/UnixTimestampUnion.php new file mode 100644 index 000000000..a67079e47 --- /dev/null +++ b/fixtures/StructuredOutput/UnionType/UnixTimestampUnion.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput\UnionType; + +final class UnixTimestampUnion +{ + public function __construct(public int $timestamp) + { + } +} diff --git a/src/agent/CHANGELOG.md b/src/agent/CHANGELOG.md index 7ab3b901b..4de33f9a6 100644 --- a/src/agent/CHANGELOG.md +++ b/src/agent/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 0.1 --- + * Add support for union types and polymorphic types via DiscriminatorMap * Add Agent class as central orchestrator for AI interactions through the Platform component * Add input/output processing pipeline: - `InputProcessorInterface` for pre-processing messages and options diff --git a/src/agent/src/StructuredOutput/AgentProcessor.php b/src/agent/src/StructuredOutput/AgentProcessor.php index c9e84a50c..d3948ba92 100644 --- a/src/agent/src/StructuredOutput/AgentProcessor.php +++ b/src/agent/src/StructuredOutput/AgentProcessor.php @@ -20,9 +20,14 @@ use Symfony\AI\Platform\Capability; use Symfony\AI\Platform\Result\ObjectResult; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; @@ -39,8 +44,20 @@ public function __construct( private ?SerializerInterface $serializer = null, ) { if (null === $this->serializer) { - $propertyInfo = new PropertyInfoExtractor([], [new PhpDocExtractor()]); - $normalizers = [new ObjectNormalizer(propertyTypeExtractor: $propertyInfo), new ArrayDenormalizer()]; + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $discriminator = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); + $propertyInfo = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + + $normalizers = [ + new BackedEnumNormalizer(), + new ObjectNormalizer( + classMetadataFactory: $classMetadataFactory, + propertyTypeExtractor: $propertyInfo, + classDiscriminatorResolver: $discriminator + ), + new ArrayDenormalizer(), + ]; + $this->serializer = new Serializer($normalizers, [new JsonEncoder()]); } } diff --git a/src/agent/tests/StructuredOutput/AgentProcessorTest.php b/src/agent/tests/StructuredOutput/AgentProcessorTest.php index 37c381a83..084afe5cd 100644 --- a/src/agent/tests/StructuredOutput/AgentProcessorTest.php +++ b/src/agent/tests/StructuredOutput/AgentProcessorTest.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Agent\Tests\StructuredOutput; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\AI\Agent\Exception\MissingModelSupportException; @@ -20,7 +21,13 @@ use Symfony\AI\Agent\StructuredOutput\AgentProcessor; use Symfony\AI\Fixtures\SomeStructure; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; +use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListItemAge; +use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListItemName; +use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListOfPolymorphicTypesDto; use Symfony\AI\Fixtures\StructuredOutput\Step; +use Symfony\AI\Fixtures\StructuredOutput\UnionType\HumanReadableTimeUnion; +use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnionTypeDto; +use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnixTimestampUnion; use Symfony\AI\Platform\Capability; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Metadata\Metadata; @@ -153,6 +160,103 @@ public function testProcessOutputWithComplexResponseFormat() $this->assertSame('x = -3.75', $structure->finalAnswer); } + /** + * @param class-string $expectedTimeStructure + */ + #[DataProvider('unionTimeTypeProvider')] + public function testProcessOutputWithUnionTypeResponseFormat(TextResult $result, string $expectedTimeStructure) + { + $processor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $options = ['output_structure' => UnionTypeDto::class]; + $input = new Input($model, new MessageBag(), $options); + $processor->processInput($input); + + $output = new Output($model, $result, new MessageBag(), $input->getOptions()); + $processor->processOutput($output); + + $this->assertInstanceOf(ObjectResult::class, $output->result); + /** @var UnionTypeDto $structure */ + $structure = $output->result->getContent(); + $this->assertInstanceOf(UnionTypeDto::class, $structure); + + $this->assertInstanceOf($expectedTimeStructure, $structure->time); + } + + public static function unionTimeTypeProvider(): array + { + $unixTimestampResult = new TextResult(<< 'format'])); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $options = ['output_structure' => ListOfPolymorphicTypesDto::class]; + $input = new Input($model, new MessageBag(), $options); + $processor->processInput($input); + + $result = new TextResult(<<getOptions()); + + $processor->processOutput($output); + + $this->assertInstanceOf(ObjectResult::class, $output->result); + + /** @var ListOfPolymorphicTypesDto $structure */ + $structure = $output->result->getContent(); + $this->assertInstanceOf(ListOfPolymorphicTypesDto::class, $structure); + + $this->assertCount(2, $structure->items); + + $nameItem = $structure->items[0]; + $ageItem = $structure->items[1]; + + $this->assertInstanceOf(ListItemName::class, $nameItem); + $this->assertInstanceOf(ListItemAge::class, $ageItem); + + $this->assertSame('John Doe', $nameItem->name); + $this->assertSame(24, $ageItem->age); + + $this->assertSame('name', $nameItem->type); + $this->assertSame('age', $ageItem->type); + } + public function testProcessOutputWithoutResponseFormat() { $resultFormatFactory = new ConfigurableResponseFormatFactory(); diff --git a/src/platform/src/Contract/JsonSchema/Factory.php b/src/platform/src/Contract/JsonSchema/Factory.php index 2a1e5b2ad..c0aa7005f 100644 --- a/src/platform/src/Contract/JsonSchema/Factory.php +++ b/src/platform/src/Contract/JsonSchema/Factory.php @@ -13,12 +13,14 @@ use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BackedEnumType; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; @@ -47,6 +49,7 @@ * minProperties?: int, * maxProperties?: int, * dependentRequired?: bool, + * anyOf?: list, * }>, * required: list, * additionalProperties: false, @@ -110,7 +113,10 @@ private function convertTypes(array $elements): ?array $schema = $this->getTypeSchema($type); if ($type->isNullable()) { - $schema['type'] = [$schema['type'], 'null']; + // anyOf already contains the null variant when applicable; do nothing + if (!isset($schema['anyOf'])) { + $schema['type'] = [$schema['type'], 'null']; + } } elseif (!($element instanceof \ReflectionParameter && $element->isOptional())) { $result['required'][] = $name; } @@ -151,6 +157,21 @@ private function getTypeSchema(Type $type): array } } + if ($type instanceof UnionType) { + // Do not handle nullables as a union but directly return the wrapped type schema + if (2 === \count($type->getTypes()) && $type->isNullable() && $type instanceof NullableType) { + return $this->getTypeSchema($type->getWrappedType()); + } + + $variants = []; + + foreach ($type->getTypes() as $variant) { + $variants[] = $this->getTypeSchema($variant); + } + + return ['anyOf' => $variants]; + } + switch (true) { case $type->isIdentifiedBy(TypeIdentifier::INT): return ['type' => 'integer']; @@ -168,6 +189,22 @@ private function getTypeSchema(Type $type): array if ($collectionValueType->isIdentifiedBy(TypeIdentifier::OBJECT)) { \assert($collectionValueType instanceof ObjectType); + // Check for the DiscriminatorMap attribute to handle polymorphic arrays + $discriminatorMapping = $this->findDiscriminatorMapping($collectionValueType->getClassName()); + if ($discriminatorMapping) { + $discriminators = []; + foreach ($discriminatorMapping as $_ => $discriminator) { + $discriminators[] = $this->buildProperties($discriminator); + } + + return [ + 'type' => 'array', + 'items' => [ + 'anyOf' => $discriminators, + ], + ]; + } + return [ 'type' => 'array', 'items' => $this->buildProperties($collectionValueType->getClassName()), @@ -195,6 +232,8 @@ private function getTypeSchema(Type $type): array } // no break + case $type->isIdentifiedBy(TypeIdentifier::NULL): + return ['type' => 'null']; case $type->isIdentifiedBy(TypeIdentifier::STRING): default: // Fallback to string for any unhandled types @@ -233,4 +272,34 @@ private function buildEnumSchema(string $enumClassName): array 'enum' => $values, ]; } + + /** + * @param class-string $className + * + * @return array|null + * + * @throws \ReflectionException + */ + private function findDiscriminatorMapping(string $className): ?array + { + /** @var \ReflectionAttribute[] $attributes */ + $attributes = (new \ReflectionClass($className))->getAttributes(DiscriminatorMap::class); + $result = \count($attributes) > 0 ? $attributes[array_key_first($attributes)]->newInstance() : null; + + if (!$result) { + return null; + } + + /** + * In the 8.* release of symfony/serializer DiscriminatorMap removes the getMapping() method in favor of property access. + * This satisfies the project's pipeline that builds against both < and >= 8.* release. + * This logic can be removed once the project builds against >= 8.* only. + * + * @see https://github.com/symfony/ai/pull/585#issuecomment-3303631346 + */ + $reflectionProperty = new \ReflectionProperty($result, 'mapping'); + $reflectionProperty->setAccessible(true); + + return $reflectionProperty->getValue($result); + } } diff --git a/src/platform/tests/Contract/JsonSchema/FactoryTest.php b/src/platform/tests/Contract/JsonSchema/FactoryTest.php index 3a58a2a89..56416083a 100644 --- a/src/platform/tests/Contract/JsonSchema/FactoryTest.php +++ b/src/platform/tests/Contract/JsonSchema/FactoryTest.php @@ -16,7 +16,9 @@ use PHPUnit\Framework\TestCase; use Symfony\AI\Fixtures\StructuredOutput\ExampleDto; use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; +use Symfony\AI\Fixtures\StructuredOutput\PolymorphicType\ListOfPolymorphicTypesDto; use Symfony\AI\Fixtures\StructuredOutput\Step; +use Symfony\AI\Fixtures\StructuredOutput\UnionType\UnionTypeDto; use Symfony\AI\Fixtures\StructuredOutput\User; use Symfony\AI\Fixtures\Tool\ToolNoParams; use Symfony\AI\Fixtures\Tool\ToolOptionalParam; @@ -226,6 +228,100 @@ public function testBuildPropertiesForMathReasoningClass() $this->assertSame($expected, $actual); } + public function testBuildPropertiesForListOfPolymorphicTypesDto() + { + $expected = [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'items' => [ + 'anyOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'type' => [ + 'type' => 'string', + 'pattern' => '^name$', + ], + ], + 'required' => [ + 'name', + 'type', + ], + 'additionalProperties' => false, + ], + [ + 'type' => 'object', + 'properties' => [ + 'age' => ['type' => 'integer'], + 'type' => [ + 'type' => 'string', + 'pattern' => '^age$', + ], + ], + 'required' => [ + 'age', + 'type', + ], + 'additionalProperties' => false, + ], + ], + ], + ], + ], + 'required' => ['items'], + 'additionalProperties' => false, + ]; + + $actual = $this->factory->buildProperties(ListOfPolymorphicTypesDto::class); + + $this->assertSame($expected, $actual); + $this->assertSame($expected['type'], $actual['type']); + $this->assertSame($expected['required'], $actual['required']); + } + + public function testBuildPropertiesForUnionTypeDto() + { + $expected = [ + 'type' => 'object', + 'properties' => [ + 'time' => [ + 'anyOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'readableTime' => ['type' => 'string'], + ], + 'required' => ['readableTime'], + 'additionalProperties' => false, + ], + [ + 'type' => 'object', + 'properties' => [ + 'timestamp' => ['type' => 'integer'], + ], + 'required' => ['timestamp'], + 'additionalProperties' => false, + ], + [ + 'type' => 'null', + ], + ], + ], + ], + 'required' => [], + 'additionalProperties' => false, + ]; + + $actual = $this->factory->buildProperties(UnionTypeDto::class); + + $this->assertSame($expected, $actual); + $this->assertSame($expected['type'], $actual['type']); + $this->assertSame($expected['required'], $actual['required']); + } + public function testBuildPropertiesForStepClass() { $expected = [