├── .gitignore ├── src ├── View │ ├── ViewInterface.php │ ├── View.php │ ├── BindView.php │ ├── DataView.php │ ├── KeyValueView.php │ ├── ResponseView.php │ └── IterableView.php ├── SymfonyOrchestraViewBundle.php ├── Attribute │ └── Type.php ├── Serializer │ └── Normalizer │ │ ├── ViewNormalizerFactory.php │ │ └── ViewNormalizer.php ├── Resources │ └── config │ │ └── services.yaml ├── EventSubscriber │ ├── SetVersionSubscriber.php │ └── ViewSubscriber.php ├── DependencyInjection │ └── SymfonyOrchestraViewExtension.php ├── PropertyAccessor │ ├── ReflectionService.php │ └── ReflectionPropertyAccessor.php └── Utils │ └── BindUtils.php ├── bin └── phpunit ├── phpunit.xml.dist ├── tests ├── Functional │ └── PropertyAccessor │ │ └── ReflectionPropertyAccessorTest.php └── Unit │ └── PropertyAccessor │ └── ReflectionPropertyAccessorTest.php ├── composer.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | -------------------------------------------------------------------------------- /src/View/ViewInterface.php: -------------------------------------------------------------------------------- 1 | sync($this, $object); 21 | } 22 | } -------------------------------------------------------------------------------- /src/Resources/config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | Symfony\Component\Serializer\Normalizer\CustomNormalizer: ~ 8 | 9 | SymfonyOrchestra\ViewBundle\: 10 | resource: '../../*' 11 | exclude: '../../{Exception,View,PropertyAccessor}' 12 | 13 | SymfonyOrchestra\ViewBundle\Serializer\Normalizer\ViewNormalizer: 14 | class: SymfonyOrchestra\ViewBundle\Serializer\Normalizer\ViewNormalizer 15 | factory: [ '@SymfonyOrchestra\ViewBundle\Serializer\Normalizer\ViewNormalizerFactory', 'create'] -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | normalize(['data' => $this->data], $format, $context); 27 | } 28 | } -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | tests 18 | 19 | 20 | 21 | 22 | 23 | src 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/View/KeyValueView.php: -------------------------------------------------------------------------------- 1 | key => $this->view]; 29 | } 30 | } -------------------------------------------------------------------------------- /src/View/ResponseView.php: -------------------------------------------------------------------------------- 1 | 'application/json']; 28 | } 29 | 30 | public function normalize(NormalizerInterface $normalizer, string $format = null, array $context = []): array|string|int|float|bool 31 | { 32 | return $normalizer->normalize([], $format, $context); 33 | } 34 | } -------------------------------------------------------------------------------- /src/EventSubscriber/SetVersionSubscriber.php: -------------------------------------------------------------------------------- 1 | buildId, $this->debug ? 0 : 24 * 3600, 'view_bind'); 33 | } 34 | } -------------------------------------------------------------------------------- /tests/Functional/PropertyAccessor/ReflectionPropertyAccessorTest.php: -------------------------------------------------------------------------------- 1 | createMock(PropertyAccessorInterface::class); 27 | $accessor->expects(self::once())->method('setValue')->with($object, $path, $value); 28 | 29 | $service = new ReflectionPropertyAccessor($accessor, new ReflectionService()); 30 | $service->setValue($object, $path, $value); 31 | } 32 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony-orchestra/view-bundle", 3 | "type": "library", 4 | "description": "The view symfony bundle for JSON API responses", 5 | "keywords": [ 6 | "symfony", 7 | "view-bundle", 8 | "view", 9 | "api", 10 | "json", 11 | "typescript" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Andrew Lukin", 17 | "email": "lukin.andrej@gmail.com", 18 | "homepage": "https://github.com/wtorsi", 19 | "role": "Developer" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.4", 24 | "symfony/http-kernel": "7.3.*", 25 | "symfony/serializer": "7.3.*", 26 | "symfony/property-access": "7.3.*", 27 | "symfony/dependency-injection": "7.3.*", 28 | "symfony/config": "7.3.*", 29 | "doctrine/common": "^3.5" 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "^9.5" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "SymfonyOrchestra\\ViewBundle\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Tests\\": "tests/" 42 | } 43 | }, 44 | "minimum-stability": "stable", 45 | "conflict": { 46 | "symfony/symfony": "*" 47 | }, 48 | "extra": { 49 | "symfony": { 50 | "allow-contrib": false, 51 | "require": "7.3.*" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/DependencyInjection/SymfonyOrchestraViewExtension.php: -------------------------------------------------------------------------------- 1 | load('services.yaml'); 26 | $this->registerViewCache($container); 27 | } 28 | 29 | private function registerViewCache(ContainerBuilder $container): void 30 | { 31 | $container->getDefinition(SetVersionSubscriber::class)->setArgument('$buildId', new Parameter('container.build_id')); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/EventSubscriber/ViewSubscriber.php: -------------------------------------------------------------------------------- 1 | getControllerResult()) instanceof ViewInterface) { 34 | return; 35 | } 36 | 37 | $json = $this->serializer->serialize( 38 | $view = $view instanceof ResponseView ? $view : new DataView($view), 39 | 'json', 40 | ['json_encode_options' => JsonResponse::DEFAULT_ENCODING_OPTIONS] 41 | ); 42 | 43 | $event->setResponse(new JsonResponse($json, $view->getStatus(), $view->getHeaders(), true)); 44 | } 45 | } -------------------------------------------------------------------------------- /src/Serializer/Normalizer/ViewNormalizer.php: -------------------------------------------------------------------------------- 1 | $v) { 27 | if (null === $v) { 28 | continue; 29 | } 30 | $data[$k] = $this->normalizer->normalize($v, $format, $context); 31 | } 32 | return $data; 33 | } 34 | 35 | public function getSupportedTypes(string|null $format): array 36 | { 37 | return [ViewInterface::class => true]; 38 | } 39 | 40 | public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool 41 | { 42 | return $data instanceof ViewInterface; 43 | } 44 | } -------------------------------------------------------------------------------- /src/View/IterableView.php: -------------------------------------------------------------------------------- 1 | new $map($v); 27 | } 28 | $this->entries = \array_values(\array_map($map ?? [static::class, 'map'], \is_array($entries) ? $entries : \iterator_to_array($entries))); 29 | } 30 | 31 | protected static function map(array|object $value): ViewInterface 32 | { 33 | throw new \RuntimeException(\sprintf('%s should be defined or mapping closure should be passed for value %s', __METHOD__, \get_class($value))); 34 | } 35 | 36 | public function normalize(NormalizerInterface $normalizer, string $format = null, array $context = []): array|string|int|float|bool 37 | { 38 | return $normalizer->normalize($this->entries); 39 | } 40 | } -------------------------------------------------------------------------------- /src/PropertyAccessor/ReflectionService.php: -------------------------------------------------------------------------------- 1 | getName() : $class; 26 | if (isset(static::$storage[$className])) { 27 | return static::$storage[$className]; 28 | } 29 | 30 | $cache = []; 31 | $class = $class instanceof \ReflectionClass ? $class : new \ReflectionClass($class); 32 | foreach ($class->getProperties() as $p) { 33 | $cache[$p->getName()] = $p; 34 | } 35 | 36 | if (($parent = $class->getParentClass()) instanceof \ReflectionClass) { 37 | $cache = \array_merge($cache, $this->getReflectionProperties($parent)); 38 | } 39 | 40 | return static::$storage[$className] = $cache; 41 | } 42 | 43 | /** 44 | * @throws \ReflectionException 45 | */ 46 | public function getReflectionProperty(string|object $class, string $propertyPath): \ReflectionProperty|null 47 | { 48 | return $this->getReflectionProperties(\is_object($class) ? ClassUtils::getClass($class) : $class)[$propertyPath] ?? null; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/PropertyAccessor/ReflectionPropertyAccessor.php: -------------------------------------------------------------------------------- 1 | __isInitialized()) { 30 | $objectOrArray->__load(); 31 | } 32 | 33 | // only public properties of view are supported 34 | $this->decorated->setValue($objectOrArray, $propertyPath, $value); 35 | } 36 | 37 | /** 38 | * @throws \ReflectionException 39 | */ 40 | public function getValue(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): mixed 41 | { 42 | if ($objectOrArray instanceof Proxy && !$objectOrArray->__isInitialized()) { 43 | $objectOrArray->__load(); 44 | } 45 | 46 | try { 47 | return $this->decorated->getValue($objectOrArray, $propertyPath); 48 | } catch (\Throwable $e) { 49 | if (!$this->isIntercepted($e, $objectOrArray, $propertyPath)) { 50 | throw $e; 51 | } 52 | if (null === $property = $this->getReflectionProperty($objectOrArray, $propertyPath)) { 53 | throw $e; 54 | } 55 | return $property->getValue($objectOrArray); 56 | } 57 | } 58 | 59 | /** 60 | * @throws \ReflectionException 61 | */ 62 | public function isWritable(object|iterable $objectOrArray, string|PropertyPathInterface $propertyPath): bool 63 | { 64 | return $this->decorated->isWritable($objectOrArray, $propertyPath) || $this->propertyExists($objectOrArray, $propertyPath); 65 | } 66 | 67 | /** 68 | * @throws \ReflectionException 69 | */ 70 | public function isReadable(object|iterable $objectOrArray, string|PropertyPathInterface $propertyPath): bool 71 | { 72 | return $this->decorated->isReadable($objectOrArray, $propertyPath) || $this->propertyExists($objectOrArray, $propertyPath); 73 | } 74 | 75 | /** 76 | * Is the property accessible as public of getter method 77 | */ 78 | public function isStrictlyReadable(object|iterable $objectOrArray, string|PropertyPathInterface $propertyPath): bool 79 | { 80 | return $this->decorated->isReadable($objectOrArray, $propertyPath); 81 | } 82 | 83 | /** 84 | * @throws \ReflectionException 85 | */ 86 | private function propertyExists(object|iterable $objectOrArray, string|PropertyPathInterface $propertyPath): bool 87 | { 88 | return null !== $this->getReflectionProperty($objectOrArray, (string)$propertyPath); 89 | } 90 | 91 | /** 92 | * @throws \ReflectionException 93 | */ 94 | private function getReflectionProperty(object|iterable $objectOrArray, string $propertyPath): ?\ReflectionProperty 95 | { 96 | if (false === \is_object($objectOrArray)) { 97 | return null; 98 | } 99 | 100 | return $this->reflectionService->getReflectionProperty($objectOrArray, $propertyPath); 101 | } 102 | 103 | private function isIntercepted(\Throwable $e, object|array $objectOrArray, string|PropertyPathInterface $propertyPath): bool 104 | { 105 | if ($e instanceof NoSuchPropertyException) { 106 | return true; 107 | } 108 | $supported = [ 109 | '/^Cannot access (private|protected) property '.\preg_quote(\get_debug_type($objectOrArray), '/').'::\$'.$propertyPath.'$/', 110 | '/^Can\'t get a way to read the property "'.$propertyPath.'" in class '.\preg_quote(\get_debug_type($objectOrArray), '/').'$/', 111 | ]; 112 | 113 | return \array_any($supported, fn($pattern) => \preg_match($pattern, $e->getMessage())); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # view-bundle 2 | 3 | The `view-bundle` is a simple and highly efficient Symfony bundle designed to replace Symfony Responses when working with the JSON API. 4 | 5 | The goal of the bundle is to separate the response views from the app's business logic, making them typed, configurable, and reusable across the app. 6 | 7 | As a result, you will have a set of simple `View` classes with an internal hierarchy that is easily understandable by everybody in a team. 8 | 9 | # Requirements 10 | 11 | - PHP 8.4 12 | - Symfony 7.2.* 13 | - Doctrine common ^3.5 14 | 15 | ```json 16 | { 17 | "php": "^8.3", 18 | "symfony/http-kernel": "7.2.*", 19 | "symfony/serializer": "7.2.*", 20 | "symfony/property-access": "7.2.*", 21 | "symfony/dependency-injection": "7.2.*", 22 | "symfony/config": "7.2.*", 23 | "doctrine/common": "^3.5" 24 | } 25 | ``` 26 | 27 | # Example 28 | 29 | Let's consider the code below as an example. 30 | We have an entity `User` with some fields and with the joined collection of `Image` images. 31 | 32 | 33 | ```php 34 | notBoundField = $user->getCreatedDatetime(); 84 | } 85 | } 86 | 87 | ``` 88 | 89 | ```php 90 | getUser()); 114 | } 115 | } 116 | ``` 117 | 118 | Will produce the following 200 response 119 | 120 | ```json 121 | { 122 | "data": { 123 | "id": "92c7c4d4-2ce0-4353-a9e2-6a3794c60d8f", 124 | "firstName": "Andrew", 125 | "images": [ 126 | { 127 | "id": "eb9fa57e-3d8f-44c5-80d4-7f33220f1a48", 128 | "path": "/grand-piano.png" 129 | }, 130 | { 131 | "id": "16d01967-9066-4dc9-9d82-028419ba0ed5", 132 | "path": "/violin.png" 133 | } 134 | ], 135 | "notBoundField": "1685-03-31" 136 | } 137 | } 138 | 139 | ``` 140 | 141 | The response is fully controllable, you can still add different headers to the response using the stack of provided internal View classes (`ResponseView`). 142 | 143 | The main payload is placed under the `data` key in the JSON array. 144 | 145 | As you can see, the last name is omitted because `null` values were removed from the response to match with `undefined` properties while working with a `Typescript`. 146 | 147 | # Installation 148 | 149 | ``` 150 | composer install symfony-orchestra/view-bundle:7.2.* 151 | ``` 152 | 153 | Add the bundle to `config/bundles.php` 154 | ```php 155 | ['all' => true], 160 | ]; 161 | 162 | 163 | ``` 164 | 165 | To make it work your controller should return an object of instance of `SymfonyOrchestra\ViewBundle\View\ViewInterface` instead of `Symfony\Component\HttpFoundation\Response`. 166 | 167 | # Cache 168 | 169 | The most usable `SymfonyOrchestra\ViewBundle\View\BindView` which maps the properties of the class with the properties of the view comes with the cache support. 170 | See `SymfonyOrchestra\ViewBundle\EventSubscriber\SetVersionSubscriber` for more details. 171 | It uses `Symfony\Component\PropertyAccess\PropertyAccessor::createCache` when the env parameter `APP_DEBUG` is set to `false`. 172 | 173 | 174 | # Internal views 175 | 176 | The bundle comes with the several internal core views. 177 | 178 | ### \SymfonyOrchestra\ViewBundle\View\ResponseView 179 | 180 | The main view that can be considered as a response. Contains headers and http status that can be overridden. 181 | See `SymfonyOrchestra\EventSubscriber\ViewSubscriber`. 182 | 183 | ### \SymfonyOrchestra\ViewBundle\View\DataView 184 | 185 | The inherited view of the `ResponseView`, that wraps all the data into `data` JSON key. 186 | See `SymfonyOrchestra\EventSubscriber\ViewSubscriber`. 187 | 188 | ### \SymfonyOrchestra\ViewBundle\View\BindView 189 | 190 | The helper View that maps the properties of the underlined object to the view as one to one. The most powerful one. 191 | It uses `SymfonyOrchestra\ViewBundle\Utils\BindUtils` internally to map the properties. 192 | 193 | ```php 194 | class User { 195 | private int $int; 196 | private string $string; 197 | private iterable $collection 198 | } 199 | 200 | class UserView extends \SymfonyOrchestra\ViewBundle\View\BindView { 201 | /** will take all the properties from the User class */ 202 | private int $int; 203 | private string $string; 204 | private array $collection 205 | } 206 | ``` 207 | 208 | ### \SymfonyOrchestra\ViewBundle\View\IterableView 209 | 210 | The view for the iterable objects. 211 | 212 | ```php 213 | 214 | class GetOptions extends GetAction 215 | { 216 | public function __invoke(Request $request): ViewInterface 217 | { 218 | $option1 = new Option(); 219 | $option2 = new Option(); 220 | return new \SymfonyOrchestra\ViewBundle\View\IterableView( 221 | [$option1, $option2], 222 | OptionView::class, 223 | ); 224 | } 225 | } 226 | 227 | ``` 228 | 229 | 230 | It can be used together with the `\SymfonyOrchestra\ViewBundle\Attribute\Type` and `\SymfonyOrchestra\ViewBundle\View\BindView` 231 | attribute to simplify the workflow. In this case the underlined iterable objects will be automatically constructed based on the configured 232 | type. 233 | 234 | **Enjoy the orchestra! 🎻** -------------------------------------------------------------------------------- /src/Utils/BindUtils.php: -------------------------------------------------------------------------------- 1 | reflectionService = new ReflectionService(); 36 | } 37 | 38 | public static function configure(string $buildId, int $cacheLifetime = 3600, string $namespace = 'bind_view'): void 39 | { 40 | if (static::$configured) { 41 | return; 42 | } 43 | 44 | static::$configured = true; 45 | static::$version = $buildId; 46 | static::$cacheLifetime = $cacheLifetime; 47 | static::$cacheNamespace = $namespace; 48 | } 49 | 50 | public static function instance(): self 51 | { 52 | static $instance; 53 | return $instance ??= new static(); 54 | } 55 | 56 | /** 57 | * @throws \ReflectionException 58 | */ 59 | public function sync(object $target, object $source): void 60 | { 61 | foreach ($this->getIntersectedProperties($target, $source) as [$targetProperty, $sourceProperty]) { 62 | /** @var \ReflectionProperty $targetProperty */ 63 | /** @var \ReflectionProperty $sourceProperty */ 64 | if ($this->getAccessor()->isStrictlyReadable($target, $targetProperty->getName())) { 65 | if (null !== $this->getAccessor()->getValue($target, $targetProperty->getName())) { 66 | continue; 67 | } 68 | } 69 | $this->getAccessor()->setValue($target, $targetProperty->getName(), $this->getValue($targetProperty, $sourceProperty, $source)); 70 | } 71 | } 72 | 73 | private function getValue(\ReflectionProperty $targetProperty, \ReflectionProperty $sourceProperty, object $source): mixed 74 | { 75 | if (null === $value = $this->getAccessor()->getValue($source, $sourceProperty->getName())) { 76 | return null; 77 | } 78 | 79 | if ($this->isView($type = $targetProperty->getType())) { 80 | /** @var \ReflectionNamedType $type */ 81 | if ($this->isTypedIterableView($targetProperty)) { 82 | return $this->buildIterableView($targetProperty, $value); 83 | } 84 | 85 | return new ($type->getName())($value); 86 | } 87 | 88 | return $value; 89 | } 90 | 91 | private function isTypedIterableView(\ReflectionProperty $property): bool 92 | { 93 | return \is_a($property->getType()->getName(), IterableView::class, true) && \count($property->getAttributes(Type::class)) > 0; 94 | } 95 | 96 | private function buildIterableView(\ReflectionProperty $property, iterable $value): IterableView 97 | { 98 | /** @var \ReflectionAttribute $attr */ 99 | $attr = \current($property->getAttributes(Type::class)); 100 | /** @var Type $type */ 101 | $type = $attr->newInstance(); 102 | 103 | return new IterableView($value, fn(object|array $v) => new ($type->class)($v)); 104 | } 105 | 106 | /** 107 | * @throws \ReflectionException 108 | */ 109 | private function getIntersectedProperties(object $target, object $source): array 110 | { 111 | $key = \implode('@', [$targetClassName = ClassUtils::getClass($target), $sourceClassName = ClassUtils::getClass($source)]); 112 | if (isset(self::$storage[$key])) { 113 | return self::$storage[$key]; 114 | } 115 | 116 | $targetProperties = $this->reflectionService->getReflectionProperties($targetClassName); 117 | $sourceProperties = $this->reflectionService->getReflectionProperties($sourceClassName); 118 | 119 | $intersection = []; 120 | foreach (\array_intersect(\array_keys($targetProperties), \array_keys($sourceProperties)) as $key) { 121 | /** @var \ReflectionProperty $targetProperty */ 122 | $targetProperty = $targetProperties[$key]; 123 | /** @var \ReflectionProperty $sourceProperty */ 124 | $sourceProperty = $sourceProperties[$key]; 125 | 126 | if (!$this->isReflectionTypeValidForInitialization($targetProperty->getType(), $sourceProperty->getType())) { 127 | continue; 128 | } 129 | 130 | $intersection[$key] = [$targetProperty, $sourceProperty]; 131 | } 132 | 133 | return self::$storage[$key] = $intersection; 134 | } 135 | 136 | private function isReflectionTypeValidForInitialization(\ReflectionType $targetType, \ReflectionType $sourceType): bool 137 | { 138 | if (!$targetType instanceof \ReflectionNamedType) { 139 | // union types are not supported for binding 140 | return false; 141 | } 142 | 143 | $sourceTypes = $sourceType instanceof \ReflectionUnionType ? $sourceType->getTypes() : [$sourceType]; 144 | 145 | if ($targetType->isBuiltin()) { 146 | //pass all built in values, in case if one of the source values is built in either 147 | foreach ($sourceTypes as $sourceType) { 148 | if ($sourceType->isBuiltin()) { 149 | return true; 150 | } 151 | } 152 | 153 | return false; 154 | } 155 | 156 | if ($this->isAutoConfigurableType($targetType)) { 157 | return true; 158 | } 159 | 160 | foreach ($sourceTypes as $sourceType) { 161 | // all custom objects are valid only if the types are valid 162 | if (\is_a($targetType->getName(), $sourceType->getName(), true)) { 163 | return true; 164 | } 165 | } 166 | 167 | return false; 168 | } 169 | 170 | private function isView(\ReflectionType $type): bool 171 | { 172 | return $type instanceof \ReflectionNamedType && \is_a($type->getName(), ViewInterface::class, true); 173 | } 174 | 175 | private function isAutoConfigurableType(\ReflectionNamedType $type): bool 176 | { 177 | foreach ([BindView::class, IterableView::class] as $class) { 178 | if (\is_a($type->getName(), $class, true)) { 179 | return true; 180 | } 181 | } 182 | 183 | return false; 184 | } 185 | 186 | private function getAccessor(): ReflectionPropertyAccessor 187 | { 188 | static $accessor; 189 | return $accessor ??= new ReflectionPropertyAccessor(new PropertyAccessor( 190 | ReflectionExtractor::DISALLOW_MAGIC_METHODS, 191 | PropertyAccessor::THROW_ON_INVALID_INDEX | PropertyAccessor::THROW_ON_INVALID_PROPERTY_PATH, 192 | static::$configured ? PropertyAccessor::createCache(static::$cacheNamespace, static::$cacheLifetime, static::$version) : null, 193 | new ReflectionExtractor([], $a = ['get', 'is', 'has'], $b = ['-', '-'], false, ReflectionExtractor::ALLOW_PRIVATE | ReflectionExtractor::ALLOW_PROTECTED | ReflectionExtractor::ALLOW_PUBLIC, null, ReflectionExtractor::DISALLOW_MAGIC_METHODS), 194 | new ReflectionExtractor(['set'], $a, $b, false, ReflectionExtractor::ALLOW_PUBLIC, null, ReflectionExtractor::DISALLOW_MAGIC_METHODS) 195 | ), $this->reflectionService); 196 | } 197 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/Unit/PropertyAccessor/ReflectionPropertyAccessorTest.php: -------------------------------------------------------------------------------- 1 | createMock(Proxy::class); 29 | $proxy->expects(self::once())->method('__isInitialized')->willReturn($initialized); 30 | $proxy->expects($initialized ? self::never() : self::once())->method('__load'); 31 | 32 | return $proxy; 33 | }; 34 | if (\is_string($objectOrArray)) { 35 | $objectOrArray = $proxy((bool)\str_replace('__proxy__', '', $objectOrArray)); 36 | } 37 | 38 | $accessor = $this->createMock(PropertyAccessorInterface::class); 39 | $accessor->expects(self::once())->method('setValue')->with($objectOrArray, $path, $value); 40 | 41 | $service = new ReflectionPropertyAccessor($accessor, new ReflectionService()); 42 | $service->setValue($objectOrArray, $path, $value); 43 | } 44 | 45 | public static function getTestSetValueData(): array 46 | { 47 | return [ 48 | 'object' => [ 49 | new \stdClass(), 50 | 'path', 51 | 'value', 52 | ], 53 | 'array' => [ 54 | [], 55 | 'path', 56 | 'value', 57 | ], 58 | 'proxy_initialized' => [ 59 | '__proxy__1', 60 | 'path', 61 | 'value', 62 | ], 63 | 'proxy_uninitialized' => [ 64 | '__proxy__0', 65 | 'path', 66 | 'value', 67 | ], 68 | ]; 69 | } 70 | 71 | /** 72 | * @dataProvider getTestGetValueData 73 | */ 74 | public function testGetValue(object|array|string $objectOrArray, string $path): void 75 | { 76 | $proxy = function (bool $initialized): Proxy { 77 | $proxy = $this->createMock(Proxy::class); 78 | $proxy->expects(self::once())->method('__isInitialized')->willReturn($initialized); 79 | $proxy->expects($initialized ? self::never() : self::once())->method('__load'); 80 | 81 | return $proxy; 82 | }; 83 | if (\is_string($objectOrArray)) { 84 | $objectOrArray = $proxy((bool)\str_replace('__proxy__', '', $objectOrArray)); 85 | } 86 | 87 | $accessor = $this->createMock(PropertyAccessorInterface::class); 88 | $accessor->expects(self::once())->method('getValue')->with($objectOrArray, $path); 89 | 90 | $service = new ReflectionPropertyAccessor($accessor, new ReflectionService()); 91 | $service->getValue($objectOrArray, $path); 92 | } 93 | 94 | public static function getTestGetValueData(): array 95 | { 96 | return [ 97 | 'object' => [ 98 | new \stdClass(), 99 | 'path', 100 | ], 101 | 'array' => [ 102 | [], 103 | 'path', 104 | ], 105 | 'proxy_initialized' => [ 106 | '__proxy__1', 107 | 'path', 108 | ], 109 | 'proxy_uninitialized' => [ 110 | '__proxy__0', 111 | 'path', 112 | ], 113 | ]; 114 | } 115 | 116 | /** 117 | * @dataProvider getTestGetValueDecoratedData 118 | */ 119 | public function testGetValueDecorated(object $class, string $path, \Throwable $exception): void 120 | { 121 | $accessor = $this->createMock(PropertyAccessorInterface::class); 122 | $accessor->expects(self::once())->method('getValue')->willThrowException($exception); 123 | 124 | $property = $this->createMock(\ReflectionProperty::class); 125 | $property->expects(self::once())->method('getValue')->with($class)->willReturn($result = 'result'); 126 | 127 | $reflectionService = $this->createMock(ReflectionService::class); 128 | $reflectionService->expects(self::once())->method('getReflectionProperty')->willReturn($property); 129 | 130 | self::assertEquals($result, (new ReflectionPropertyAccessor($accessor, $reflectionService))->getValue($class, $path)); 131 | } 132 | 133 | public static function getTestGetValueDecoratedData(): array 134 | { 135 | return [ 136 | [ 137 | new \stdClass(), 138 | 'path', 139 | new NoSuchPropertyException('Cannot access private property stdClass::$path'), 140 | ], 141 | [ 142 | new \stdClass(), 143 | 'path2', 144 | new NoSuchPropertyException('Cannot access protected property stdClass::$path2'), 145 | ], 146 | ]; 147 | } 148 | 149 | /** 150 | * @dataProvider getTestGetValueExceptionData 151 | */ 152 | public function testGetValueException(\Exception $exception, bool $throw): void 153 | { 154 | $class = new \stdClass(); 155 | $path = 'path'; 156 | 157 | $accessor = $this->createMock(PropertyAccessorInterface::class); 158 | $accessor->expects(self::once())->method('getValue')->willThrowException($exception); 159 | 160 | $property = $this->createMock(\ReflectionProperty::class); 161 | $property->expects(self::exactly((int)!$throw))->method('getValue'); 162 | 163 | $reflectionService = $this->createMock(ReflectionService::class); 164 | $reflectionService->expects(self::exactly((int)!$throw))->method('getReflectionProperty')->willReturn($property); 165 | 166 | if ($throw) { 167 | $this->expectExceptionObject($exception); 168 | } 169 | (new ReflectionPropertyAccessor($accessor, $reflectionService))->getValue($class, $path); 170 | } 171 | 172 | public static function getTestGetValueExceptionData(): array 173 | { 174 | return [ 175 | [new NoSuchPropertyException('Incorrect message'), false], 176 | [new \Exception('Cannot access protected property stdClass::$path'), false], 177 | [new \Exception('Cannot access private property stdClass::$path'), false], 178 | [new \Exception('Can\'t get a way to read the property "path" in class stdClass'), false], 179 | [new \Exception('Incorrect exception'), true], 180 | ]; 181 | } 182 | 183 | public function testGetValueExceptionInService(): void 184 | { 185 | $class = new \stdClass(); 186 | $path = 'path'; 187 | 188 | $accessor = $this->createMock(PropertyAccessorInterface::class); 189 | $accessor->expects(self::once())->method('getValue')->willThrowException($e = new NoSuchPropertyException('Cannot access private property stdClass::$path')); 190 | 191 | $property = $this->createMock(\ReflectionProperty::class); 192 | $property->expects(self::never())->method('getValue'); 193 | 194 | $reflectionService = $this->createMock(ReflectionService::class); 195 | $reflectionService->expects(self::once())->method('getReflectionProperty')->with($class, $path)->willReturn(null); 196 | 197 | $this->expectExceptionObject($e); 198 | (new ReflectionPropertyAccessor($accessor, $reflectionService))->getValue($class, $path); 199 | } 200 | 201 | /** 202 | * @dataProvider getTestIsWritableData 203 | */ 204 | public function testIsWritable(bool $isWritable, bool $hasProperty, bool $expected): void 205 | { 206 | $class = new \stdClass(); 207 | $path = 'path'; 208 | 209 | $accessor = $this->createMock(PropertyAccessorInterface::class); 210 | $accessor->expects(self::once())->method('isWritable')->with($class, $path)->willReturn($isWritable); 211 | 212 | $property = $this->createMock(\ReflectionProperty::class); 213 | 214 | $reflectionService = $this->createMock(ReflectionService::class); 215 | $reflectionService 216 | ->expects($isWritable ? self::never() : self::once()) 217 | ->method('getReflectionProperty') 218 | ->willReturn($hasProperty ? $property : null); 219 | 220 | $actual = (new ReflectionPropertyAccessor($accessor, $reflectionService))->isWritable($class, $path); 221 | self::assertEquals($expected, $actual); 222 | } 223 | 224 | public static function getTestIsWritableData(): array 225 | { 226 | return [ 227 | [ 228 | true, 229 | false, 230 | true, 231 | ], 232 | [ 233 | false, 234 | false, 235 | false, 236 | ], 237 | [ 238 | false, 239 | true, 240 | true, 241 | ], 242 | ]; 243 | } 244 | 245 | /** 246 | * @dataProvider getTestIsReadableData 247 | */ 248 | public function testIsReadable(bool $isReadable, bool $hasProperty, bool $expected): void 249 | { 250 | $class = new \stdClass(); 251 | $path = 'path'; 252 | 253 | $accessor = $this->createMock(PropertyAccessorInterface::class); 254 | $accessor->expects(self::once())->method('isReadable')->with($class, $path)->willReturn($isReadable); 255 | 256 | $property = $this->createMock(\ReflectionProperty::class); 257 | 258 | $reflectionService = $this->createMock(ReflectionService::class); 259 | $reflectionService 260 | ->expects($isReadable ? self::never() : self::once()) 261 | ->method('getReflectionProperty') 262 | ->willReturn($hasProperty ? $property : null); 263 | 264 | $actual = (new ReflectionPropertyAccessor($accessor, $reflectionService))->isReadable($class, $path); 265 | self::assertEquals($expected, $actual); 266 | } 267 | 268 | public static function getTestIsReadableData(): array 269 | { 270 | return [ 271 | [ 272 | true, 273 | false, 274 | true, 275 | ], 276 | [ 277 | false, 278 | false, 279 | false, 280 | ], 281 | [ 282 | false, 283 | true, 284 | true, 285 | ], 286 | ]; 287 | } 288 | 289 | /** 290 | * @dataProvider getTestIsStrictlyReadableData 291 | */ 292 | public function testIsStrictlyReadable(bool $isReadable, bool $expected): void 293 | { 294 | $class = new \stdClass(); 295 | $path = 'path'; 296 | 297 | $accessor = $this->createMock(PropertyAccessorInterface::class); 298 | $accessor->expects(self::once())->method('isReadable')->with($class, $path)->willReturn($isReadable); 299 | 300 | $reflectionService = $this->createMock(ReflectionService::class); 301 | $reflectionService->expects(self::never())->method('getReflectionProperty'); 302 | 303 | $actual = (new ReflectionPropertyAccessor($accessor, $reflectionService))->isStrictlyReadable($class, $path); 304 | self::assertEquals($expected, $actual); 305 | } 306 | 307 | public static function getTestIsStrictlyReadableData(): array 308 | { 309 | return [ 310 | [ 311 | true, 312 | true, 313 | ], 314 | [ 315 | false, 316 | false, 317 | ], 318 | ]; 319 | } 320 | } --------------------------------------------------------------------------------