* @license OSL-3.0 http://opensource.org/licenses/osl-3.0 * @link http://cweiske.de/ */ final class JsonMapper { /** * Throw an exception when JSON data contain a property * that is not defined in the PHP class * * @var boolean */ public $bExceptionOnUndefinedProperty = false; /** * 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; /** * Throw an exception when an object is expected but the JSON contains * a non-object type. * * @var boolean */ public $bStrictObjectTypeChecking = false; /** * Throw an exception, if null value is found * but the type of attribute does not allow nulls. * * @var bool */ public $bStrictNullTypes = true; /** * Allow mapping of private and proteted properties. * * @var boolean */ public $bIgnoreVisibility = false; /** * Override class names that JsonMapper uses to create objects. * Useful when your setter methods accept abstract classes or interfaces. * * @var array */ public $classMap = array(); /** * Callback used when an undefined property is found. * * Works only when $bExceptionOnUndefinedProperty is disabled. * * Parameters to this function are: * 1. Object that is being filled * 2. Name of the unknown JSON property * 3. JSON value of the property * * @var callable */ public $undefinedPropertyHandler = null; /** * 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(); /** * 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 * * @return object Mapped object is returned. * @see mapArray() */ public function map($json, $object) { 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(); foreach ($json as $key => $jvalue) { $key = $this->getSafeName($key); $providedProperties[$key] = true; // 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) = $this->arInspectedClasses[$strClassName][$key]; if (!$hasProperty) { if ($this->bExceptionOnUndefinedProperty) { throw new JsonMapperException( 'JSON property "' . $key . '" does not exist' . ' in object of type ' . $strClassName ); } else if ($this->undefinedPropertyHandler !== null) { call_user_func( $this->undefinedPropertyHandler, $object, $key, $jvalue ); } continue; } if ($accessor === null) { if ($this->bExceptionOnUndefinedProperty) { throw new JsonMapperException( 'JSON property "' . $key . '" has no public setter method' . ' in object of type ' . $strClassName ); } continue; } if ($this->isNullable($type) || !$this->bStrictNullTypes) { if ($jvalue === null) { $this->setProperty($object, $accessor, null); continue; } $type = $this->removeNullable($type); } else if ($jvalue === null) { throw new JsonMapperException( 'JSON property "' . $key . '" in class "' . $strClassName . '" must not be NULL' ); } if ($type === null || $type === 'mixed') { //no given type - simply set the json data $this->setProperty($object, $accessor, $jvalue); continue; } else if ($this->isObjectOfSameType($type, $jvalue)) { $this->setProperty($object, $accessor, $jvalue); continue; } else if ($this->isSimpleType($type)) { if ($type === 'string' && is_object($jvalue)) { throw new JsonMapperException( 'JSON property "' . $key . '" in class "' . $strClassName . '" is an object and' . ' cannot be converted to a string' ); } settype($jvalue, $type); $this->setProperty($object, $accessor, $jvalue); continue; } //FIXME: check if type exists, give detailed error message if not if ($type === '') { throw new JsonMapperException( 'Empty type at property "' . $strClassName . '::$' . $key . '"' ); } $array = null; $subtype = null; if ($this->isArrayOfType($type)) { //array $array = array(); $subtype = substr($type, 0, -2); } else if (substr($type, -1) == ']') { list($proptype, $subtype) = explode('[', substr($type, 0, -1)); if (!$this->isSimpleType($proptype)) { $proptype = $this->getFullNamespace($proptype, $strNs); } if ($proptype == 'array') { $array = array(); } else { $array = $this->createInstance($proptype, false, $jvalue); } } else { $type = $this->getFullNamespace($type, $strNs); if (is_a($type, 'ArrayObject', true)) { $array = $this->createInstance($type, false, $jvalue); } } if ($array !== null) { if (!is_array($jvalue) && $this->isFlatType(gettype($jvalue))) { throw new JsonMapperException( 'JSON property "' . $key . '" must be an array, ' . gettype($jvalue) . ' given' ); } $cleanSubtype = $this->removeNullable($subtype); if (!$this->isSimpleType($cleanSubtype) && $cleanSubtype !== null ) { $subtype = $this->getFullNamespace($cleanSubtype, $strNs); } $child = $this->mapArray($jvalue, $array, $subtype, $key); } else if ($this->isFlatType(gettype($jvalue))) { //use constructor parameter if we have a class // but only a flat type (i.e. string, int) if ($this->bStrictObjectTypeChecking) { throw new JsonMapperException( 'JSON property "' . $key . '" must be an object, ' . gettype($jvalue) . ' given' ); } $type = $this->getFullNamespace($type, $strNs); $child = $this->createInstance($type, true, $jvalue); } else { $type = $this->getFullNamespace($type, $strNs); $child = $this->createInstance($type, false, $jvalue); $this->map($jvalue, $child); } $this->setProperty($object, $accessor, $child); } if ($this->bExceptionOnMissingData) { $this->checkMissingData($providedProperties, $rc); } return $object; } /** * 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 ($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 new JsonMapperException( 'Required property "' . $property->name . '" of class ' . $rc->getName() . ' is missing in JSON data' ); } } } /** * Map an array * * @param array $json JSON array structure from json_decode() * @param mixed $array Array or ArrayObject that gets filled with * data from $json * @param string $class Class name for children objects. * All children will get mapped onto this type. * Supports class names and simple types * like "string" and nullability "string|null". * Pass "null" to not convert any values * @param string $parent_key Defines the key this array belongs to * in order to aid debugging. * * @return mixed Mapped $array is returned */ public function mapArray($json, $array, $class = null, $parent_key = '') { foreach ($json as $key => $jvalue) { if ($class === null) { $array[$key] = $jvalue; } else if ($this->isArrayOfType($class)) { $array[$key] = $this->mapArray( $jvalue, array(), substr($class, 0, -2) ); } 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)) { settype($jvalue, $class); $array[$key] = $jvalue; } else { $array[$key] = $this->createInstance( $class, true, $jvalue ); } } } else if ($this->isFlatType($class)) { throw new JsonMapperException( 'JSON property "' . ($parent_key ? $parent_key : '?') . '"' . ' is an array of type "' . $class . '"' . ' but contained a value of type' . ' "' . gettype($jvalue) . '"' ); } else if (is_a($class, 'ArrayObject', true)) { $array[$key] = $this->mapArray( $jvalue, $this->createInstance($class) ); } else { $array[$key] = $this->map( $jvalue, $this->createInstance($class, false, $jvalue) ); } } 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 */ protected function inspectProperty(ReflectionClass $rc, $name) { //try setter method first $setter = 'set' . $this->getCamelCaseName($name); if ($rc->hasMethod($setter)) { $rmeth = $rc->getMethod($setter); if ($rmeth->isPublic() || $this->bIgnoreVisibility) { $rparams = $rmeth->getParameters(); if (count($rparams) > 0) { $pclass = $rparams[0]->getClass(); $nullability = ''; if ($rparams[0]->allowsNull()) { $nullability = '|null'; } if ($pclass !== null) { return array( true, $rmeth, '\\' . $pclass->getName() . $nullability ); } } $docblock = $rmeth->getDocComment(); $annotations = $this->parseAnnotations($docblock); if (!isset($annotations['param'][0])) { // If there is no annotations (higher priority) inspect // if there's a scalar type being defined if (PHP_MAJOR_VERSION >= 7) { $ptype = $rparams[0]->getType(); if ($ptype !== null) { return array(true, $rmeth, $ptype . $nullability); } } return array(true, $rmeth, null); } list($type) = explode(' ', trim($annotations['param'][0])); return array(true, $rmeth, $type); } } //now try to set the property directly //we have to look it up in the class hierarchy $class = $rc; $rprop = null; do { if ($class->hasProperty($name)) { $rprop = $class->getProperty($name); } } while ($rprop === null && $class = $class->getParentClass()); if ($rprop === null) { //case-insensitive property matching foreach ($rc->getProperties() as $p) { if ((strcasecmp($p->name, $name) === 0)) { $rprop = $p; break; } } } if ($rprop !== null) { if ($rprop->isPublic() || $this->bIgnoreVisibility) { $docblock = $rprop->getDocComment(); $annotations = $this->parseAnnotations($docblock); if (!isset($annotations['var'][0])) { return array(true, $rprop, null); } //support "@var type description" list($type) = explode(' ', $annotations['var'][0]); return array(true, $rprop, $type); } else { //no setter, private property return array(true, null, null); } } //no setter, no property return array(false, null, null); } /** * Removes - and _ and makes the next letter uppercase * * @param string $name Property name * * @return string CamelCasedVariableName */ protected function getCamelCaseName($name) { return str_replace( ' ', '', ucwords(str_replace(array('_', '-'), ' ', $name)) ); } /** * Since hyphens cannot be used in variables we have to uppercase them. * * Technically you may use them, but they are awkward to access. * * @param string $name Property name * * @return string Name without hyphen */ protected function getSafeName($name) { if (strpos($name, '-') !== false) { $name = $this->getCamelCaseName($name); } return $name; } /** * 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->isPublic() && $this->bIgnoreVisibility) { $accessor->setAccessible(true); } if ($accessor instanceof ReflectionProperty) { $accessor->setValue($object, $value); } else { //setter method $accessor->invoke($object, $value); } } /** * Create a new object of the given type. * * This method exists to be overwritten in child classes, * so you can do dependency injection or so. * * @param string $class Class name to instantiate * @param boolean $useParameter Pass $parameter to the constructor or not * @param mixed $jvalue Constructor parameter (the json value) * * @return object Freshly created object */ public function createInstance( $class, $useParameter = false, $jvalue = null ) { if (isset($this->classMap[$class])) { if (is_callable($mapper = $this->classMap[$class])) { $class = $mapper($class, $jvalue); } else { $class = $this->classMap[$class]; } } if ($useParameter) { return new $class($jvalue); } else { return (new ReflectionClass($class))->newInstanceWithoutConstructor(); } } /** * 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 * * @see isFlatType() */ protected function isSimpleType($type) { return $type == 'string' || $type == 'boolean' || $type == 'bool' || $type == 'integer' || $type == 'int' || $type == 'double' || $type == 'float' || $type == 'array' || $type == 'object'; } /** * 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 * * @see isSimpleType() */ protected function isFlatType($type) { return $type == 'NULL' || $type == 'string' || $type == 'boolean' || $type == 'bool' || $type == 'integer' || $type == 'int' || $type == 'double' || $type == 'float'; } /** * Returns true if type is an array of elements * (bracket notation) * * @param string $strType type to be matched * * @return bool */ protected function isArrayOfType($strType) { return substr($strType, -2) === '[]'; } /** * 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) { if ($type === null) { return null; } 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 static 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; } }