* @license OSL-3.0 http://opensource.org/licenses/osl-3.0 * @link http://www.netresearch.de/ */ namespace apimatic\jsonmapper; use Exception; use ReflectionClass; use ReflectionException; use ReflectionMethod; /** * Automatically map JSON structures into objects. * * @category Netresearch * @package JsonMapper * @author Christian Weiske * @license OSL-3.0 http://opensource.org/licenses/osl-3.0 * @link http://www.netresearch.de/ */ class JsonMapper { /** * PSR-3 compatible logger object * * @link http://www.php-fig.org/psr/psr-3/ * @var object * @see setLogger() */ protected $logger; /** * Throw an exception when JSON data contain a property * that is not defined in the PHP class * * @var boolean */ public $bExceptionOnUndefinedProperty = false; /** * Calls this method on the PHP class when an undefined property * is found. This method should receive two arguments, $key * and $value for the property key and value. Only works if * $bExceptionOnUndefinedProperty is set to false. * * @var string */ public $sAdditionalPropertiesCollectionMethod = null; /** * Throw an exception if the JSON data miss a property * that is marked with @required in the PHP class * * @var boolean */ public $bExceptionOnMissingData = false; /** * If the types of map() parameters shall be checked. * You have to disable it if you're using the json_decode "assoc" parameter. * * `json_decode($str, false)` * * @var boolean */ public $bEnforceMapType = true; /** * Contains user provided map of class names vs their child classes. * This is only needed if discriminators are to be used. PHP reflection is not * used to get child classes because most code bases use autoloaders where * classes are lazily loaded. * * @var array */ public $arChildClasses = array(); /** * Contains user provided map of discriminators substitution along with * its actual value. * This is only needed if discriminators are to be used in type combinators, * and their actual values are substituted in the type combinator templates. * * @var array */ public $discriminatorSubs = array(); /** * Runtime cache for inspected classes. This is particularly effective if * mapArray() is called with a large number of objects * * @var array property inspection result cache */ protected $arInspectedClasses = array(); /** * An array of directives from php defined configuration files. * * @var array|null Array of values from the configuration files. */ protected $config = null; protected $zendOptimizerPlusExtensionLoaded = null; /** * Constructor for JsonMapper. * * @throws JsonMapperException */ function __construct() { $zendOptimizerPlus = "Zend Optimizer+"; $zendOptimizerPlusSaveCommentKey = "zend_optimizerplus.save_comments"; $opCacheSaveCommentKey = "opcache.save_comments"; if (!isset($this->config)) { $iniPath = php_ini_loaded_file(); $accessAllowed = $this->isPathAllowed($iniPath, ini_get('open_basedir')); if ($accessAllowed && is_readable($iniPath)) { $this->config = parse_ini_file($iniPath); } } if (!isset($this->zendOptimizerPlusExtensionLoaded)) { $this->zendOptimizerPlusExtensionLoaded = extension_loaded($zendOptimizerPlus); } $zendOptimizerDiscardedComments = $this->zendOptimizerPlusExtensionLoaded === true && $this->commentsDiscardedFor($zendOptimizerPlusSaveCommentKey); $opCacheDiscardedComments = $this->commentsDiscardedFor($opCacheSaveCommentKey); if ($zendOptimizerDiscardedComments || $opCacheDiscardedComments) { throw JsonMapperException::commentsDisabledInConfigurationException( array($zendOptimizerPlusSaveCommentKey, $opCacheSaveCommentKey) ); } } /** * Returns true if the provided file path is accessible and * not restricted by open_basedir restriction. * * @param string|false $filePath Real file path to be checked. * @param string|false $allowedPaths Allowed paths separated by os * path separator. * * @return bool Whether the provided path is allowed to access. */ protected function isPathAllowed($filePath, $allowedPaths) { if (empty($filePath)) { return false; } if (empty($allowedPaths)) { return true; } $allowedPathArray = explode(PATH_SEPARATOR, $allowedPaths); if (!in_array(dirname($filePath), $allowedPathArray)) { return false; } return true; } /** * Returns true if comments are disabled locally or in php.ini file. * However, if comments are enabled locally by overwriting global * php.ini configurations then returns false. * * @param string $configKey Configuration key to be checked. * * @return bool Whether comments are disabled in environment or php.ini file. */ protected function commentsDiscardedFor($configKey) { $localConfigVal = strtolower(ini_get($configKey)); $phpIniConfigVal = !isset($this->config[$configKey]) ? '' : strtolower($this->config[$configKey]); $enableValues = ["1", "on", "true", "yes"]; $disableValues = ["0", "off", "false", "no"]; $notEnabled = in_array($localConfigVal, $enableValues, true) === false; $isDisabled = in_array($localConfigVal, $disableValues, true) === true; $isDisabledInPhpIniFile = in_array( $phpIniConfigVal, $disableValues, true ) === true; return $notEnabled && ($isDisabled || $isDisabledInPhpIniFile); } /** * Map data all data in $json into the given $object instance. * * @param object $json JSON object structure from json_decode() * @param object $object Object to map $json data into * @param bool $strict True if looking to map with strict type checking, * Default: false * * @return object Mapped object is returned. * @see mapArray() */ public function map($json, $object, $strict = false) { if ($this->bEnforceMapType && !is_object($json)) { throw new \InvalidArgumentException( 'JsonMapper::map() requires first argument to be an object' . ', ' . gettype($json) . ' given.' ); } if (!is_object($object)) { throw new \InvalidArgumentException( 'JsonMapper::map() requires second argument to be an object' . ', ' . gettype($object) . ' given.' ); } $strClassName = get_class($object); $rc = new ReflectionClass($object); $strNs = $rc->getNamespaceName(); $providedProperties = array(); $additionalPropertiesMethod = $this->getAdditionalPropertiesMethod($rc); foreach ($json as $key => $jvalue) { // $providedProperties[$key] = true; $isAdditional = false; // Store the property inspection results so we don't have to do it // again for subsequent objects of the same type if (!isset($this->arInspectedClasses[$strClassName][$key])) { $this->arInspectedClasses[$strClassName][$key] = $this->inspectProperty($rc, $key); } list($hasProperty, $accessor, $type, $factoryMethod, $mapsBy, $namespace) = $this->arInspectedClasses[$strClassName][$key]; if ($accessor !== null) { $providedProperties[$accessor->getName()] = true; } if (!$hasProperty) { if ($this->bExceptionOnUndefinedProperty) { throw JsonMapperException::undefinedPropertyException( $key, $strClassName ); } $isAdditional = true; $this->log( 'info', 'Property {property} does not exist in {class}', array('property' => $key, 'class' => $strClassName) ); } if ($accessor === null) { if ($this->bExceptionOnUndefinedProperty) { throw JsonMapperException::undefinedPropertyException( $key, $strClassName, true ); } $isAdditional = true; $this->log( 'info', 'Property {property} has no public setter method in {class}', array('property' => $key, 'class' => $strClassName) ); } //FIXME: check if type exists, give detailled error message if not if ($type === '') { throw JsonMapperException::missingTypePropertyException( $key, $strClassName ); } if ($isAdditional) { if ($additionalPropertiesMethod !== null) { $additionalPropertiesMethod->invoke($object, $key, $jvalue); } continue; } $value = $this->getMappedValue( $jvalue, $type, $mapsBy, $factoryMethod, $namespace, $rc->getName(), $strict ); $this->setProperty($object, $accessor, $value, $strNs); } if ($this->bExceptionOnMissingData) { $this->checkMissingData($providedProperties, $rc); } return $object; } /** * Checks if type is an array, and extracts its dimensions and inner type. * * @param string $type Type to be checked for array. * @param int $dimensions Dimensions passed in recursions, initial: 0. * * @return array */ public function getArrayTypeAndDimensions($type, $dimensions = 0) { list($isMap, $isArray, $innerType) = TypeCombination::extractTypeInfo($type); if ($isMap || $isArray) { // if it's an array or map of some type // increment dimension and check for innerType return $this->getArrayTypeAndDimensions($innerType, ++$dimensions); } return array($type, $dimensions); } /** * Try calling the factory method if exists, otherwise throw JsonMapperException * * @param string $factoryMethod factory method in the format * 'path/to/callable/function argType' * @param mixed $value value to be passed in as param into factory * method. * @param string $strClassName strClassName referencing this factory method * * @return mixed|false * @throws JsonMapperException */ protected function callFactoryMethod($factoryMethod, $value, $strClassName) { $factoryMethod = explode(' ', $factoryMethod)[0]; if (!is_callable($factoryMethod)) { throw JsonMapperException::unCallableFactoryMethodException( $factoryMethod, $strClassName ); } return call_user_func($factoryMethod, $value); } /** * Try calling the given function with value, return [true, updatedValue] * if call successful. * * @param mixed $value value to be passed in argument of factory method. * @param string $factoryMethod factory method string in the format * 'path/to/callable/function argType'. * * @return array Return an array [bool $success, $value] and value will be the * failure cause if not success. */ protected function callFactoryWithErrorHandling($value, $factoryMethod) { $success = true; if (version_compare(phpversion(), '7.0', '<')) { try { $value = $this->callFactoryMethod($factoryMethod, $value, ''); } catch (Exception $e) { // In Php versions < 7.0 catching only exceptions but not typeErrors // since strict types were not available for php < 7.0 // also we can't use throwable since its only available after php 7.0 $success = false; $value = $e->getMessage(); } } else { try { $value = $this->callFactoryMethod($factoryMethod, $value, ''); } catch (\Throwable $e) { // In Php versions >= 7.0 catching exceptions including typeErrors // using Throwable since its base interface for Exceptions & Errors // since types can be strict for php >= 7.0 $success = false; $value = $e->getMessage(); } } return [$success, $value]; } /** * Get mapped value for a property in an object. * * @param mixed $jvalue Raw normalized data for the property * @param string $type Type found by inspectProperty() * @param string|null $mapsBy OneOf/AnyOf types hint found by * inspectProperty in mapsBy annotation * @param string[]|null $factoryMethods Callable factory methods for property * @param string $namespace Namespace of the class * @param string $className Name of the class * @param bool $strict True if looking to map with strict * type checking. * * @return array|false|mixed|object|null * @throws JsonMapperException|ReflectionException */ protected function getMappedValue( $jvalue, $type, $mapsBy, $factoryMethods, $namespace, $className, $strict ) { if ($mapsBy) { return $this->mapFor( $jvalue, $mapsBy, $namespace, $factoryMethods, $className ); } //use factory method generated value if factory provided if ($factoryMethods !== null && isset($factoryMethods[0])) { return $this->callFactoryMethod( $factoryMethods[0], $jvalue, $className ); } if ($this->isNullable($type)) { if ($jvalue === null) { return null; } $type = $this->removeNullable($type); } if ($type === null || $type === 'mixed' || $type === '') { //no given type - simply return the json data return $jvalue; } else if ($this->isObjectOfSameType($type, $jvalue)) { return $jvalue; } else if ($this->isSimpleType($type)) { if ($strict && !$this->isSimpleValue($jvalue, $type)) { // if mapping strictly for multipleTypes throw JsonMapperException::unableToSetTypeException( $type, json_encode($jvalue) ); } settype($jvalue, $type); return $jvalue; } $array = null; list($subtype, $dimension) = $this->getArrayTypeAndDimensions($type); if ($dimension > 0) { // array with some dimensions $array = array(); } else if (substr($type, -1) == ']') { list($proptype, $subtype) = explode('[', substr($type, 0, -1)); if (!$this->isSimpleType($proptype)) { $proptype = $this->getFullNamespace($proptype, $namespace); } $array = $this->createInstance($proptype); } else if ($type == 'ArrayObject' || is_subclass_of($type, 'ArrayObject') ) { $subtype = null; $array = $this->createInstance($type); } if ($array !== null) { if (!$this->isSimpleType($subtype)) { $subtype = $this->getFullNamespace($subtype, $namespace); } if ($jvalue === null) { $child = null; } else if ($this->isRegisteredType( $this->getFullNamespace($subtype, $namespace) ) ) { $child = $this->mapClassArray( $jvalue, $subtype, $dimension, $strict ); } else { $child = $this->mapArray( $jvalue, $array, $subtype, $dimension, $strict ); } } else if ($this->isFlatType(gettype($jvalue))) { //use constructor parameter if we have a class // but only a flat type (i.e. string, int) if ($jvalue === null) { $child = null; } else { $type = $this->getFullNamespace($type, $namespace); $child = new $type($jvalue); } } else { $type = $this->getFullNamespace($type, $namespace); $child = $this->mapClass($jvalue, $type, $strict); } return $child; } /** * Check if an array is Associative (has string keys) or * its Indexed (empty or non-string keys), returns [isAssociative, isIndexed] * * @param mixed $value A value that could be isAssociative or isIndexed array * * @return array Returns Array of result i.e [isAssociative, isIndexed] */ protected function isAssociativeOrIndexed($value) { if (is_object($value)) { return [true, false]; } if (!is_array($value)) { return [false, false]; } foreach ($value as $key => $v) { if (is_string($key)) { return [true, false]; } } return [false, true]; } /** * Gets not nested type for the given value * * @param mixed $value Value to be checked for type * * @return string|false Return flat PHP types for the given value * and if not flat type return false. */ protected function getFlatType($value) { $type = gettype($value); if (!$this->isFlatType($type)) { return false; } switch ($type) { case 'integer': $type = 'int'; break; case 'double': $type = 'float'; break; case 'boolean': $type = 'bool'; break; case 'NULL': $type = 'null'; break; } return $type; } /** * Check all given factory methods that can be called with given value. * * @param mixed $value Any value to be checked with factoryMethods. * @param mixed $newVal A copy of value to be updated. * @param string $type Extracted type of the value. * @param string[] $factoryMethods Methods in the format 'path/to/method argType' * which will be converting $value into any * desirable type. * * @return string Returns the type or typeGroup of value based on * given factory methods. * @throws JsonMapperException */ protected function applyFactoryMethods($value, &$newVal, $type, $factoryMethods) { $errorMsg = []; $types = [$type]; // list of possible types foreach ($factoryMethods as $m) { // checking each provided factory method $method = explode(' ', $m); // try calling factory method list($success, $val) = $this->callFactoryWithErrorHandling($value, $m); if ($success) { if ($type == $method[1]) { // if method call is successful // and given type equals to argType of factory method // update the value with returned $val of factory method // and return with type early $newVal = $val; return $type; } // if method call is successful // and given type is not same as argType of factory method // then add argType in list of possible types for $value array_push($types, $method[1]); } elseif ($type == $method[1]) { // if method call is failure given type equals to argType of // factory method then add reason $val as an error message array_push($errorMsg, "$method[0]: $val"); } } if (!empty($errorMsg)) { // if any error msg is added then throw exception throw JsonMapperException::invalidArgumentFactoryMethodException( $type, join("\n", $errorMsg) ); } // converting possible types array into the string format // of an anyof typeGroup $types = array_unique($types); asort($types); $type = join(',', $types); if (count($types) > 1) { // wrap in brackets for multiple types $type = "($type)"; } return $type; } /** * Extract type from any given value. * * @param mixed $value Any value to be checked for type, should be * an array if checking for inner type * @param string[] $factory Methods in the format 'path/to/method argType' * which will be converting $value into any * desirable type, Default: [] * @param string $start string to be appended at the start of the * extracted type, Default: '' * @param string $end string to be appended at the end of the * extracted type, Default: '' * * @return string Returns the type that could be mapped on the given value. * @throws JsonMapperException */ protected function getType(&$value, $factory = [], $start = '', $end = '') { $type = $this->getFlatType($value); $newVal = $value; if (!$type && is_array($value)) { if ($this->isAssociativeOrIndexed($value)[0]) { // if value is associative array $start .= 'array $v) { array_push($types, $this->getType($v, $factory)); $newVal[$k] = $v; } $types = array_unique($types); asort($types); $isOneOfOrAnyOf = !empty($types) && substr($types[0], -1) === ')'; if (count($types) > 1 || $isOneOfOrAnyOf) { // wrap in brackets for multiple types or oneof/anyof type $start .= '('; $end = ')' . $end; } $type = join(',', $types); } elseif (!$type && is_object($value)) { $class = get_class($value); // returns full path of class $slashPos = strrpos($class, '\\'); if (!$slashPos) { // if slash not found then replace with -1 $slashPos = -1; } $type = substr($class, ++$slashPos); } $type = "$start$type$end"; if (!empty($factory)) { $type = $this->applyFactoryMethods($value, $newVal, $type, $factory); } $value = $newVal; return $type; } /** * Check the given type/types in the provided typeGroup, return true if * type(s) exists in the typeGroup * * @param TypeCombination|string $typeGroup TypesCombination object or string * format for grouped types. All kind * of groups are allowed here. * @param TypeCombination|string $type Can be a normal type like string[], * int, Car, etc. or a combination of * types like (CarA,CarB)[], (int,Enum), * or array. * @param string $start prefix used by string $type, * Default: "" * @param string $end postfix used by string $type, * Default: "" * * @return bool */ protected function checkForType($typeGroup, $type, $start = '', $end = '') { if (is_string($typeGroup)) { // convert into TypeCombination object $typeGroup = TypeCombination::withFormat($typeGroup); } if (is_string($type) && strpos($type, '(') !== false) { // for combination of types like: (A,B)[] or array // convert into TypeCombination object $type = TypeCombination::withFormat($type); } $checkAllInner = false; // required when $type instance of TypeCombination. if (is_string($type)) { // for checking simple types like: string, int[] or Car[] if ($typeGroup->getGroupName() == 'map') { $start .= 'arraygetGroupName() == 'array') { $end = '[]' . $end; } foreach ($typeGroup->getTypes() as $t) { if (is_string($t)) { $matched = $type === "$start$t$end"; } else { $matched = $this->checkForType($t, $type, $start, $end); } if ($matched) { // if any type in the typeGroup matched with given type, // then early return true return true; } } return false; } elseif (in_array($type->getGroupName(), ['array','map'])) { // To handle type if its array/map group of types // extract all internal groups from the given typeGroup that // are similar to $type $typeGroup = TypeCombination::with($typeGroup->extractSimilar($type)); // update type to the innermost level of oneof/anyof $type = $type->extractOneOfAnyOfGroup(); // check all inner elements of $type $checkAllInner = true; } // To handle type if its oneof/anyof group of types foreach ($type->getTypes() as $t) { $contains = $this->checkForType($typeGroup, $t); if (!$checkAllInner && $contains) { // if any type is found then // type is matched with $typeGroup return true; } if ($checkAllInner && !$contains) { // if any type is missing then // type is not matched with $typeGroup return false; } } return $checkAllInner; } /** * Converts the given typeCombination into its string format. * * @param TypeCombination|string $type Combined type/Single type. * * @return string */ protected function formatType($type) { return is_string($type) ? $type : $type->getFormat(); } /** * Checks if type of the given value is present in the type group, * also updates the value when necessary. * * @param string $typeGroup String format for grouped types, i.e. * oneof(Car,Atom) * @param mixed $value Any value to be checked in type group * @param array $factoryMethods Callable factory methods for the value, that * are required to serialize it into any of the * provided types in typeGroup in the format: * 'path/to/method argType', Default: [] * * @return mixed Returns the same value or updated one if any factory method * is applied * @throws JsonMapperException Throws exception if a factory method is provided * but applicable on value, or also throws an * exception if type of value didn't match with type * group */ public function checkTypeGroupFor($typeGroup, $value, $factoryMethods = []) { $type = self::getType($value, $factoryMethods); if ($this->checkForType($typeGroup, $type)) { return $value; } throw JsonMapperException::unableToMapException('Type', $type, $typeGroup); } /** * Map the data in $value by the provided $typeGroup i.e. oneOf(A,B) * will try to map value with only one of A or B, that matched. While * anyOf(A,B) will try to map it with any of A or B and sets its type to * the first one that matched. * * @param mixed $value Raw normalized value to be * mapped with any typeGroup * @param string|TypeCombination $typeGroup TypesCombination object or * string format for grouped types * @param string $namespace Namespace of any customType * class that's present in the * provided typeGroup. * @param string[]|null $factoryMethods Callable factory methods for * the value, that are required * to deserialize it into any of * the provided types in typeGroup * like ['path/to/method argType'] * @param string|null $className Name of the parent class that's * holding this property (if any) * * @return array|mixed|object * @throws JsonMapperException */ public function mapFor( $value, $typeGroup, $namespace = '', $factoryMethods = null, $className = null ) { if (is_string($typeGroup)) { // convert into TypeCombination object $typeGroup = TypeCombination::withFormat( $typeGroup, isset($factoryMethods) ? $factoryMethods : [] ); } $isArrayGroup = $typeGroup->getGroupName() == 'array'; $isMapGroup = $typeGroup->getGroupName() == 'map'; if ($isArrayGroup || $isMapGroup) { list($isAssociative, $isIndexed) = $this->isAssociativeOrIndexed($value); if (($isMapGroup && !$isAssociative) || ($isArrayGroup && !$isIndexed)) { // Throw exception: // IF value is not associative array with groupType == map // Or value is not indexed array with groupType == array $typeName = $isMapGroup ? 'Associative Array' : 'Array'; throw JsonMapperException::unableToMapException( $typeName, $this->formatType($typeGroup), json_encode($value) ); } $mappedObject = []; foreach ($value as $k => $v) { $mappedObject[$k] = $this->mapFor( $v, $typeGroup->getTypes()[0], $namespace, null, $className ); } return $mappedObject; } return $this->checkMappingsFor( $typeGroup, $value, $className, $namespace, function ($type, $value, $factoryMethods, $nspace, $className) { if (is_string($type)) { return $this->getMappedValue( $value, $type, null, $factoryMethods, $nspace, $className, true ); } return $this->mapFor( $value, $type, $nspace, null, $className ); } ); } /** * Checks mappings for all types with mappedObject, provided by * mappedObjectCallback. * * @param TypeCombination $typeGroup TypesCombination object or string * format for grouped types * @param mixed $value Mixed typed value to be checked * by mappings with each of the types * @param string|null $className Name of the class * @param string $namespace Namespace of the class * @param callable $mappedObjCallback Callback function to be called with * each type in provided types, this * function must return the mapped * Object, for which the mapping will * be checked, and to ignore any type, * it can throw JsonMapperException * * @return false|mixed|null Returns the final mapped object after checking * for oneOf and anyOf cases * @throws JsonMapperException */ protected function checkMappingsFor( $typeGroup, $value, $className, $namespace, $mappedObjCallback ) { $mappedObject = null; $mappedWith = ''; $deserializers = $typeGroup->getDeserializers(); $selectedDeserializer = null; $discSubs = isset($this->discriminatorSubs) ? $this->discriminatorSubs : []; // check json value for each type in types array foreach ($typeGroup->getTypes() as $type) { try { if (is_string($type)) { list($matched, $method) = $this->isValueOfType( $value, $type, $typeGroup->getDiscriminator($type, $discSubs), $namespace, $deserializers ); if (!$matched) { // skip this type as it can't be mapped on the given value. continue; } $selectedDeserializer = isset($method) ? [$method] : null; } $mappedObject = call_user_func( $mappedObjCallback, $type, $value, $selectedDeserializer, $namespace, $className ); } catch (Exception $e) { continue; // ignore the type if it can't be mapped for given value } $matchedType = $type; if ($typeGroup->getGroupName() == 'oneOf' && $mappedWith) { // if its oneOf and we have a value that is already mapped, // then throw jsonMapperException throw OneOfValidationException::moreThanOneOfException( $this->formatType($matchedType), $this->formatType($mappedWith), json_encode($value) ); } $mappedWith = $matchedType; if ($typeGroup->getGroupName() == 'anyOf') { break; // break if its anyOf, and we already have mapped its value } } if (!$mappedWith) { if ($typeGroup->getGroupName() == 'oneOf') { throw OneOfValidationException::cannotMapAnyOfException( $this->formatType($typeGroup), json_encode($value) ); } throw AnyOfValidationException::cannotMapAnyOfException( $this->formatType($typeGroup), json_encode($value) ); } return $mappedObject; } /** * Checks types against the value. * * @param mixed $value Value to be checked * @param string $type type defined in param's typehint * @param array|null $disc An array with format discriminatorFieldName * as element 1 and discriminatorValue as * element 2 * @param string $nspace Namespace of the class * @param string[] $methods deserializer functions array in the format * ["pathToCallableFunction typeOfValue", ...] * Default: [] * * @return array array(bool $matched, ?string $method) $matched represents if * Type matched with value, $method represents the selected * factory method (if any) * @throws ReflectionException * @throws JsonMapperException */ protected function isValueOfType($value, $type, $disc, $nspace, $methods = []) { if (!empty($methods)) { $methodFound = false; foreach ($methods as $method) { if (isset($method) && explode(' ', $method)[1] == $type) { $methodFound = true; if ($this->callFactoryWithErrorHandling($value, $method)[0]) { return array(true, $method); } } } if ($methodFound) { // if any method was found but couldn't deserialize value return array(false, null); } } list($isMap, $isArray, $innerType) = TypeCombination::extractTypeInfo($type); if ($isMap || $isArray) { // if type is array like int[] or map like array list($isAssociative, $isIndexed) = $this->isAssociativeOrIndexed($value); if (($isMap && $isAssociative) || ($isArray && $isIndexed)) { // Value must be associativeArray/object for MapType // Or it must be indexed array for ArrayType foreach ($value as $v) { if (!$this->isValueOfType($v, $innerType, $disc, $nspace)[0]) { // false if any element is not of same type return array(false, null); } } // true only if all elements in the array/map are of same type return array(true, null); } return array(false, null); // false if type is array/map but value is not } if ($type == 'mixed') { return array(true, null); } if ($type == 'null' || $this->isSimpleType($type) || !is_object($value)) { return array($this->isSimpleValue($value, $type), null); } if (!isset($disc)) { // if default discriminator is not provided // try getting it from the class annotations $rc = new ReflectionClass($this->getFullNamespace($type, $nspace)); $disc = $this->getDiscriminator($rc); } return array($this->isComplexValue($value, $disc), null); } /** * Check if value is a complex type with provided discriminator * * @param mixed $value Value to be checked * @param array|null $discriminator An array with format discriminatorFieldName * as element 1 and discriminatorValue as * element 2 * * @return bool True if value is a complexType with provided discriminator */ protected function isComplexValue($value, $discriminator) { if (!isset($discriminator)) { // if discriminator is missing return true; } list($discriminatorField, $discriminatorValue) = $discriminator; if (!isset($value->{$discriminatorField})) { // if value didn't have discriminatorField return true; } // if discriminator field is set then decide w.r.t its value return $value->{$discriminatorField} == $discriminatorValue; } /** * Checks if the given type is a "simple type" * * @param string $type type name from gettype() * * @return boolean True if it is a simple PHP type */ protected function isSimpleType($type) { return $type == 'string' || $type == 'boolean' || $type == 'bool' || $type == 'integer' || $type == 'int' || $type == 'float' || $type == 'double' || $type == 'array' || $type == 'object'; } /** * Check if value is of simple type * * @param mixed $value Value to be checked * @param string $type Type defined in param's typehint * * @return bool True if value is of the given simple type */ protected function isSimpleValue($value, $type) { return ($type == 'string' && is_string($value)) || ($type == 'array' && (is_array($value) || is_object($value))) || ($type == 'object' && is_object($value)) || ($type == 'bool' && is_bool($value)) || ($type == 'boolean' && is_bool($value)) || ($type == 'int' && is_int($value)) || ($type == 'integer' && is_int($value)) || ($type == 'float' && is_float($value)) || ($type == 'double' && is_float($value)) || ($type == 'null' && is_null($value)); } /** * Map all data in $json into a new instance of $type class. * * @param object|null $json JSON object structure from json_decode() * @param string $type The type of class instance to map into. * @param bool $strict True if looking to map with strict type checking, * Default: false * * @return object|null Mapped object is returned. * @throws ReflectionException|JsonMapperException * @see mapClassArray() */ public function mapClass($json, $type, $strict = false) { if ($json === null) { return null; } if (!is_object($json)) { throw new \InvalidArgumentException( 'JsonMapper::mapClass() requires first argument to be an object' . ', ' . gettype($json) . ' given.' ); } $ttype = ltrim($type, "\\"); if (!class_exists($type)) { throw new \InvalidArgumentException( 'JsonMapper::mapClass() requires second argument to be a class name' . ', ' . $type . ' given.' ); } $rc = new ReflectionClass($ttype); //try and find a class with matching discriminator $matchedRc = $this->getDiscriminatorMatch($json, $rc); //otherwise fallback to an instance of $type class if ($matchedRc === null) { $instance = $this->createInstance($ttype, $json, $strict); } else { $instance = $this->createInstance( $matchedRc->getName(), $json, $strict ); } return $this->map($json, $instance, $strict); } /** * Get class instance that best matches the class * * @param object|null $json JSON object structure from json_decode() * @param ReflectionClass $rc Class to get instance of. This method * will try to first match the * discriminator field with the * discriminator value of the current * class or its child class. If no * matches is found, then the current * class's instance is returned. * * @return ReflectionClass|null Object instance if match is found. * @throws ReflectionException */ protected function getDiscriminatorMatch($json, $rc) { $discriminator = $this->getDiscriminator($rc); if ($discriminator) { list($fieldName, $fieldValue) = $discriminator; if (isset($json->{$fieldName}) && $json->{$fieldName} === $fieldValue) { return $rc; } if (!$this->isRegisteredType($rc->name)) { return null; } foreach ($this->getChildClasses($rc) as $clazz) { $childRc = $this->getDiscriminatorMatch($json, $clazz); if ($childRc) { return $childRc; } } } return null; } /** * Get discriminator info * * @param ReflectionClass $rc ReflectionClass of class to inspect * * @return array|null An array with discriminator arguments * Element 1 is discriminator field name * and element 2 is discriminator value. */ protected function getDiscriminator($rc) { $annotations = $this->parseAnnotations($rc->getDocComment()); $annotationInfo = array(); if (isset($annotations['discriminator'])) { $annotationInfo[0] = trim($annotations['discriminator'][0]); if (isset($annotations['discriminatorType'])) { $annotationInfo[1] = trim($annotations['discriminatorType'][0]); } else { $annotationInfo[1] = $rc->getShortName(); } return $annotationInfo; } return null; } /** * Get child classes from a ReflectionClass * * @param ReflectionClass $rc ReflectionClass of class to inspect * * @return ReflectionClass[] ReflectionClass instances for child classes * @throws ReflectionException */ protected function getChildClasses($rc) { $children = array(); foreach ($this->arChildClasses[$rc->name] as $class) { $child = new ReflectionClass($class); if ($child->isSubclassOf($rc)) { $children[] = $child; } } return $children; } /** * Convert a type name to a fully namespaced type name. * * @param string $type Type name (simple type or class name) * @param string $strNs Base namespace that gets prepended to the type name * * @return string Fully-qualified type name with namespace */ protected function getFullNamespace($type, $strNs) { if (\is_string($type) && $type !== '' && $type[0] != '\\') { //create a full qualified namespace if ($strNs != '') { $type = '\\' . $strNs . '\\' . $type; } } return $type; } /** * Check required properties exist in json * * @param array $providedProperties array with json properties * @param ReflectionClass $rc Reflection class to check * * @return void * @throws JsonMapperException */ protected function checkMissingData($providedProperties, ReflectionClass $rc) { foreach ($rc->getProperties() as $property) { $rprop = $rc->getProperty($property->name); $docblock = $rprop->getDocComment(); $annotations = $this->parseAnnotations($docblock); if (isset($annotations['required']) && !isset($providedProperties[$property->name]) ) { throw JsonMapperException::requiredPropertyMissingException( $property->name, $rc->getName() ); } } } /** * Get additional properties setter method for the class. * * @param ReflectionClass $rc Reflection class to check * * @return ReflectionMethod Method or null if disabled. */ protected function getAdditionalPropertiesMethod(ReflectionClass $rc) { if ($this->bExceptionOnUndefinedProperty === false && $this->sAdditionalPropertiesCollectionMethod !== null ) { $additionalPropertiesMethod = null; try { $additionalPropertiesMethod = $rc->getMethod($this->sAdditionalPropertiesCollectionMethod); if (!$additionalPropertiesMethod->isPublic()) { throw new \InvalidArgumentException( $this->sAdditionalPropertiesCollectionMethod . " method is not public on the given class." ); } if ($additionalPropertiesMethod->getNumberOfParameters() < 2) { throw new \InvalidArgumentException( $this->sAdditionalPropertiesCollectionMethod . ' method does not receive two args, $key and $value.' ); } } catch (\ReflectionException $e) { throw new \InvalidArgumentException( $this->sAdditionalPropertiesCollectionMethod . " method is not available on the given class." ); } return $additionalPropertiesMethod; } else { return null; } } /** * Map an array * * @param array $jsonArray JSON array structure from json_decode() * @param mixed $array Array or ArrayObject that gets filled with * data from $json. * @param string|object $class Class name for children objects. All children * will get mapped onto this type. Supports class * names and simple types like "string". * @param int $dimension Dimension of array to map, i.e. 2 for 2D * array, Default: 1 * @param bool $strict True if looking to map with strict type * checking, Default: false * * @return mixed Mapped $array is returned */ public function mapArray( $jsonArray, $array, $class = null, $dimension = 1, $strict = false ) { foreach ($jsonArray as $key => $jvalue) { if ($class === null) { $array[$key] = $jvalue; } else if ($dimension > 1) { $array[$key] = $this->mapArray( $jvalue, array(), $class, $dimension - 1, $strict ); } else if ($this->isFlatType(gettype($jvalue))) { // use constructor parameter if we have a class // but only a flat type (i.e. string, int) if ($jvalue === null) { $array[$key] = null; } else { if ($this->isSimpleType($class)) { if ($strict && !$this->isSimpleValue($jvalue, $class)) { // if mapping strictly for multipleTypes throw JsonMapperException::unableToSetTypeException( $class, json_encode($jvalue) ); } settype($jvalue, $class); $array[$key] = $jvalue; } else { $array[$key] = new $class($jvalue); } } } else { $instance = $this->createInstance( $class, $jvalue, $strict ); $array[$key] = $this->map($jvalue, $instance, $strict); } } return $array; } /** * Map an array * * @param array|null $jsonArray JSON array structure from json_decode() * @param string $type Class name * @param int $dimension Dimension of array to map, i.e. 2 for 2D array, * Default: 1 * @param bool $strict True if looking to map with strict type checking, * Default: false * * @return array|null A new array containing object of $type * which is mapped from $jsonArray * @throws ReflectionException|JsonMapperException */ public function mapClassArray($jsonArray, $type, $dimension = 1, $strict = false) { if ($jsonArray === null) { return null; } $array = array(); foreach ($jsonArray as $key => $jvalue) { if ($dimension > 1) { $array[$key] = $this->mapClassArray( $jvalue, $type, $dimension - 1, $strict ); } else { $array[$key] = $this->mapClass($jvalue, $type, $strict); } } return $array; } /** * Try to find out if a property exists in a given class. * Checks property first, falls back to setter method. * * @param ReflectionClass $rc Reflection class to check * @param string $name Property name * * @return array First value: if the property exists * Second value: the accessor to use ( * ReflectionMethod or ReflectionProperty, or null) * Third value: type of the property * Fourth value: factory method */ protected function inspectProperty(ReflectionClass $rc, $name) { $rmeth = null; $annotations = []; $mapsBy = null; $namespace = $rc->getNamespaceName(); foreach ($rc->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { $annotations = $this->parseAnnotations($method->getDocComment()); if ($name === $this->getMapAnnotationFromParsed($annotations)) { $rmeth = $method; $mapsBy = $this->getMapByAnnotationFromParsed($annotations); break; } } if ($rmeth === null) { //try setter method $setter = 'set' . str_replace( ' ', '', ucwords(str_replace('_', ' ', $name)) ); if ($rc->hasMethod($setter)) { $rmeth = $rc->getMethod($setter); $annotations = $this->parseAnnotations($rmeth->getDocComment()); } } if ($rmeth !== null && $rmeth->isPublic()) { $type = null; $factoryMethod = null; $namespace = $rmeth->getDeclaringClass()->getNamespaceName(); $rparams = $rmeth->getParameters(); if (count($rparams) > 0) { $type = $this->getParameterType($rparams[0]); } if (($type === null || $type === 'array' || $type === 'array|null') && isset($annotations['param'][0]) ) { list($type) = explode(' ', trim($annotations['param'][0])); } //support "@factory method_name" if (isset($annotations['factory'])) { $factoryMethod = $annotations['factory']; } return array(true, $rmeth, $type, $factoryMethod, $mapsBy, $namespace); } $rprop = null; // check for @maps annotation for hints foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $p) { $mappedName = $this->getMapAnnotation($p); if ($mappedName !== null && $name == $mappedName) { $mapsBy = $this->getMapByAnnotation($p); $rprop = $p; break; } } //now try to set the property directly if ($rprop === null) { if ($rc->hasProperty($name) && $this->getMapAnnotation($rc->getProperty($name)) === null ) { $rprop = $rc->getProperty($name); } else { //case-insensitive property matching foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $p) { if ((strcasecmp($p->name, $name) === 0) && $this->getMapAnnotation($p) === null ) { $rprop = $p; break; } } } } if ($rprop !== null) { if ($rprop->isPublic()) { $docblock = $rprop->getDocComment(); $annotations = $this->parseAnnotations($docblock); $namespace = $rprop->getDeclaringClass()->getNamespaceName(); $type = null; $factoryMethod = null; //support "@var type description" if (isset($annotations['var'][0])) { list($type) = explode(' ', $annotations['var'][0]); } //support "@factory method_name" if (isset($annotations['factory'])) { $factoryMethod = $annotations['factory']; } return array(true, $rprop, $type, $factoryMethod, $mapsBy, $namespace); } else { //no setter, private property return array(true, null, null, null, $mapsBy, $namespace); } } //no setter, no property return array(false, null, null, null, $mapsBy, $namespace); } /** * Get Phpdoc typehint for parameter * * @param \ReflectionParameter $param ReflectionParameter instance for parameter * * @return string|null */ protected function getParameterType(\ReflectionParameter $param) { if (PHP_VERSION_ID < 80000 && null !== $class = $param->getClass()) { return "\\" . $class->getName(); } if (is_callable([$param, 'hasType']) && $param->hasType()) { $type = $param->getType(); if ($type->isBuiltIn()) { $typeName = $this->reflectionTypeToString($type); } else { $typeName = "\\" . $this->reflectionTypeToString($type); } return $type->allowsNull() ? "$typeName|null" : $typeName; } return null; } /** * Get name for a ReflectionType instance * * @param \ReflectionType $type Reflection type instance * * @return string */ protected function reflectionTypeToString($type) { if (\class_exists('ReflectionNamedType') && $type instanceof \ReflectionNamedType ) { return $type->getName(); } else { return (string)$type; } } /** * Get map annotation value for a property * * @param object $property Property of a class * * @return string|null Map annotation value */ protected function getMapAnnotation($property) { $annotations = $this->parseAnnotations($property->getDocComment()); return $this->getMapAnnotationFromParsed($annotations); } /** * Get map annotation value from a parsed annotation list * * @param array $annotations Parsed annotation list * * @return string|null Map annotation value */ protected function getMapAnnotationFromParsed($annotations) { if (isset($annotations['maps'][0])) { return $annotations['maps'][0]; } return null; } /** * Get mapBy annotation value for a property * * @param object $property Property of a class * * @return string|null MapBy annotation value */ protected function getMapByAnnotation($property) { $annotations = $this->parseAnnotations($property->getDocComment()); return $this->getMapByAnnotationFromParsed($annotations); } /** * Get mapsBy annotation value from a parsed annotation list * * @param array $annotations Parsed annotation list * * @return string|null MapsBy annotation value */ protected function getMapByAnnotationFromParsed($annotations) { if (isset($annotations['mapsBy'][0])) { return $annotations['mapsBy'][0]; } return null; } /** * Set a property on a given object to a given value. * * Checks if the setter or the property are public are made before * calling this method. * * @param object $object Object to set property on * @param object $accessor ReflectionMethod or ReflectionProperty * @param mixed $value Value of property * * @return void */ protected function setProperty( $object, $accessor, $value ) { if ($accessor instanceof \ReflectionProperty) { $object->{$accessor->getName()} = $value; } else { $object->{$accessor->getName()}($value); } } /** * Create a new object of the given type. * * @param string $class Class name to instantiate * @param object $jobject Use jobject for constructor args * @param bool $strict True if looking to map with strict type checking, * Default: false * * @return object Freshly created object * @throws ReflectionException|JsonMapperException */ protected function createInstance($class, &$jobject = null, $strict = false) { $rc = new ReflectionClass($class); $ctor = $rc->getConstructor(); if ($ctor === null || 0 === $ctorReqParamsCount = $ctor->getNumberOfRequiredParameters() ) { return new $class(); } else if ($jobject === null) { throw JsonMapperException::noArgumentsException( $class, $ctor->getNumberOfRequiredParameters() ); } $ctorRequiredParams = array_slice( $ctor->getParameters(), 0, $ctorReqParamsCount ); $ctorRequiredParamsName = array_map( function (\ReflectionParameter $param) { return $param->getName(); }, $ctorRequiredParams ); $ctorRequiredParams = array_combine( $ctorRequiredParamsName, $ctorRequiredParams ); $ctorArgs = []; foreach ($jobject as $key => $jvalue) { if (count($ctorArgs) === $ctorReqParamsCount) { break; } // Store the property inspection results so we don't have to do it // again for subsequent objects of the same type if (!isset($this->arInspectedClasses[$class][$key])) { $this->arInspectedClasses[$class][$key] = $this->inspectProperty($rc, $key); } list($hasProperty, $accessor, $type, $factoryMethod, $mapsBy, $namespace) = $this->arInspectedClasses[$class][$key]; if (!$hasProperty) { // if no matching property or setter method found if (isset($ctorRequiredParams[$key])) { $rp = $ctorRequiredParams[$key]; $jtype = null; } else { continue; } } else if ($accessor instanceof \ReflectionProperty) { // if a property was found if (isset($ctorRequiredParams[$accessor->getName()])) { $rp = $ctorRequiredParams[$accessor->getName()]; $jtype = $type; } else { continue; } } else { // if a setter method was found $methodName = $accessor->getName(); $methodName = substr($methodName, 0, 3) === 'set' ? lcfirst(substr($methodName, 3)) : $methodName; if (isset($ctorRequiredParams[$methodName])) { $rp = $ctorRequiredParams[$methodName]; $jtype = $type; } else { continue; } } $ttype = $this->getParameterType($rp); if (($ttype !== null && $ttype !== 'array' && $ttype !== 'array|null') || $jtype === null ) { // when $ttype is too generic, fallback to $jtype $jtype = $ttype; } $ctorArgs[$rp->getPosition()] = $this->getMappedValue( $jvalue, $jtype, $mapsBy, $factoryMethod, $namespace, $rc->getName(), $strict ); if (!$strict) { unset($jobject->{$key}); } unset($ctorRequiredParamsName[$rp->getPosition()]); } if (count($ctorArgs) < $ctorReqParamsCount) { throw JsonMapperException::fewerArgumentsException( $class, $ctorRequiredParamsName ); } ksort($ctorArgs); return $rc->newInstanceArgs($ctorArgs); } /** * Checks if the object is of this type or has this type as one of its parents * * @param string $type class name of type being required * @param mixed $value Some PHP value to be tested * * @return boolean True if $object has type of $type */ protected function isObjectOfSameType($type, $value) { if (false === is_object($value)) { return false; } return is_a($value, $type); } /** * Checks if the given type is a type that is not nested * (simple type except array and object) * * @param string $type type name from gettype() * * @return boolean True if it is a non-nested PHP type */ protected function isFlatType($type) { return $type == 'NULL' || $type == 'string' || $type == 'boolean' || $type == 'bool' || $type == 'integer' || $type == 'int' || $type == 'double'; } /** * Is type registered with mapper * * @param string|null $type Class name * * @return boolean True if registered with $this->arChildClasses */ protected function isRegisteredType($type) { if (!isset($type)) { return false; } return isset($this->arChildClasses[ltrim($type, "\\")]); } /** * Checks if the given type is nullable * * @param string $type type name from the phpdoc param * * @return boolean True if it is nullable */ protected function isNullable($type) { return stripos('|' . $type . '|', '|null|') !== false; } /** * Remove the 'null' section of a type * * @param string $type type name from the phpdoc param * * @return string The new type value */ protected function removeNullable($type) { return substr( str_ireplace('|null|', '|', '|' . $type . '|'), 1, -1 ); } /** * Copied from PHPUnit 3.7.29, Util/Test.php * * @param string $docblock Full method docblock * * @return array */ protected function parseAnnotations($docblock) { $annotations = array(); // Strip away the docblock header and footer // to ease parsing of one line annotations $docblock = substr($docblock, 3, -2); $re = '/@(?P[A-Za-z_-]+)(?:[ \t]+(?P.*?))?[ \t]*\r?$/m'; if (preg_match_all($re, $docblock, $matches)) { $numMatches = count($matches[0]); for ($i = 0; $i < $numMatches; ++$i) { $annotations[$matches['name'][$i]][] = $matches['value'][$i]; } } return $annotations; } /** * Log a message to the $logger object * * @param string $level Logging level * @param string $message Text to log * @param array $context Additional information * * @return null */ protected function log($level, $message, array $context = array()) { if ($this->logger) { $this->logger->log($level, $message, $context); } } /** * Sets a logger instance on the object * * @param LoggerInterface $logger PSR-3 compatible logger object * * @return null */ public function setLogger($logger) { $this->logger = $logger; } } ?>