ServiceInputProcessor.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. <?php
  2. /**
  3. * Service Input Processor
  4. *
  5. * Copyright © Magento, Inc. All rights reserved.
  6. * See COPYING.txt for license details.
  7. */
  8. declare(strict_types=1);
  9. namespace Magento\Framework\Webapi;
  10. use Magento\Framework\Api\AttributeValue;
  11. use Magento\Framework\Api\AttributeValueFactory;
  12. use Magento\Framework\Api\SimpleDataObjectConverter;
  13. use Magento\Framework\Exception\InputException;
  14. use Magento\Framework\Exception\SerializationException;
  15. use Magento\Framework\ObjectManager\ConfigInterface;
  16. use Magento\Framework\ObjectManagerInterface;
  17. use Magento\Framework\Phrase;
  18. use Magento\Framework\Reflection\MethodsMap;
  19. use Magento\Framework\Reflection\TypeProcessor;
  20. use Magento\Framework\Webapi\Exception as WebapiException;
  21. use Zend\Code\Reflection\ClassReflection;
  22. /**
  23. * Deserialize arguments from API requests.
  24. *
  25. * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  26. * @api
  27. * @since 100.0.2
  28. */
  29. class ServiceInputProcessor implements ServicePayloadConverterInterface
  30. {
  31. const EXTENSION_ATTRIBUTES_TYPE = \Magento\Framework\Api\ExtensionAttributesInterface::class;
  32. /**
  33. * @var \Magento\Framework\Reflection\TypeProcessor
  34. */
  35. protected $typeProcessor;
  36. /**
  37. * @var \Magento\Framework\ObjectManagerInterface
  38. */
  39. protected $objectManager;
  40. /**
  41. * @var \Magento\Framework\Api\AttributeValueFactory
  42. */
  43. protected $attributeValueFactory;
  44. /**
  45. * @var \Magento\Framework\Webapi\CustomAttributeTypeLocatorInterface
  46. */
  47. protected $customAttributeTypeLocator;
  48. /**
  49. * @var \Magento\Framework\Reflection\MethodsMap
  50. */
  51. protected $methodsMap;
  52. /**
  53. * @var \Magento\Framework\Reflection\NameFinder
  54. */
  55. private $nameFinder;
  56. /**
  57. * @var array
  58. */
  59. private $serviceTypeToEntityTypeMap;
  60. /**
  61. * @var ConfigInterface
  62. */
  63. private $config;
  64. /**
  65. * Initialize dependencies.
  66. *
  67. * @param TypeProcessor $typeProcessor
  68. * @param ObjectManagerInterface $objectManager
  69. * @param AttributeValueFactory $attributeValueFactory
  70. * @param CustomAttributeTypeLocatorInterface $customAttributeTypeLocator
  71. * @param MethodsMap $methodsMap
  72. * @param ServiceTypeToEntityTypeMap $serviceTypeToEntityTypeMap
  73. * @param ConfigInterface $config
  74. */
  75. public function __construct(
  76. TypeProcessor $typeProcessor,
  77. ObjectManagerInterface $objectManager,
  78. AttributeValueFactory $attributeValueFactory,
  79. CustomAttributeTypeLocatorInterface $customAttributeTypeLocator,
  80. MethodsMap $methodsMap,
  81. ServiceTypeToEntityTypeMap $serviceTypeToEntityTypeMap = null,
  82. ConfigInterface $config = null
  83. ) {
  84. $this->typeProcessor = $typeProcessor;
  85. $this->objectManager = $objectManager;
  86. $this->attributeValueFactory = $attributeValueFactory;
  87. $this->customAttributeTypeLocator = $customAttributeTypeLocator;
  88. $this->methodsMap = $methodsMap;
  89. $this->serviceTypeToEntityTypeMap = $serviceTypeToEntityTypeMap
  90. ?: \Magento\Framework\App\ObjectManager::getInstance()->get(ServiceTypeToEntityTypeMap::class);
  91. $this->config = $config
  92. ?: \Magento\Framework\App\ObjectManager::getInstance()->get(ConfigInterface::class);
  93. }
  94. /**
  95. * The getter function to get the new NameFinder dependency
  96. *
  97. * @return \Magento\Framework\Reflection\NameFinder
  98. *
  99. * @deprecated 100.1.0
  100. */
  101. private function getNameFinder()
  102. {
  103. if ($this->nameFinder === null) {
  104. $this->nameFinder = \Magento\Framework\App\ObjectManager::getInstance()
  105. ->get(\Magento\Framework\Reflection\NameFinder::class);
  106. }
  107. return $this->nameFinder;
  108. }
  109. /**
  110. * Convert the input array from key-value format to a list of parameters suitable for the specified class / method.
  111. *
  112. * The input array should have the field name as the key, and the value will either be a primitive or another
  113. * key-value array. The top level of this array needs keys that match the names of the parameters on the
  114. * service method.
  115. *
  116. * Mismatched types are caught by the PHP runtime, not explicitly checked for by this code.
  117. *
  118. * @param string $serviceClassName name of the service class that we are trying to call
  119. * @param string $serviceMethodName name of the method that we are trying to call
  120. * @param array $inputArray data to send to method in key-value format
  121. * @return array list of parameters that can be used to call the service method
  122. * @throws InputException if no value is provided for required parameters
  123. * @throws WebapiException
  124. */
  125. public function process($serviceClassName, $serviceMethodName, array $inputArray)
  126. {
  127. $inputData = [];
  128. $inputError = [];
  129. foreach ($this->methodsMap->getMethodParams($serviceClassName, $serviceMethodName) as $param) {
  130. $paramName = $param[MethodsMap::METHOD_META_NAME];
  131. $snakeCaseParamName = strtolower(preg_replace("/(?<=\\w)(?=[A-Z])/", "_$1", $paramName));
  132. if (isset($inputArray[$paramName]) || isset($inputArray[$snakeCaseParamName])) {
  133. $paramValue = isset($inputArray[$paramName])
  134. ? $inputArray[$paramName]
  135. : $inputArray[$snakeCaseParamName];
  136. try {
  137. $inputData[] = $this->convertValue($paramValue, $param[MethodsMap::METHOD_META_TYPE]);
  138. } catch (SerializationException $e) {
  139. throw new WebapiException(new Phrase($e->getMessage()));
  140. }
  141. } else {
  142. if ($param[MethodsMap::METHOD_META_HAS_DEFAULT_VALUE]) {
  143. $inputData[] = $param[MethodsMap::METHOD_META_DEFAULT_VALUE];
  144. } else {
  145. $inputError[] = $paramName;
  146. }
  147. }
  148. }
  149. $this->processInputError($inputError);
  150. return $inputData;
  151. }
  152. /**
  153. * Retrieve constructor data
  154. *
  155. * @param string $className
  156. * @param array $data
  157. * @return array
  158. * @throws \ReflectionException
  159. * @throws \Magento\Framework\Exception\LocalizedException
  160. */
  161. private function getConstructorData(string $className, array $data): array
  162. {
  163. $preferenceClass = $this->config->getPreference($className);
  164. $class = new ClassReflection($preferenceClass ?: $className);
  165. try {
  166. $constructor = $class->getMethod('__construct');
  167. } catch (\ReflectionException $e) {
  168. $constructor = null;
  169. }
  170. if ($constructor === null) {
  171. return [];
  172. }
  173. $res = [];
  174. $parameters = $constructor->getParameters();
  175. foreach ($parameters as $parameter) {
  176. if (isset($data[$parameter->getName()])) {
  177. $parameterType = $this->typeProcessor->getParamType($parameter);
  178. try {
  179. $res[$parameter->getName()] = $this->convertValue($data[$parameter->getName()], $parameterType);
  180. } catch (\ReflectionException $e) {
  181. // Parameter was not correclty declared or the class is uknown.
  182. // By not returing the contructor value, we will automatically fall back to the "setters" way.
  183. continue;
  184. }
  185. }
  186. }
  187. return $res;
  188. }
  189. /**
  190. * Creates a new instance of the given class and populates it with the array of data. The data can
  191. * be in different forms depending on the adapter being used, REST vs. SOAP. For REST, the data is
  192. * in snake_case (e.g. tax_class_id) while for SOAP the data is in camelCase (e.g. taxClassId).
  193. *
  194. * @param string $className
  195. * @param array $data
  196. * @return object the newly created and populated object
  197. * @throws \Exception
  198. * @SuppressWarnings(PHPMD.CyclomaticComplexity)
  199. */
  200. protected function _createFromArray($className, $data)
  201. {
  202. $data = is_array($data) ? $data : [];
  203. // convert to string directly to avoid situations when $className is object
  204. // which implements __toString method like \ReflectionObject
  205. $className = (string) $className;
  206. $class = new ClassReflection($className);
  207. if (is_subclass_of($className, self::EXTENSION_ATTRIBUTES_TYPE)) {
  208. $className = substr($className, 0, -strlen('Interface'));
  209. }
  210. // Primary method: assign to constructor parameters
  211. $constructorArgs = $this->getConstructorData($className, $data);
  212. $object = $this->objectManager->create($className, $constructorArgs);
  213. // Secondary method: fallback to setter methods
  214. foreach ($data as $propertyName => $value) {
  215. if (isset($constructorArgs[$propertyName])) {
  216. continue;
  217. }
  218. // Converts snake_case to uppercase CamelCase to help form getter/setter method names
  219. // This use case is for REST only. SOAP request data is already camel cased
  220. $camelCaseProperty = SimpleDataObjectConverter::snakeCaseToUpperCamelCase($propertyName);
  221. $methodName = $this->getNameFinder()->getGetterMethodName($class, $camelCaseProperty);
  222. $methodReflection = $class->getMethod($methodName);
  223. if ($methodReflection->isPublic()) {
  224. $returnType = $this->typeProcessor->getGetterReturnType($methodReflection)['type'];
  225. try {
  226. $setterName = $this->getNameFinder()->getSetterMethodName($class, $camelCaseProperty);
  227. } catch (\Exception $e) {
  228. if (empty($value)) {
  229. continue;
  230. } else {
  231. throw $e;
  232. }
  233. }
  234. try {
  235. if ($camelCaseProperty === 'CustomAttributes') {
  236. $setterValue = $this->convertCustomAttributeValue($value, $className);
  237. } else {
  238. $setterValue = $this->convertValue($value, $returnType);
  239. }
  240. } catch (SerializationException $e) {
  241. throw new SerializationException(
  242. new Phrase(
  243. 'Error occurred during "%field_name" processing. %details',
  244. ['field_name' => $propertyName, 'details' => $e->getMessage()]
  245. )
  246. );
  247. }
  248. $object->{$setterName}($setterValue);
  249. }
  250. }
  251. return $object;
  252. }
  253. /**
  254. * Convert custom attribute data array to array of AttributeValue Data Object
  255. *
  256. * @param array $customAttributesValueArray
  257. * @param string $dataObjectClassName
  258. * @return AttributeValue[]
  259. * @throws SerializationException
  260. */
  261. protected function convertCustomAttributeValue($customAttributesValueArray, $dataObjectClassName)
  262. {
  263. $result = [];
  264. $dataObjectClassName = ltrim($dataObjectClassName, '\\');
  265. foreach ($customAttributesValueArray as $key => $customAttribute) {
  266. if (!is_array($customAttribute)) {
  267. $customAttribute = [AttributeValue::ATTRIBUTE_CODE => $key, AttributeValue::VALUE => $customAttribute];
  268. }
  269. list($customAttributeCode, $customAttributeValue) = $this->processCustomAttribute($customAttribute);
  270. $entityType = $this->serviceTypeToEntityTypeMap->getEntityType($dataObjectClassName);
  271. if ($entityType) {
  272. $type = $this->customAttributeTypeLocator->getType(
  273. $customAttributeCode,
  274. $entityType
  275. );
  276. } else {
  277. $type = TypeProcessor::ANY_TYPE;
  278. }
  279. if ($this->typeProcessor->isTypeAny($type) || $this->typeProcessor->isTypeSimple($type)
  280. || !is_array($customAttributeValue)
  281. ) {
  282. try {
  283. $attributeValue = $this->convertValue($customAttributeValue, $type);
  284. } catch (SerializationException $e) {
  285. throw new SerializationException(
  286. new Phrase(
  287. 'Attribute "%attribute_code" has invalid value. %details',
  288. ['attribute_code' => $customAttributeCode, 'details' => $e->getMessage()]
  289. )
  290. );
  291. }
  292. } else {
  293. $attributeValue = $this->_createDataObjectForTypeAndArrayValue($type, $customAttributeValue);
  294. }
  295. //Populate the attribute value data object once the value for custom attribute is derived based on type
  296. $result[$customAttributeCode] = $this->attributeValueFactory->create()
  297. ->setAttributeCode($customAttributeCode)
  298. ->setValue($attributeValue);
  299. }
  300. return $result;
  301. }
  302. /**
  303. * Derive the custom attribute code and value.
  304. *
  305. * @param string[] $customAttribute
  306. * @return string[]
  307. */
  308. private function processCustomAttribute($customAttribute)
  309. {
  310. $camelCaseAttributeCodeKey = lcfirst(
  311. SimpleDataObjectConverter::snakeCaseToUpperCamelCase(AttributeValue::ATTRIBUTE_CODE)
  312. );
  313. // attribute code key could be snake or camel case, depending on whether SOAP or REST is used.
  314. if (isset($customAttribute[AttributeValue::ATTRIBUTE_CODE])) {
  315. $customAttributeCode = $customAttribute[AttributeValue::ATTRIBUTE_CODE];
  316. } elseif (isset($customAttribute[$camelCaseAttributeCodeKey])) {
  317. $customAttributeCode = $customAttribute[$camelCaseAttributeCodeKey];
  318. } else {
  319. $customAttributeCode = null;
  320. }
  321. if (!$customAttributeCode && !isset($customAttribute[AttributeValue::VALUE])) {
  322. throw new SerializationException(
  323. new Phrase('An empty custom attribute is specified. Enter the attribute and try again.')
  324. );
  325. } elseif (!$customAttributeCode) {
  326. throw new SerializationException(
  327. new Phrase(
  328. 'A custom attribute is specified with a missing attribute code. Verify the code and try again.'
  329. )
  330. );
  331. } elseif (!array_key_exists(AttributeValue::VALUE, $customAttribute)) {
  332. throw new SerializationException(
  333. new Phrase(
  334. 'The "' . $customAttributeCode .
  335. '" attribute code doesn\'t have a value set. Enter the value and try again.'
  336. )
  337. );
  338. }
  339. return [$customAttributeCode, $customAttribute[AttributeValue::VALUE]];
  340. }
  341. /**
  342. * Creates a data object type from a given type name and a PHP array.
  343. *
  344. * @param string $type The type of data object to create
  345. * @param array $customAttributeValue The data object values
  346. * @return mixed
  347. */
  348. protected function _createDataObjectForTypeAndArrayValue($type, $customAttributeValue)
  349. {
  350. if (substr($type, -2) === "[]") {
  351. $type = substr($type, 0, -2);
  352. $attributeValue = [];
  353. foreach ($customAttributeValue as $value) {
  354. $attributeValue[] = $this->_createFromArray($type, $value);
  355. }
  356. } else {
  357. $attributeValue = $this->_createFromArray($type, $customAttributeValue);
  358. }
  359. return $attributeValue;
  360. }
  361. /**
  362. * Convert data from array to Data Object representation if type is Data Object or array of Data Objects.
  363. *
  364. * @param mixed $data
  365. * @param string $type Convert given value to the this type
  366. * @return mixed
  367. * @throws \Magento\Framework\Exception\LocalizedException
  368. */
  369. public function convertValue($data, $type)
  370. {
  371. $isArrayType = $this->typeProcessor->isArrayType($type);
  372. if ($isArrayType && isset($data['item'])) {
  373. $data = $this->_removeSoapItemNode($data);
  374. }
  375. if ($this->typeProcessor->isTypeSimple($type) || $this->typeProcessor->isTypeAny($type)) {
  376. $result = $this->typeProcessor->processSimpleAndAnyType($data, $type);
  377. } else {
  378. /** Complex type or array of complex types */
  379. if ($isArrayType) {
  380. // Initializing the result for array type else it will return null for empty array
  381. $result = is_array($data) ? [] : null;
  382. $itemType = $this->typeProcessor->getArrayItemType($type);
  383. if (is_array($data)) {
  384. foreach ($data as $key => $item) {
  385. $result[$key] = $this->_createFromArray($itemType, $item);
  386. }
  387. }
  388. } else {
  389. $result = $this->_createFromArray($type, $data);
  390. }
  391. }
  392. return $result;
  393. }
  394. /**
  395. * Remove item node added by the SOAP server for array types
  396. *
  397. * @param array|mixed $value
  398. * @return array
  399. * @throws \InvalidArgumentException
  400. */
  401. protected function _removeSoapItemNode($value)
  402. {
  403. if (isset($value['item'])) {
  404. if (is_array($value['item'])) {
  405. $value = $value['item'];
  406. } else {
  407. return [$value['item']];
  408. }
  409. } else {
  410. throw new \InvalidArgumentException('Value must be an array and must contain "item" field.');
  411. }
  412. /**
  413. * In case when only one Data object value is passed, it will not be wrapped into a subarray
  414. * within item node. If several Data object values are passed, they will be wrapped into
  415. * an indexed array within item node.
  416. */
  417. $isAssociative = array_keys($value) !== range(0, count($value) - 1);
  418. return $isAssociative ? [$value] : $value;
  419. }
  420. /**
  421. * Process an input error
  422. *
  423. * @param array $inputError
  424. * @return void
  425. * @throws InputException
  426. */
  427. protected function processInputError($inputError)
  428. {
  429. if (!empty($inputError)) {
  430. $exception = new InputException();
  431. foreach ($inputError as $errorParamField) {
  432. $exception->addError(
  433. new Phrase('"%fieldName" is required. Enter and try again.', ['fieldName' => $errorParamField])
  434. );
  435. }
  436. if ($exception->wasErrorAdded()) {
  437. throw $exception;
  438. }
  439. }
  440. }
  441. }