From 39b8e3d269fdf653845e8dccd914fb1dce9cc563 Mon Sep 17 00:00:00 2001 From: Sam Mear Date: Wed, 21 Jan 2026 13:26:14 +0000 Subject: [PATCH 1/2] public readonly reflection --- .../MessageQueue/MessageEncoderTest.php | 12 ++ .../MessageQueue/ReadonlyMessage.php | 17 ++ .../_files/encoder_communication.php | 5 + .../Magento/Framework/MessageQueue/README.md | 11 +- .../Reflection/DataObjectProcessor.php | 185 +++++++++++++++++- .../Test/Unit/DataObjectProcessorTest.php | 59 ++++++ ...tDataObjectWithGetterAndPublicProperty.php | 24 +++ .../TestDataObjectWithPublicProperties.php | 17 ++ .../Webapi/ServiceInputProcessor.php | 13 +- .../Test/Unit/ServiceInputProcessorTest.php | 16 ++ 10 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 dev/tests/integration/testsuite/Magento/Framework/MessageQueue/ReadonlyMessage.php create mode 100644 lib/internal/Magento/Framework/Reflection/Test/Unit/TestDataObjectWithGetterAndPublicProperty.php create mode 100644 lib/internal/Magento/Framework/Reflection/Test/Unit/TestDataObjectWithPublicProperties.php diff --git a/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/MessageEncoderTest.php b/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/MessageEncoderTest.php index f221843393ec8..1811435baea72 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/MessageEncoderTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/MessageEncoderTest.php @@ -106,6 +106,18 @@ public function testDecode() $this->assertEquals('AL', $addresses[0]->getRegion()->getRegionCode()); } + public function testEncodeDecodeReadonlyMessage() + { + $message = new ReadonlyMessage(10, 'Test Message'); + + $encodedMessage = $this->encoder->encode('readonly.message.created', $message); + $decodedMessage = $this->encoder->decode('readonly.message.created', $encodedMessage); + + $this->assertInstanceOf(ReadonlyMessage::class, $decodedMessage); + $this->assertSame(10, $decodedMessage->entityId); + $this->assertSame('Test Message', $decodedMessage->name); + } + /** */ public function testDecodeInvalidMessageFormat() diff --git a/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/ReadonlyMessage.php b/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/ReadonlyMessage.php new file mode 100644 index 0000000000000..9ece88d6354d9 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/ReadonlyMessage.php @@ -0,0 +1,17 @@ + \Magento\Catalog\Api\Data\ProductInterface::class, 'response' => null, ], + 'readonly.message.created' => [ + 'request_type' => 'object_interface', + 'request' => \Magento\Framework\MessageQueue\ReadonlyMessage::class, + 'response' => null, + ], ], ]; diff --git a/lib/internal/Magento/Framework/MessageQueue/README.md b/lib/internal/Magento/Framework/MessageQueue/README.md index 4e0de76b13ecc..14ebc0b685b8a 100644 --- a/lib/internal/Magento/Framework/MessageQueue/README.md +++ b/lib/internal/Magento/Framework/MessageQueue/README.md @@ -1 +1,10 @@ -This component is designed to provide Message Queue Framework +This component is designed to provide Message Queue Framework. + +## DTO serialization + +Message payloads using `object_interface` topics are serialized through the Web API data processors. + +- Getter methods define the payload fields; if a getter and a public property map to the same field, the getter wins. +- Public properties without getters are supported, including promoted/readonly properties, and are converted to + snake_case field names. +- Constructor hydration accepts both camelCase and snake_case keys to support promoted/readonly DTOs on decode. diff --git a/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php b/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php index 404d0d2c58d3c..e907796f0c5f1 100644 --- a/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php +++ b/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php @@ -6,6 +6,7 @@ namespace Magento\Framework\Reflection; use Magento\Framework\Api\CustomAttributesDataInterface; +use Magento\Framework\Api\SimpleDataObjectConverter; use Magento\Framework\Phrase; /** @@ -93,15 +94,23 @@ public function buildOutputDataArray($dataObject, $dataObjectType) { $methods = $this->methodsMapProcessor->getMethodsMap($dataObjectType); $outputData = []; + $methodFieldNames = []; $excludedMethodsForDataObjectType = $this->excludedMethodsClassMap[$dataObjectType] ?? []; foreach (array_keys($methods) as $methodName) { - if (in_array($methodName, $excludedMethodsForDataObjectType)) { + if (!$this->methodsMapProcessor->isMethodValidForDataField($dataObjectType, $methodName)) { continue; } - if (!$this->methodsMapProcessor->isMethodValidForDataField($dataObjectType, $methodName)) { + $key = $this->fieldNamer->getFieldNameForMethodName($methodName); + if ($key === null) { + continue; + } + + $methodFieldNames[$key] = true; + + if (in_array($methodName, $excludedMethodsForDataObjectType)) { continue; } @@ -115,14 +124,19 @@ public function buildOutputDataArray($dataObject, $dataObjectType) } $returnType = $this->methodsMapProcessor->getMethodReturnType($dataObjectType, $methodName); - $key = $this->fieldNamer->getFieldNameForMethodName($methodName); if ($key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES && $value === []) { continue; } if ($key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES) { + if (!($dataObject instanceof CustomAttributesDataInterface)) { + continue; + } $value = $this->customAttributesProcessor->buildOutputDataArray($dataObject, $dataObjectType); } elseif ($key === "extension_attributes") { + if (!($value instanceof \Magento\Framework\Api\ExtensionAttributesInterface)) { + continue; + } $value = $this->extensionAttributesProcessor->buildOutputDataArray($value, $returnType); if (empty($value)) { continue; @@ -134,6 +148,8 @@ public function buildOutputDataArray($dataObject, $dataObjectType) $outputData[$key] = $value; } + $outputData = $this->addPublicProperties($dataObject, $dataObjectType, $outputData, $methodFieldNames); + $outputData = $this->changeOutputArray($dataObject, $outputData); return $outputData; @@ -168,6 +184,169 @@ private function processValue($value, $returnType) return $this->typeCaster->castValueToType($value, $returnType); } + /** + * Append public properties that do not have a matching getter. + * + * @param object $dataObject + * @param string $dataObjectType + * @param array $outputData + * @param array $methodFieldNames + * @return array + */ + private function addPublicProperties($dataObject, $dataObjectType, array $outputData, array $methodFieldNames): array + { + $reflection = new \ReflectionObject($dataObject); + foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic()) { + continue; + } + + if (!$property->isInitialized($dataObject)) { + continue; + } + + $key = SimpleDataObjectConverter::camelCaseToSnakeCase($property->getName()); + if (isset($methodFieldNames[$key]) || array_key_exists($key, $outputData)) { + continue; + } + + $value = $property->getValue($dataObject); + $propertyMetadata = $this->getPropertyMetadata($property); + $returnType = $this->resolvePropertyReturnType($propertyMetadata['type'], $value, $property); + + if ($value === null && !$propertyMetadata['isRequired']) { + continue; + } + + if ($key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES && $value === []) { + continue; + } + + if ($key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES) { + $value = $this->customAttributesProcessor->buildOutputDataArray($dataObject, $dataObjectType); + } elseif ($key === "extension_attributes") { + $value = $this->extensionAttributesProcessor->buildOutputDataArray($value, $returnType); + if (empty($value)) { + continue; + } + } else { + $value = $this->processValue($value, $returnType); + } + + $outputData[$key] = $value; + } + + return $outputData; + } + + /** + * Resolve property type metadata for serialization. + * + * @param \ReflectionProperty $property + * @return array{type: string|null, isRequired: bool} + */ + private function getPropertyMetadata(\ReflectionProperty $property): array + { + $type = $property->getType(); + if ($type === null) { + return ['type' => null, 'isRequired' => false]; + } + + $allowsNull = $type->allowsNull(); + $typeName = null; + + if ($type instanceof \ReflectionUnionType) { + foreach ($type->getTypes() as $unionType) { + if ($unionType->getName() !== 'null') { + $typeName = $unionType->getName(); + break; + } + } + } elseif ($type instanceof \ReflectionIntersectionType) { + $types = $type->getTypes(); + $typeName = $types ? $types[0]->getName() : null; + } else { + $typeName = $type->getName(); + } + + $typeName = $this->normalizePropertyType($property, $typeName); + + return [ + 'type' => $typeName, + 'isRequired' => !$allowsNull, + ]; + } + + /** + * Normalize property type names for casting. + * + * @param \ReflectionProperty $property + * @param string|null $typeName + * @return string|null + */ + private function normalizePropertyType(\ReflectionProperty $property, ?string $typeName): ?string + { + if ($typeName === null) { + return null; + } + + if ($typeName === 'self' || $typeName === 'static') { + return $property->getDeclaringClass()->getName(); + } + + if ($typeName === 'parent') { + $parent = $property->getDeclaringClass()->getParentClass(); + return $parent ? $parent->getName() : null; + } + + if ($typeName === 'array' || $typeName === 'iterable' || $typeName === 'mixed') { + return TypeProcessor::UNSTRUCTURED_ARRAY; + } + + return $typeName; + } + + /** + * Ensure object values use a compatible return type. + * + * @param string|null $returnType + * @param mixed $value + * @param \ReflectionProperty $property + * @return string|null + */ + private function resolvePropertyReturnType(?string $returnType, $value, \ReflectionProperty $property): ?string + { + if (is_array($value) && $returnType === null) { + return TypeProcessor::UNSTRUCTURED_ARRAY; + } + + if (is_object($value) && !($value instanceof Phrase)) { + if ($returnType === null || !$this->isObjectType($returnType)) { + return get_class($value); + } + + if ($returnType === 'self' || $returnType === 'static') { + return $property->getDeclaringClass()->getName(); + } + + if ($returnType === 'parent') { + $parent = $property->getDeclaringClass()->getParentClass(); + return $parent ? $parent->getName() : get_class($value); + } + } + + return $returnType; + } + + /** + * @param string $type + * @return bool + */ + private function isObjectType(string $type): bool + { + return interface_exists($type) || class_exists($type); + } + /** * Change output array if needed. * diff --git a/lib/internal/Magento/Framework/Reflection/Test/Unit/DataObjectProcessorTest.php b/lib/internal/Magento/Framework/Reflection/Test/Unit/DataObjectProcessorTest.php index 38edd04257055..239419be587c4 100644 --- a/lib/internal/Magento/Framework/Reflection/Test/Unit/DataObjectProcessorTest.php +++ b/lib/internal/Magento/Framework/Reflection/Test/Unit/DataObjectProcessorTest.php @@ -211,4 +211,63 @@ public function testBuildOutputDataArrayWithUnstructuredArray() $this->assertEquals($unstructuredArrayData, $outputData['items']); $this->assertSame($unstructuredArrayData, $outputData['items']); } + + public function testBuildOutputDataArrayWithPublicProperties() + { + $objectManager = new ObjectManager($this); + + $this->dataObjectProcessor = $objectManager->getObject( + DataObjectProcessor::class, + [ + 'methodsMapProcessor' => $this->methodsMapProcessor, + 'typeCaster' => $objectManager->getObject(TypeCaster::class), + 'fieldNamer' => $objectManager->getObject(FieldNamer::class), + 'extensionAttributesProcessor' => $this->extensionAttributesProcessorMock, + ] + ); + + $testDataObject = new TestDataObjectWithPublicProperties(12, 'Sample'); + + $outputData = $this->dataObjectProcessor->buildOutputDataArray( + $testDataObject, + TestDataObjectWithPublicProperties::class + ); + + $this->assertSame( + [ + 'entity_id' => 12, + 'name' => 'Sample', + ], + $outputData + ); + } + + public function testBuildOutputDataArrayPrefersGetterOverPublicProperty() + { + $objectManager = new ObjectManager($this); + + $this->dataObjectProcessor = $objectManager->getObject( + DataObjectProcessor::class, + [ + 'methodsMapProcessor' => $this->methodsMapProcessor, + 'typeCaster' => $objectManager->getObject(TypeCaster::class), + 'fieldNamer' => $objectManager->getObject(FieldNamer::class), + 'extensionAttributesProcessor' => $this->extensionAttributesProcessorMock, + ] + ); + + $testDataObject = new TestDataObjectWithGetterAndPublicProperty('property-value'); + + $outputData = $this->dataObjectProcessor->buildOutputDataArray( + $testDataObject, + TestDataObjectWithGetterAndPublicProperty::class + ); + + $this->assertSame( + [ + 'name' => 'getter-value', + ], + $outputData + ); + } } diff --git a/lib/internal/Magento/Framework/Reflection/Test/Unit/TestDataObjectWithGetterAndPublicProperty.php b/lib/internal/Magento/Framework/Reflection/Test/Unit/TestDataObjectWithGetterAndPublicProperty.php new file mode 100644 index 0000000000000..1a4361b32b094 --- /dev/null +++ b/lib/internal/Magento/Framework/Reflection/Test/Unit/TestDataObjectWithGetterAndPublicProperty.php @@ -0,0 +1,24 @@ +getParameters(); foreach ($parameters as $parameter) { - if (isset($data[$parameter->getName()])) { + $parameterName = $parameter->getName(); + $snakeCaseParameterName = SimpleDataObjectConverter::camelCaseToSnakeCase($parameterName); + if (isset($data[$parameterName]) || isset($data[$snakeCaseParameterName])) { $parameterType = $this->typeProcessor->getParamType($parameter); // Allow only simple types or Api Data Objects @@ -253,8 +255,9 @@ private function getConstructorData(string $className, array $data): array continue; } + $parameterValue = isset($data[$parameterName]) ? $data[$parameterName] : $data[$snakeCaseParameterName]; try { - $res[$parameter->getName()] = $this->convertValue($data[$parameter->getName()], $parameterType); + $res[$parameterName] = $this->convertValue($parameterValue, $parameterType); } catch (\ReflectionException $e) { // Parameter was not correclty declared or the class is uknown. // By not returing the contructor value, we will automatically fall back to the "setters" way. @@ -299,10 +302,14 @@ protected function _createFromArray($className, $data) // Primary method: assign to constructor parameters $constructorArgs = $this->getConstructorData($className, $data); $object = $this->objectManager->create($className, $constructorArgs); + $constructorArgSnakeCaseMap = []; + foreach (array_keys($constructorArgs) as $constructorArgName) { + $constructorArgSnakeCaseMap[SimpleDataObjectConverter::camelCaseToSnakeCase($constructorArgName)] = true; + } // Secondary method: fallback to setter methods foreach ($data as $propertyName => $value) { - if (isset($constructorArgs[$propertyName])) { + if (isset($constructorArgs[$propertyName]) || isset($constructorArgSnakeCaseMap[$propertyName])) { continue; } diff --git a/lib/internal/Magento/Framework/Webapi/Test/Unit/ServiceInputProcessorTest.php b/lib/internal/Magento/Framework/Webapi/Test/Unit/ServiceInputProcessorTest.php index e8b32182cfe68..c73965e6f1985 100644 --- a/lib/internal/Magento/Framework/Webapi/Test/Unit/ServiceInputProcessorTest.php +++ b/lib/internal/Magento/Framework/Webapi/Test/Unit/ServiceInputProcessorTest.php @@ -290,6 +290,22 @@ public function testSimpleConstructorProperties() $this->assertEquals('Test', $arg->getName()); } + public function testSimpleConstructorPropertiesWithSnakeCase() + { + $data = ['simpleConstructor' => ['entity_id' => 15, 'name' => 'Test']]; + $result = $this->serviceInputProcessor->process( + TestService::class, + 'simpleConstructor', + $data + ); + $this->assertNotNull($result); + $arg = $result[0]; + + $this->assertTrue($arg instanceof SimpleConstructor); + $this->assertEquals(15, $arg->getEntityId()); + $this->assertEquals('Test', $arg->getName()); + } + public function testSimpleArrayProperties() { $data = ['ids' => [1, 2, 3, 4]]; From 6713ba671771eeef68c7ad640402972c51bee864 Mon Sep 17 00:00:00 2001 From: Sam Mear Date: Thu, 22 Jan 2026 13:33:50 +0000 Subject: [PATCH 2/2] Enhance reflection handling in DataObjectProcessor and ServiceInputProcessor --- .../MessageQueue/ReadonlyMessage.php | 7 + .../Reflection/DataObjectProcessor.php | 197 ++++++++++++++---- .../Webapi/ServiceInputProcessor.php | 126 ++++++++--- 3 files changed, 262 insertions(+), 68 deletions(-) diff --git a/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/ReadonlyMessage.php b/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/ReadonlyMessage.php index 9ece88d6354d9..b63a391308362 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/ReadonlyMessage.php +++ b/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/ReadonlyMessage.php @@ -7,8 +7,15 @@ namespace Magento\Framework\MessageQueue; +/** + * Simple DTO used by message queue integration tests. + */ class ReadonlyMessage { + /** + * @param int $entityId + * @param string $name + */ public function __construct( public readonly int $entityId, public readonly string $name diff --git a/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php b/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php index e907796f0c5f1..0d6314cb6a6f5 100644 --- a/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php +++ b/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php @@ -193,50 +193,150 @@ private function processValue($value, $returnType) * @param array $methodFieldNames * @return array */ - private function addPublicProperties($dataObject, $dataObjectType, array $outputData, array $methodFieldNames): array - { + private function addPublicProperties( + $dataObject, + $dataObjectType, + array $outputData, + array $methodFieldNames + ): array { $reflection = new \ReflectionObject($dataObject); foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { - if ($property->isStatic()) { + $propertyData = $this->getPublicPropertyOutputData( + $dataObject, + $dataObjectType, + $property, + $methodFieldNames, + $outputData + ); + if ($propertyData === null) { continue; } - if (!$property->isInitialized($dataObject)) { - continue; - } + $outputData[$propertyData['key']] = $propertyData['value']; + } - $key = SimpleDataObjectConverter::camelCaseToSnakeCase($property->getName()); - if (isset($methodFieldNames[$key]) || array_key_exists($key, $outputData)) { - continue; - } + return $outputData; + } - $value = $property->getValue($dataObject); - $propertyMetadata = $this->getPropertyMetadata($property); - $returnType = $this->resolvePropertyReturnType($propertyMetadata['type'], $value, $property); + /** + * Build output payload for a public property. + * + * @param object $dataObject + * @param string $dataObjectType + * @param \ReflectionProperty $property + * @param array $methodFieldNames + * @param array $outputData + * @return array|null + */ + private function getPublicPropertyOutputData( + $dataObject, + $dataObjectType, + \ReflectionProperty $property, + array $methodFieldNames, + array $outputData + ): ?array { + $key = $this->getPublicPropertyKey($dataObject, $property, $methodFieldNames, $outputData); + if ($key === null) { + return null; + } - if ($value === null && !$propertyMetadata['isRequired']) { - continue; - } + $value = $property->getValue($dataObject); + $propertyMetadata = $this->getPropertyMetadata($property); + if ($this->shouldSkipPublicPropertyValue($key, $value, $propertyMetadata['isRequired'])) { + return null; + } - if ($key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES && $value === []) { - continue; + $returnType = $this->resolvePropertyReturnType($propertyMetadata['type'], $value, $property); + $value = $this->processPublicPropertyValue($dataObject, $dataObjectType, $key, $value, $returnType); + if ($value === null) { + return null; + } + + return [ + 'key' => $key, + 'value' => $value, + ]; + } + + /** + * Determine the output key for a public property. + * + * @param object $dataObject + * @param \ReflectionProperty $property + * @param array $methodFieldNames + * @param array $outputData + * @return string|null + */ + private function getPublicPropertyKey( + $dataObject, + \ReflectionProperty $property, + array $methodFieldNames, + array $outputData + ): ?string { + if ($property->isStatic() || !$property->isInitialized($dataObject)) { + return null; + } + + $key = SimpleDataObjectConverter::camelCaseToSnakeCase($property->getName()); + if (isset($methodFieldNames[$key]) || array_key_exists($key, $outputData)) { + return null; + } + + return $key; + } + + /** + * Determine whether a property should be skipped based on its value. + * + * @param string $key + * @param mixed $value + * @param bool $isRequired + * @return bool + */ + private function shouldSkipPublicPropertyValue(string $key, $value, bool $isRequired): bool + { + if ($value === null && !$isRequired) { + return true; + } + + return $key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES && $value === []; + } + + /** + * Process a public property value based on the field key. + * + * @param object $dataObject + * @param string $dataObjectType + * @param string $key + * @param mixed $value + * @param string|null $returnType + * @return mixed|null + */ + private function processPublicPropertyValue( + $dataObject, + $dataObjectType, + string $key, + $value, + ?string $returnType + ) { + if ($key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES) { + if (!($dataObject instanceof CustomAttributesDataInterface)) { + return null; } - if ($key === CustomAttributesDataInterface::CUSTOM_ATTRIBUTES) { - $value = $this->customAttributesProcessor->buildOutputDataArray($dataObject, $dataObjectType); - } elseif ($key === "extension_attributes") { - $value = $this->extensionAttributesProcessor->buildOutputDataArray($value, $returnType); - if (empty($value)) { - continue; - } - } else { - $value = $this->processValue($value, $returnType); + return $this->customAttributesProcessor->buildOutputDataArray($dataObject, $dataObjectType); + } + + if ($key === "extension_attributes") { + if (!($value instanceof \Magento\Framework\Api\ExtensionAttributesInterface)) { + return null; } - $outputData[$key] = $value; + $extensionAttributes = $this->extensionAttributesProcessor->buildOutputDataArray($value, $returnType); + return empty($extensionAttributes) ? null : $extensionAttributes; } - return $outputData; + return $this->processValue($value, $returnType); } /** @@ -320,25 +420,42 @@ private function resolvePropertyReturnType(?string $returnType, $value, \Reflect return TypeProcessor::UNSTRUCTURED_ARRAY; } - if (is_object($value) && !($value instanceof Phrase)) { - if ($returnType === null || !$this->isObjectType($returnType)) { - return get_class($value); - } + if (!is_object($value) || $value instanceof Phrase) { + return $returnType; + } - if ($returnType === 'self' || $returnType === 'static') { - return $property->getDeclaringClass()->getName(); - } + return $this->resolveObjectReturnType($returnType, $value, $property); + } - if ($returnType === 'parent') { - $parent = $property->getDeclaringClass()->getParentClass(); - return $parent ? $parent->getName() : get_class($value); - } + /** + * Resolve return types for object property values. + * + * @param string|null $returnType + * @param object $value + * @param \ReflectionProperty $property + * @return string|null + */ + private function resolveObjectReturnType(?string $returnType, object $value, \ReflectionProperty $property): ?string + { + if ($returnType === null || !$this->isObjectType($returnType)) { + return get_class($value); + } + + if ($returnType === 'self' || $returnType === 'static') { + return $property->getDeclaringClass()->getName(); + } + + if ($returnType === 'parent') { + $parent = $property->getDeclaringClass()->getParentClass(); + return $parent ? $parent->getName() : get_class($value); } return $returnType; } /** + * Check whether the type maps to a class or interface. + * * @param string $type * @return bool */ diff --git a/lib/internal/Magento/Framework/Webapi/ServiceInputProcessor.php b/lib/internal/Magento/Framework/Webapi/ServiceInputProcessor.php index 66fc0190b0fb4..78ed7228c0f3f 100644 --- a/lib/internal/Magento/Framework/Webapi/ServiceInputProcessor.php +++ b/lib/internal/Magento/Framework/Webapi/ServiceInputProcessor.php @@ -25,6 +25,7 @@ use Magento\Framework\Webapi\Exception as WebapiException; use Magento\Framework\Webapi\CustomAttribute\PreprocessorInterface; use Laminas\Code\Reflection\ClassReflection; +use Laminas\Code\Reflection\MethodReflection; use Magento\Framework\Webapi\Validator\IOLimit\DefaultPageSizeSetter; use Magento\Framework\Webapi\Validator\ServiceInputValidatorInterface; @@ -226,47 +227,116 @@ public function process($serviceClassName, $serviceMethodName, array $inputArray * @throws LocalizedException */ private function getConstructorData(string $className, array $data): array + { + $constructor = $this->getConstructorReflection($className); + if ($constructor === null) { + return []; + } + + return $this->getConstructorArguments($constructor, $data); + } + + /** + * Get constructor reflection for a class. + * + * @param string $className + * @return MethodReflection|null + * @throws \ReflectionException + */ + private function getConstructorReflection(string $className): ?MethodReflection { $preferenceClass = $this->config->getPreference($className); $class = new ClassReflection($preferenceClass ?: $className); try { - $constructor = $class->getMethod('__construct'); + return $class->getMethod('__construct'); } catch (\ReflectionException $e) { - $constructor = null; + return null; } + } - if ($constructor === null) { - return []; + /** + * Build constructor arguments from input data. + * + * @param MethodReflection $constructor + * @param array $data + * @return array + */ + private function getConstructorArguments(MethodReflection $constructor, array $data): array + { + $result = []; + foreach ($constructor->getParameters() as $parameter) { + $parameterValue = $this->getConstructorParameterValue($parameter, $data); + if ($parameterValue === null) { + continue; + } + + $result[$parameter->getName()] = $parameterValue; } - $res = []; - $parameters = $constructor->getParameters(); - foreach ($parameters as $parameter) { - $parameterName = $parameter->getName(); - $snakeCaseParameterName = SimpleDataObjectConverter::camelCaseToSnakeCase($parameterName); - if (isset($data[$parameterName]) || isset($data[$snakeCaseParameterName])) { - $parameterType = $this->typeProcessor->getParamType($parameter); - - // Allow only simple types or Api Data Objects - if (!($this->typeProcessor->isTypeSimple($parameterType) - || preg_match('~\\\\?\w+\\\\\w+\\\\Api\\\\Data\\\\~', $parameterType) === 1 - )) { - continue; - } + return $result; + } - $parameterValue = isset($data[$parameterName]) ? $data[$parameterName] : $data[$snakeCaseParameterName]; - try { - $res[$parameterName] = $this->convertValue($parameterValue, $parameterType); - } catch (\ReflectionException $e) { - // Parameter was not correclty declared or the class is uknown. - // By not returing the contructor value, we will automatically fall back to the "setters" way. - continue; - } - } + /** + * Resolve a constructor parameter value from input data. + * + * @param \ReflectionParameter $parameter + * @param array $data + * @return mixed|null + * @throws LocalizedException + */ + private function getConstructorParameterValue(\ReflectionParameter $parameter, array $data) + { + [$hasValue, $parameterValue] = $this->getParameterValue($data, $parameter->getName()); + if (!$hasValue) { + return null; + } + + $parameterType = $this->typeProcessor->getParamType($parameter); + if (!$this->isAllowedConstructorType($parameterType)) { + return null; + } + + try { + return $this->convertValue($parameterValue, $parameterType); + } catch (\ReflectionException $e) { + // Parameter was not correctly declared or the class is unknown. + // By not returning the constructor value, we will automatically fall back to the "setters" way. + return null; + } + } + + /** + * Retrieve input value for a constructor parameter. + * + * @param array $data + * @param string $parameterName + * @return array [bool $hasValue, mixed $value] + */ + private function getParameterValue(array $data, string $parameterName): array + { + if (isset($data[$parameterName])) { + return [true, $data[$parameterName]]; + } + + $snakeCaseParameterName = SimpleDataObjectConverter::camelCaseToSnakeCase($parameterName); + if (isset($data[$snakeCaseParameterName])) { + return [true, $data[$snakeCaseParameterName]]; } - return $res; + return [false, null]; + } + + /** + * Allow only simple types or Api Data Objects for constructor hydration. + * + * @param string $parameterType + * @return bool + */ + private function isAllowedConstructorType(string $parameterType): bool + { + return $this->typeProcessor->isTypeSimple($parameterType) + || preg_match('~\\\\?\w+\\\\\w+\\\\Api\\\\Data\\\\~', $parameterType) === 1; } /**