diff --git a/composer.json b/composer.json index 8443e53f1..e413904cf 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "php-stubs/wordpress-stubs": "^5.0@stable", "php-stubs/woocommerce-stubs": "^8.0@stable", "vimeo/psalm": "^4.0", - "vlucas/phpdotenv": "^5" + "vlucas/phpdotenv": "^5", + "coenjacobs/mozart": "^0.7.1" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 180c71364..908b9e94c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2fa610ed883c0868838d3008b7127cbf", + "content-hash": "6ca9c2c7864d2649617db6d3850382c5", "packages": [ { "name": "container-interop/service-provider", @@ -904,6 +904,64 @@ }, "time": "2021-11-11T15:53:55+00:00" }, + { + "name": "coenjacobs/mozart", + "version": "0.7.1", + "source": { + "type": "git", + "url": "https://github.com/coenjacobs/mozart.git", + "reference": "dbcdeb992d20d9c8914eef090f9a0d684bb1102c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coenjacobs/mozart/zipball/dbcdeb992d20d9c8914eef090f9a0d684bb1102c", + "reference": "dbcdeb992d20d9c8914eef090f9a0d684bb1102c", + "shasum": "" + }, + "require": { + "league/flysystem": "^1.0", + "php": "^7.3|^8.0", + "symfony/console": "^4|^5", + "symfony/finder": "^4|^5" + }, + "require-dev": { + "mheap/phpunit-github-actions-printer": "^1.4", + "phpunit/phpunit": "^8.5", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.4" + }, + "bin": [ + "bin/mozart" + ], + "type": "library", + "autoload": { + "psr-4": { + "CoenJacobs\\Mozart\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Coen Jacobs", + "email": "coenjacobs@gmail.com" + } + ], + "description": "Composes all dependencies as a package inside a WordPress plugin", + "support": { + "issues": "https://github.com/coenjacobs/mozart/issues", + "source": "https://github.com/coenjacobs/mozart/tree/0.7.1" + }, + "funding": [ + { + "url": "https://github.com/coenjacobs", + "type": "github" + } + ], + "time": "2021-02-02T21:37:03+00:00" + }, { "name": "composer/package-versions-deprecated", "version": "1.11.99.5", @@ -1828,31 +1886,34 @@ }, { "name": "inpsyde/modularity", - "version": "1.10.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/inpsyde/modularity.git", - "reference": "2119d0e32706741a3c6dc0a85d908ec19ebf142e" + "reference": "e1ca1c81b7b663355906b586525d21ac5d46bc65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inpsyde/modularity/zipball/2119d0e32706741a3c6dc0a85d908ec19ebf142e", - "reference": "2119d0e32706741a3c6dc0a85d908ec19ebf142e", + "url": "https://api.github.com/repos/inpsyde/modularity/zipball/e1ca1c81b7b663355906b586525d21ac5d46bc65", + "reference": "e1ca1c81b7b663355906b586525d21ac5d46bc65", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=7.4 <8.4", + "php": ">=7.4", "psr/container": "^1.1.0 || ^2" }, "require-dev": { "brain/monkey": "^2.6.1", - "inpsyde/php-coding-standards": "^2@dev", - "inpsyde/wp-stubs-versions": "dev-latest", + "inpsyde/wp-stubs-versions": "6.7", "mikey179/vfsstream": "^v1.6.11", + "phpstan/phpstan": "^2.1.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpstan/phpstan-mockery": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0.4", "phpunit/phpunit": "^9.6.19", - "roots/wordpress-no-content": "@dev", - "vimeo/psalm": "^5.24.0" + "swissspidy/phpstan-no-private": "^v1.0.0", + "syde/phpcs": "^1.0.0" }, "type": "library", "extra": { @@ -1871,18 +1932,168 @@ ], "authors": [ { - "name": "Inpsyde GmbH", - "email": "hello@inpsyde.com", - "homepage": "https://inpsyde.com/", + "name": "Syde GmbH", + "email": "hello@syde.com", + "homepage": "https://syde.com/", "role": "Company" } ], "description": "Modular PSR-11 implementation for WordPress plugins, themes or libraries.", "support": { "issues": "https://github.com/inpsyde/modularity/issues", - "source": "https://github.com/inpsyde/modularity/tree/1.10.0" + "source": "https://github.com/inpsyde/modularity/tree/1.12.0" }, - "time": "2024-09-03T10:42:50+00:00" + "time": "2025-05-09T12:13:17+00:00" + }, + { + "name": "league/flysystem", + "version": "1.1.10", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "3239285c825c152bcc315fe0e87d6b55f5972ed1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/3239285c825c152bcc315fe0e87d6b55f5972ed1", + "reference": "3239285c825c152bcc315fe0e87d6b55f5972ed1", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/mime-type-detection": "^1.3", + "php": "^7.2.5 || ^8.0" + }, + "conflict": { + "league/flysystem-sftp": "<1.0.6" + }, + "require-dev": { + "phpspec/prophecy": "^1.11.1", + "phpunit/phpunit": "^8.5.8" + }, + "suggest": { + "ext-ftp": "Allows you to use FTP server storage", + "ext-openssl": "Allows you to use FTPS server storage", + "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", + "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", + "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", + "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", + "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", + "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", + "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", + "league/flysystem-webdav": "Allows you to use WebDAV storage", + "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter", + "spatie/flysystem-dropbox": "Allows you to use Dropbox storage", + "srmklive/flysystem-dropbox-v2": "Allows you to use Dropbox storage for PHP 5 applications" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Filesystem abstraction: Many filesystems, one API.", + "keywords": [ + "Cloud Files", + "WebDAV", + "abstraction", + "aws", + "cloud", + "copy.com", + "dropbox", + "file systems", + "files", + "filesystem", + "filesystems", + "ftp", + "rackspace", + "remote", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/1.1.10" + }, + "funding": [ + { + "url": "https://offset.earth/frankdejonge", + "type": "other" + } + ], + "time": "2022-10-04T09:16:37+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" }, { "name": "mockery/mockery", @@ -4528,6 +4739,69 @@ ], "time": "2023-01-24T14:02:46+00:00" }, + { + "name": "symfony/finder", + "version": "v5.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "63741784cd7b9967975eec610b256eed3ede022b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", + "reference": "63741784cd7b9967975eec610b256eed3ede022b", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v5.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-28T13:32:08+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.30.0", @@ -5541,8 +5815,8 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "php-stubs/woocommerce-stubs": 0, - "php-stubs/wordpress-stubs": 0 + "php-stubs/wordpress-stubs": 0, + "php-stubs/woocommerce-stubs": 0 }, "prefer-stable": true, "prefer-lowest": false, @@ -5550,7 +5824,7 @@ "php": "^7.4 | ^8.0", "ext-json": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "7.4" }, diff --git a/lib/packages/Dhii/Container/AliasingContainer.php b/lib/packages/Dhii/Container/AliasingContainer.php index 606113b83..a1d2e046d 100644 --- a/lib/packages/Dhii/Container/AliasingContainer.php +++ b/lib/packages/Dhii/Container/AliasingContainer.php @@ -64,6 +64,8 @@ class AliasingContainer implements ContainerInterface */ public function has($key) { + $key = (string) $key; + return $this->inner->has($this->getInnerKey($key)); } diff --git a/lib/packages/Dhii/Container/CachingContainer.php b/lib/packages/Dhii/Container/CachingContainer.php index f62abacb9..cafc2f8e6 100644 --- a/lib/packages/Dhii/Container/CachingContainer.php +++ b/lib/packages/Dhii/Container/CachingContainer.php @@ -64,7 +64,7 @@ class CachingContainer implements ContainerInterface throw new NotFoundException($this->__('Key "%1$s" not found in inner container', [$key]), 0, $e); } catch (Exception $e) { throw new ContainerException( - $this->__('Could not retrieve value for key "%1$s from inner container', [$key]), + $this->__('Could not retrieve value for key "%1$s" from inner container', [$key]), 0, $e ); diff --git a/lib/packages/Dhii/Container/CompositeCachingServiceProvider.php b/lib/packages/Dhii/Container/CompositeCachingServiceProvider.php index 13bbb38f3..65187182c 100644 --- a/lib/packages/Dhii/Container/CompositeCachingServiceProvider.php +++ b/lib/packages/Dhii/Container/CompositeCachingServiceProvider.php @@ -38,7 +38,8 @@ class CompositeCachingServiceProvider implements ServiceProviderInterface } /** - * {@inheritDoc} + * @inheritDoc + * * @psalm-suppress InvalidNullableReturnType * It isn't actually going to return null ever, because $factories will be filled during indexing. */ @@ -56,7 +57,8 @@ class CompositeCachingServiceProvider implements ServiceProviderInterface } /** - * {@inheritDoc} + * @inheritDoc + * * @psalm-suppress InvalidNullableReturnType * It isn't actually going to return null ever, because $factories will be filled during indexing. */ @@ -78,7 +80,7 @@ class CompositeCachingServiceProvider implements ServiceProviderInterface * * Caches them internally. * - * @param iterable|ServiceProviderInterface[] $providers The providers to index. + * @param iterable $providers The providers to index. */ protected function indexProviderDefinitions(iterable $providers): void { diff --git a/lib/packages/Dhii/Container/DelegatingContainer.php b/lib/packages/Dhii/Container/DelegatingContainer.php index e322579d9..d2ffd5768 100644 --- a/lib/packages/Dhii/Container/DelegatingContainer.php +++ b/lib/packages/Dhii/Container/DelegatingContainer.php @@ -89,6 +89,7 @@ class DelegatingContainer implements ContainerInterface public function has($id) { $services = $this->provider->getFactories(); + $id = (string) $id; return array_key_exists($id, $services); } diff --git a/lib/packages/Dhii/Container/DeprefixingContainer.php b/lib/packages/Dhii/Container/DeprefixingContainer.php index 8c6f14389..65fe531f6 100644 --- a/lib/packages/Dhii/Container/DeprefixingContainer.php +++ b/lib/packages/Dhii/Container/DeprefixingContainer.php @@ -84,7 +84,10 @@ class DeprefixingContainer implements ContainerInterface */ public function has($key) { - return $this->inner->has($this->getInnerKey($key)) || (!$this->strict && $this->inner->has($key)); + $key = (string) $key; + $realKey = $this->getInnerKey($key); + + return $this->inner->has($realKey) || (!$this->strict && $this->inner->has($key)); } /** diff --git a/lib/packages/Dhii/Container/Dictionary.php b/lib/packages/Dhii/Container/Dictionary.php index 2f61df85c..84433963b 100644 --- a/lib/packages/Dhii/Container/Dictionary.php +++ b/lib/packages/Dhii/Container/Dictionary.php @@ -5,13 +5,13 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Dhii\Container; use ArrayIterator; -use Traversable; use WooCommerce\PayPalCommerce\Vendor\Dhii\Collection\WritableContainerInterface; use WooCommerce\PayPalCommerce\Vendor\Dhii\Collection\WritableMapInterface; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\NotFoundException; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Util\StringTranslatingTrait; use IteratorAggregate; use RangeException; +use Traversable; /** * A simple mutable dictionary, i.e. an enumerable key-value map. @@ -54,6 +54,8 @@ class Dictionary implements */ public function has($key) { + $key = (string) $key; + $isHas = array_key_exists($key, $this->data); return $isHas; diff --git a/lib/packages/Dhii/Container/FlashContainer.php b/lib/packages/Dhii/Container/FlashContainer.php index 4a7a3e836..c2506aadc 100644 --- a/lib/packages/Dhii/Container/FlashContainer.php +++ b/lib/packages/Dhii/Container/FlashContainer.php @@ -61,6 +61,8 @@ class FlashContainer implements */ public function has($key) { + $key = (string) $key; + return array_key_exists($key, $this->flashData); } diff --git a/lib/packages/Dhii/Container/HierarchyContainer.php b/lib/packages/Dhii/Container/HierarchyContainer.php index 7c85b8f15..41550492e 100644 --- a/lib/packages/Dhii/Container/HierarchyContainer.php +++ b/lib/packages/Dhii/Container/HierarchyContainer.php @@ -88,6 +88,8 @@ class HierarchyContainer implements ContainerInterface */ public function has($key) { + $key = (string) $key; + return array_key_exists($key, $this->data); } } diff --git a/lib/packages/Dhii/Container/MappingContainer.php b/lib/packages/Dhii/Container/MappingContainer.php index 10b21d52c..ee482cc38 100644 --- a/lib/packages/Dhii/Container/MappingContainer.php +++ b/lib/packages/Dhii/Container/MappingContainer.php @@ -85,6 +85,8 @@ class MappingContainer implements ContainerInterface */ public function has($key) { + $key = (string) $key; + return $this->inner->has($key); } } diff --git a/lib/packages/Dhii/Container/MaskingContainer.php b/lib/packages/Dhii/Container/MaskingContainer.php index f2a721033..570a89825 100644 --- a/lib/packages/Dhii/Container/MaskingContainer.php +++ b/lib/packages/Dhii/Container/MaskingContainer.php @@ -80,6 +80,8 @@ class MaskingContainer implements ContainerInterface */ public function has($key) { + $key = (string) $key; + return $this->isExposed($key) && $this->inner->has($key); } diff --git a/lib/packages/Dhii/Container/NoOpContainer.php b/lib/packages/Dhii/Container/NoOpContainer.php index 143c4eb7f..68f151d48 100644 --- a/lib/packages/Dhii/Container/NoOpContainer.php +++ b/lib/packages/Dhii/Container/NoOpContainer.php @@ -12,6 +12,7 @@ use WooCommerce\PayPalCommerce\Vendor\Dhii\Collection\WritableMapInterface; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\ContainerException; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\NotFoundException; use IteratorAggregate; +use Traversable; /** * A container that does nothing. @@ -92,7 +93,7 @@ class NoOpContainer implements /** * @inheritDoc */ - public function getIterator() + public function getIterator(): Traversable { return new ArrayIterator([]); } diff --git a/lib/packages/Dhii/Container/PathContainer.php b/lib/packages/Dhii/Container/PathContainer.php index 5b0187278..c418e5ea3 100644 --- a/lib/packages/Dhii/Container/PathContainer.php +++ b/lib/packages/Dhii/Container/PathContainer.php @@ -127,6 +127,8 @@ class PathContainer implements ContainerInterface */ public function has($key) { + $key = (string) $key; + /** * @psalm-suppress InvalidCatch * The base interface does not extend Throwable, but in fact everything that is possible diff --git a/lib/packages/Dhii/Container/PrefixingContainer.php b/lib/packages/Dhii/Container/PrefixingContainer.php index f41afc226..895a2c14d 100644 --- a/lib/packages/Dhii/Container/PrefixingContainer.php +++ b/lib/packages/Dhii/Container/PrefixingContainer.php @@ -5,9 +5,12 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Dhii\Container; use WooCommerce\PayPalCommerce\Vendor\Dhii\Collection\ContainerInterface; +use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\ContainerException; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\NotFoundException; +use Exception; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface as PsrContainerInterface; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\NotFoundExceptionInterface; +use RuntimeException; /** * A container implementation that wraps around an inner container and prefixes its keys, requiring consumers to @@ -89,11 +92,18 @@ class PrefixingContainer implements ContainerInterface */ public function has($key) { + $key = (string) $key; if (!$this->isPrefixed($key) && $this->strict) { return false; } - return $this->inner->has($this->unprefix($key)) || (!$this->strict && $this->inner->has($key)); + try { + $realKey = $this->unprefix($key); + } catch (Exception $e) { + throw new ContainerException(sprintf('Could not unprefix key "%1$s"', $key), 0, $e); + } + + return $this->inner->has($realKey) || (!$this->strict && $this->inner->has($key)); } /** @@ -108,7 +118,7 @@ class PrefixingContainer implements ContainerInterface protected function unprefix(string $key): string { return $this->isPrefixed($key) - ? substr($key, strlen($this->prefix)) + ? $this->substring($key, strlen($this->prefix)) : $key; } @@ -125,4 +135,35 @@ class PrefixingContainer implements ContainerInterface { return strlen($this->prefix) > 0 && strpos($key, $this->prefix) === 0; } + + /** + * Extracts a substring from the specified string. + * + * @see substr() + * + * @param string $string The string to extract from. + * @param int $offset The char position, at which to start extraction. + * @param int|null $length The char position, at which to end extraction; unlimited if `null`. + * + * @return string The extracted substring. + * + * @throws RuntimeException If unable to extract. + */ + protected function substring(string $string, int $offset = 0, ?int $length = null): string + { + $length = $length ?? strlen($string) - $offset; + $substring = substr($string, $offset, $length); + if ($substring === false) { + throw new RuntimeException( + sprintf( + 'Could not extract substring starting at %1$d of length %2$s from string "%3$s"', + $offset, + $length ?: 'null', + $string + ) + ); + } + + return $substring; + } } diff --git a/lib/packages/Dhii/Container/SegmentingContainer.php b/lib/packages/Dhii/Container/SegmentingContainer.php index 27647605d..35cf09336 100644 --- a/lib/packages/Dhii/Container/SegmentingContainer.php +++ b/lib/packages/Dhii/Container/SegmentingContainer.php @@ -114,6 +114,8 @@ class SegmentingContainer implements ContainerInterface */ public function has($key) { + $key = (string) $key; + return $this->inner->has($key); } } diff --git a/lib/packages/Dhii/Container/ServiceProvider.php b/lib/packages/Dhii/Container/ServiceProvider.php index 39dc8d012..ac3f9e33e 100644 --- a/lib/packages/Dhii/Container/ServiceProvider.php +++ b/lib/packages/Dhii/Container/ServiceProvider.php @@ -5,22 +5,22 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Dhii\Container; use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface; +use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; /** * A value object capable of providing services. * - * @package Dhii\Di + * @psalm-type Factory = callable(ContainerInterface): mixed + * @psalm-type Extension = callable(ContainerInterface, mixed): mixed */ class ServiceProvider implements ServiceProviderInterface { + /** @var callable[] */ + protected array $factories; /** * @var callable[] */ - protected $factories; - /** - * @var callable[] - */ - protected $extensions; + protected array $extensions; /** * @param callable[] $factories A map of service name to service factory. diff --git a/lib/packages/Dhii/Container/SimpleCacheContainer.php b/lib/packages/Dhii/Container/SimpleCacheContainer.php index eba8e29bb..516f8e9de 100644 --- a/lib/packages/Dhii/Container/SimpleCacheContainer.php +++ b/lib/packages/Dhii/Container/SimpleCacheContainer.php @@ -55,6 +55,7 @@ class SimpleCacheContainer implements */ public function has($id) { + $id = (string) $id; $storage = $this->storage; try { @@ -77,7 +78,11 @@ class SimpleCacheContainer implements try { $storage->set($key, $value, $ttl); } catch (Exception $e) { - throw new ContainerException(sprintf('Could not set key "%1$s" with value "%2$s"', $key, $value), 0, $e); + throw new ContainerException( + sprintf('Could not set key "%1$s" with value "%2$s"', $key, (string) $value), + 0, + $e + ); } } @@ -105,7 +110,7 @@ class SimpleCacheContainer implements try { $storage->clear(); } catch (Exception $e) { - throw new ContainerException(sprintf('Could not clear container'), 0, $e); + throw new ContainerException('Could not clear container', 0, $e); } } } diff --git a/lib/packages/Dhii/Container/TaggingServiceProvider.php b/lib/packages/Dhii/Container/TaggingServiceProvider.php new file mode 100644 index 000000000..9f7e81b86 --- /dev/null +++ b/lib/packages/Dhii/Container/TaggingServiceProvider.php @@ -0,0 +1,121 @@ + */ + protected array $factories; + /** @var array */ + protected array $extensions; + + public function __construct(ServiceProviderInterface $inner) + { + $this->factories = $inner->getFactories(); + $this->extensions = $inner->getExtensions(); + $this->indexTags(); + } + + /** + * @inheritDoc + */ + public function getFactories() + { + return $this->factories; + } + + /** + * @inheritDoc + */ + public function getExtensions() + { + return $this->extensions; + } + + /** + * Indexes tagged factories, and creates factories and extensions for tags. + * + * @throws ReflectionException If problem obtaining factory reflection. + */ + protected function indexTags(): void + { + $tags = []; + + foreach ($this->factories as $serviceName => $factory) { + if (is_string($factory)) { + continue; + } + + $reflection = is_object($factory) && get_class($factory) === 'Closure' + ? new ReflectionFunction($factory) + : new ReflectionObject($factory); + $docBlock = $reflection->getDocComment(); + + // No docblock + if ($docBlock === false) { + continue; + } + + $factoryTags = $this->getTagsFromDocBlock($docBlock); + foreach ($factoryTags as $tag) { + if (!isset($tags[$tag]) || !is_array($tags[$tag])) { + $tags[$tag] = []; + } + $tags[$tag][] = $serviceName; + } + } + + foreach ($tags as $tag => $taggedServiceNames) { + $this->factories[$tag] = fn (): array => []; + $this->extensions[$tag] = function (ContainerInterface $c, array $prev) use ($taggedServiceNames): array { + return array_merge( + $prev, + array_map(fn (string $serviceName) => $c->get($serviceName), $taggedServiceNames) + ); + }; + } + } + + /** + * Retrieves tags names that are part of a docBlock. + * + * @link https://www.php.net/manual/en/reflectionclass.getdoccomment.php#118606 + * + * @param string $docBlock The docBlock. + * + * @return array A list of tag names. + */ + protected function getTagsFromDocBlock(string $docBlock): array + { + $regex = '#^\s*/?\**\s*(@tag\s*(?P[^\s]+))#m'; + preg_match_all($regex, $docBlock, $matches); + + return $matches['tags']; + } +} diff --git a/lib/packages/Dhii/Container/Util/StringTranslatingTrait.php b/lib/packages/Dhii/Container/Util/StringTranslatingTrait.php index 206d7e282..c799b4a37 100644 --- a/lib/packages/Dhii/Container/Util/StringTranslatingTrait.php +++ b/lib/packages/Dhii/Container/Util/StringTranslatingTrait.php @@ -27,6 +27,7 @@ trait StringTranslatingTrait */ protected function __(string $string, array $args = array(), $context = null): string { + $context = (string) $context; $string = $this->_translate($string, $context); array_unshift($args, $string); return call_user_func_array('sprintf', $args); diff --git a/lib/packages/Inpsyde/Modularity/Container/ContainerConfigurator.php b/lib/packages/Inpsyde/Modularity/Container/ContainerConfigurator.php index 429869912..810fe1119 100644 --- a/lib/packages/Inpsyde/Modularity/Container/ContainerConfigurator.php +++ b/lib/packages/Inpsyde/Modularity/Container/ContainerConfigurator.php @@ -1,52 +1,38 @@ - */ - private $services = []; + /** @var array */ + private array $services = []; + /** @var array */ + private array $factoryIds = []; + private ServiceExtensions $extensions; + private ?ContainerInterface $compiledContainer = null; + /** @var ContainerInterface[] */ + private array $containers = []; /** - * @var array - */ - private $factoryIds = []; - - /** - * @var array> - */ - private $extensions = []; - - /** - * @var ContainerInterface[] - */ - private $containers = []; - - /** - * @var null|ContainerInterface - */ - private $compiledContainer; - - /** - * ContainerConfigurator constructor. - * * @param ContainerInterface[] $containers */ - public function __construct(array $containers = []) + public function __construct(array $containers = [], ?ServiceExtensions $extensions = null) { array_map([$this, 'addContainer'], $containers); + $this->extensions = $extensions ?? new ServiceExtensions(); } /** - * Allowing to add child containers. - * * @param ContainerInterface $container + * @return void */ public function addContainer(ContainerInterface $container): void { @@ -55,7 +41,7 @@ class ContainerConfigurator /** * @param string $id - * @param callable(ContainerInterface $container):mixed $factory + * @param Service $factory */ public function addFactory(string $id, callable $factory): void { @@ -67,8 +53,7 @@ class ContainerConfigurator /** * @param string $id - * @param callable(ContainerInterface $container):mixed $service - * + * @param Service $service * @return void */ public function addService(string $id, callable $service): void @@ -89,7 +74,6 @@ class ContainerConfigurator /** * @param string $id - * * @return bool */ public function hasService(string $id): bool @@ -109,37 +93,31 @@ class ContainerConfigurator /** * @param string $id - * @param callable(mixed $service, ContainerInterface $container):mixed $extender - * + * @param ExtendingService $extender * @return void */ public function addExtension(string $id, callable $extender): void { - if (!isset($this->extensions[$id])) { - $this->extensions[$id] = []; - } - - $this->extensions[$id][] = $extender; + $this->extensions->add($id, $extender); } /** * @param string $id - * * @return bool */ public function hasExtension(string $id): bool { - return isset($this->extensions[$id]); + return $this->extensions->has($id); } /** - * Returns a read only version of this Container. - * * @return ContainerInterface + * + * @phpstan-assert ContainerInterface $this->compiledContainer */ public function createReadOnlyContainer(): ContainerInterface { - if (!$this->compiledContainer) { + if ($this->compiledContainer === null) { $this->compiledContainer = new ReadOnlyContainer( $this->services, $this->factoryIds, diff --git a/lib/packages/Inpsyde/Modularity/Container/PackageProxyContainer.php b/lib/packages/Inpsyde/Modularity/Container/PackageProxyContainer.php index 2c8d79d5d..47cc8c11d 100644 --- a/lib/packages/Inpsyde/Modularity/Container/PackageProxyContainer.php +++ b/lib/packages/Inpsyde/Modularity/Container/PackageProxyContainer.php @@ -10,15 +10,8 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; class PackageProxyContainer implements ContainerInterface { - /** - * @var Package - */ - private $package; - - /** - * @var ContainerInterface|null - */ - private $container; + private Package $package; + private ?ContainerInterface $container = null; /** * @param Package $package @@ -31,8 +24,6 @@ class PackageProxyContainer implements ContainerInterface /** * @param string $id * @return mixed - * - * @throws \Exception */ public function get(string $id) { @@ -44,8 +35,6 @@ class PackageProxyContainer implements ContainerInterface /** * @param string $id * @return bool - * - * @throws \Exception */ public function has(string $id): bool { @@ -55,33 +44,30 @@ class PackageProxyContainer implements ContainerInterface /** * @return bool * - * @throws \Exception - * @psalm-assert-if-true ContainerInterface $this->container + * @phpstan-assert-if-true ContainerInterface $this->container + * @phpstan-assert-if-false null $this->container */ private function tryContainer(): bool { - if ($this->container) { + if ($this->container !== null) { return true; } - /** TODO: We need a better way to deal with status checking besides equality */ if ( - $this->package->statusIs(Package::STATUS_READY) - || $this->package->statusIs(Package::STATUS_BOOTED) + $this->package->hasContainer() + || $this->package->hasReachedStatus(Package::STATUS_INITIALIZED) ) { $this->container = $this->package->container(); } - return (bool)$this->container; + return $this->container !== null; } /** * @param string $id * @return void * - * @throws \Exception - * - * @psalm-assert ContainerInterface $this->container + * @phpstan-assert ContainerInterface $this->container */ private function assertPackageBooted(string $id): void { @@ -90,13 +76,11 @@ class PackageProxyContainer implements ContainerInterface } $name = $this->package->name(); - $status = $this->package->statusIs(Package::STATUS_FAILED) - ? 'is errored' - : 'is not ready yet'; + $status = $this->package->hasFailed() ? 'is errored' : 'is not ready yet'; - throw new class ("Error retrieving service {$id} because package {$name} {$status}.") - extends \Exception - implements ContainerExceptionInterface { + $error = "Error retrieving service {$id} because package {$name} {$status}."; + throw new class (esc_html($error)) extends \Exception implements ContainerExceptionInterface + { }; } } diff --git a/lib/packages/Inpsyde/Modularity/Container/ReadOnlyContainer.php b/lib/packages/Inpsyde/Modularity/Container/ReadOnlyContainer.php index 763ae23ac..cbd06b9a0 100644 --- a/lib/packages/Inpsyde/Modularity/Container/ReadOnlyContainer.php +++ b/lib/packages/Inpsyde/Modularity/Container/ReadOnlyContainer.php @@ -7,58 +7,43 @@ namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\NotFoundExceptionInterface; +/** + * @phpstan-import-type Service from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule + * @phpstan-import-type ExtendingService from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule + */ class ReadOnlyContainer implements ContainerInterface { - /** - * @var array - */ - private $services; + /** @var array */ + private array $services; + /** @var array */ + private array $factoryIds; + private ServiceExtensions $extensions; + /** @var ContainerInterface[] */ + private array $containers; + /** @var array */ + private array $resolvedServices = []; /** - * @var array - */ - private $factoryIds; - - /** - * @var array> - */ - private $extensions; - - /** - * Resolved factories. - * - * @var array - */ - private $resolvedServices = []; - - /** - * @var ContainerInterface[] - */ - private $containers; - - /** - * ReadOnlyContainer constructor. - * - * @param array $services + * @param array $services * @param array $factoryIds - * @param array> $extensions + * @param ServiceExtensions|array $extensions * @param ContainerInterface[] $containers */ public function __construct( array $services, array $factoryIds, - array $extensions, + $extensions, array $containers ) { + $this->services = $services; $this->factoryIds = $factoryIds; - $this->extensions = $extensions; + $this->extensions = $this->configureServiceExtensions($extensions); $this->containers = $containers; } /** * @param string $id - * * @return mixed */ public function get(string $id) @@ -69,7 +54,7 @@ class ReadOnlyContainer implements ContainerInterface if (array_key_exists($id, $this->services)) { $service = $this->services[$id]($this); - $resolved = $this->resolveExtensions($id, $service); + $resolved = $this->extensions->resolve($service, $id, $this); if (!isset($this->factoryIds[$id])) { $this->resolvedServices[$id] = $resolved; @@ -83,19 +68,18 @@ class ReadOnlyContainer implements ContainerInterface if ($container->has($id)) { $service = $container->get($id); - return $this->resolveExtensions($id, $service); + return $this->extensions->resolve($service, $id, $this); } } - throw new class ("Service with ID {$id} not found.") - extends \Exception - implements NotFoundExceptionInterface { + $error = "Service with ID {$id} not found."; + throw new class (esc_html($error)) extends \Exception implements NotFoundExceptionInterface + { }; } /** * @param string $id - * * @return bool */ public function has(string $id): bool @@ -118,21 +102,43 @@ class ReadOnlyContainer implements ContainerInterface } /** - * @param string $id - * @param mixed $service + * Support extensions as array or ServiceExtensions instance for backward compatibility. * - * @return mixed + * With PHP 8+ we could use an actual union type, but when we bump to PHP 8 as min supported + * version, we will probably bump major version as well, so we can just get rid of support + * for array. + * + * @param mixed $extensions + * @return ServiceExtensions */ - private function resolveExtensions(string $id, $service) + private function configureServiceExtensions($extensions): ServiceExtensions { - if (!isset($this->extensions[$id])) { - return $service; + if ($extensions instanceof ServiceExtensions) { + return $extensions; } - foreach ($this->extensions[$id] as $extender) { - $service = $extender($service, $this); + if (!is_array($extensions)) { + $type = is_object($extensions) ? get_class($extensions) : gettype($extensions); + throw new \TypeError( + sprintf( + '%s::%s(): Argument #3 ($extensions) must be of type %s|array, %s given', + __CLASS__, + '__construct', + ServiceExtensions::class, + esc_html($type) + ) + ); } - return $service; + $servicesExtensions = new ServiceExtensions(); + foreach ($extensions as $id => $callback) { + /** + * @var string $id + * @var ExtendingService $callback + */ + $servicesExtensions->add($id, $callback); + } + + return $servicesExtensions; } } diff --git a/lib/packages/Inpsyde/Modularity/Container/ServiceExtensions.php b/lib/packages/Inpsyde/Modularity/Container/ServiceExtensions.php new file mode 100644 index 000000000..3b764041f --- /dev/null +++ b/lib/packages/Inpsyde/Modularity/Container/ServiceExtensions.php @@ -0,0 +1,194 @@ +> */ + protected array $extensions = []; + + /** + * @param string $type + * + * @return string + */ + final public static function typeId(string $type): string + { + return "@instanceof<{$type}>"; + } + + /** + * @param string $extensionId + * @param ExtendingService $extender + * + * @return static + */ + public function add(string $extensionId, callable $extender): ServiceExtensions + { + if (!isset($this->extensions[$extensionId])) { + $this->extensions[$extensionId] = []; + } + $this->extensions[$extensionId][] = $extender; + + return $this; + } + + /** + * @param string $extensionId + * + * @return bool + */ + public function has(string $extensionId): bool + { + return isset($this->extensions[$extensionId]); + } + + /** + * @param mixed $service + * @param string $id + * @param Container $container + * + * @return mixed + */ + final public function resolve($service, string $id, Container $container) + { + $service = $this->resolveById($id, $service, $container); + + return is_object($service) + ? $this->resolveByType(get_class($service), $service, $container) + : $service; + } + + /** + * @param string $id + * @param mixed $service + * @param Container $container + * + * @return mixed + */ + protected function resolveById(string $id, $service, Container $container) + { + foreach ($this->extensions[$id] ?? [] as $extender) { + $service = $extender($service, $container); + } + + return $service; + } + + /** + * @param string $className + * @param object $service + * @param Container $container + * @param string[] $extendedClasses + * + * @return mixed + * + * phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh + * phpcs:disable Syde.Functions.ReturnTypeDeclaration.NoReturnType + */ + protected function resolveByType( + string $className, + object $service, + Container $container, + array $extendedClasses = [] + ) { + // phpcs:enable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh + // phpcs:enable Syde.Functions.ReturnTypeDeclaration.NoReturnType + + $extendedClasses[] = $className; + + /** @var array> $allCallbacks */ + $allCallbacks = []; + + // 1st group of extensions: targeting exact class + $byClass = $this->extensions[self::typeId($className)] ?? null; + if (($byClass !== null) && ($byClass !== [])) { + $allCallbacks[$className] = $byClass; + } + + // 2nd group of extensions: targeting parent classes + $parents = class_parents($service, false) ?: []; + foreach ($parents as $parentName) { + $byParent = $this->extensions[self::typeId($parentName)] ?? null; + if (($byParent !== null) && ($byParent !== [])) { + $allCallbacks[$parentName] = $byParent; + } + } + + // 3rd group of extensions: targeting implemented interfaces + $interfaces = class_implements($service, false) ?: []; + foreach ($interfaces as $interfaceName) { + $byInterface = $this->extensions[self::typeId($interfaceName)] ?? null; + if (($byInterface !== null) && ($byInterface !== [])) { + $allCallbacks[$interfaceName] = $byInterface; + } + } + + $resultType = self::SERVICE_TYPE_NOT_CHANGED; + /** @var class-string $type */ + foreach ($allCallbacks as $type => $extenders) { + // When the previous group of callbacks resulted in a type change, we need to check + // type before processing next group. + if (($resultType === self::SERVICE_TYPE_CHANGED) && !is_a($service, $type)) { + continue; + } + /** @var object $service */ + [$service, $resultType] = $this->extendByType($type, $service, $container, $extenders); + if ($resultType === self::SERVICE_TYPE_NOT_OBJECT) { + // Service is not an object anymore, let's return it. + return $service; + } + } + + // If type changed since beginning, let's start over. + // We check if class was already extended to avoid infinite recursion. E.g. instead of: + // `-> extend(A): B -> extend(B): A -> *loop* ->` + // we have: + // `-> extend(A): B -> extend(B): A -> return A`. + $newClassName = get_class($service); + if (!in_array($newClassName, $extendedClasses, true)) { + return $this->resolveByType($newClassName, $service, $container, $extendedClasses); + } + + return $service; + } + + /** + * @param class-string $type + * @param object $service + * @param Container $container + * @param list $extenders + * + * @return list{mixed, int} + */ + private function extendByType( + string $type, + object $service, + Container $container, + array $extenders + ): array { + + foreach ($extenders as $extender) { + $service = $extender($service, $container); + if (!is_object($service)) { + return [$service, self::SERVICE_TYPE_NOT_OBJECT]; + } + if (!is_a($service, $type)) { + return [$service, self::SERVICE_TYPE_CHANGED]; + } + } + + return [$service, self::SERVICE_TYPE_NOT_CHANGED]; + } +} diff --git a/lib/packages/Inpsyde/Modularity/Module/ExecutableModule.php b/lib/packages/Inpsyde/Modularity/Module/ExecutableModule.php index 2f7771fb5..b874af21f 100644 --- a/lib/packages/Inpsyde/Modularity/Module/ExecutableModule.php +++ b/lib/packages/Inpsyde/Modularity/Module/ExecutableModule.php @@ -8,7 +8,6 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; interface ExecutableModule extends Module { - /** * Perform actions with objects retrieved from the container. Usually, adding WordPress hooks. * Return true to signal a success, false to signal a failure. diff --git a/lib/packages/Inpsyde/Modularity/Module/ExtendingModule.php b/lib/packages/Inpsyde/Modularity/Module/ExtendingModule.php index 260e1f0c4..0c6f527c5 100644 --- a/lib/packages/Inpsyde/Modularity/Module/ExtendingModule.php +++ b/lib/packages/Inpsyde/Modularity/Module/ExtendingModule.php @@ -4,9 +4,13 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module; +use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; + +/** + * @phpstan-type ExtendingService callable(mixed $service, ContainerInterface $container): mixed + */ interface ExtendingModule extends Module { - /** * Return application services' extensions. * @@ -18,7 +22,7 @@ interface ExtendingModule extends Module * That is done by using as ID (array key in the `extensions` method) the target module ID * and the service ID. * - * @return array + * @return array */ public function extensions(): array; } diff --git a/lib/packages/Inpsyde/Modularity/Module/FactoryModule.php b/lib/packages/Inpsyde/Modularity/Module/FactoryModule.php index 6e686c0c2..85670d95a 100644 --- a/lib/packages/Inpsyde/Modularity/Module/FactoryModule.php +++ b/lib/packages/Inpsyde/Modularity/Module/FactoryModule.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module; +/** + * @phpstan-import-type Service from ServiceModule + */ interface FactoryModule extends Module { /** @@ -12,7 +15,7 @@ interface FactoryModule extends Module * Similar to `services`, but object created by given factories are not "cached", but a *new* * instance is returned everytime `get()` is called in the container. * - * @return array + * @return array */ public function factories(): array; } diff --git a/lib/packages/Inpsyde/Modularity/Module/Module.php b/lib/packages/Inpsyde/Modularity/Module/Module.php index 49dd0e5e3..fa5589393 100644 --- a/lib/packages/Inpsyde/Modularity/Module/Module.php +++ b/lib/packages/Inpsyde/Modularity/Module/Module.php @@ -9,12 +9,10 @@ namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module; */ interface Module { - /** * Unique identifier for your Module. * * @return string */ public function id(): string; - } diff --git a/lib/packages/Inpsyde/Modularity/Module/ModuleClassNameIdTrait.php b/lib/packages/Inpsyde/Modularity/Module/ModuleClassNameIdTrait.php index 938b9b753..b5b5245e6 100644 --- a/lib/packages/Inpsyde/Modularity/Module/ModuleClassNameIdTrait.php +++ b/lib/packages/Inpsyde/Modularity/Module/ModuleClassNameIdTrait.php @@ -4,16 +4,11 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module; -/** - * Trait ModuleClassNameIdTrait - * - * @package WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module - */ trait ModuleClassNameIdTrait { - /** * @return string + * * @see Module::id() */ public function id(): string diff --git a/lib/packages/Inpsyde/Modularity/Module/ServiceModule.php b/lib/packages/Inpsyde/Modularity/Module/ServiceModule.php index 4668ccc5f..8aec8a020 100644 --- a/lib/packages/Inpsyde/Modularity/Module/ServiceModule.php +++ b/lib/packages/Inpsyde/Modularity/Module/ServiceModule.php @@ -4,9 +4,13 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module; +use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; + +/** + * @phpstan-type Service callable(ContainerInterface $container): mixed + */ interface ServiceModule extends Module { - /** * Return application services' factories. * @@ -15,7 +19,7 @@ interface ServiceModule extends Module * Services are "cached", so the given factory is called once the first time `get()` is called * in the container, and on subsequent `get()` the same instance is returned again and again. * - * @return array + * @return array */ public function services(): array; } diff --git a/lib/packages/Inpsyde/Modularity/Package.php b/lib/packages/Inpsyde/Modularity/Package.php index f55eaeccb..75f1d3e52 100644 --- a/lib/packages/Inpsyde/Modularity/Package.php +++ b/lib/packages/Inpsyde/Modularity/Package.php @@ -6,19 +6,22 @@ namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container\ContainerConfigurator; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container\PackageProxyContainer; -use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; +use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\FactoryModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\Module; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties\Properties; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; +/** + * @phpstan-import-type Service from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule + * @phpstan-import-type ExtendingService from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule + */ class Package { /** * All the hooks fired in this class use this prefix. - * @var string */ private const HOOK_PREFIX = 'inpsyde.modularity.'; @@ -34,14 +37,13 @@ class Package * $container->has(Package::PROPERTIES); * $container->get(Package::PROPERTIES); * - * - * @var string */ public const PROPERTIES = 'properties'; /** - * Custom action to be used to add Modules to the package. + * Custom action to be used to add modules and connect other packages. * It might also be used to access package properties. + * Access container is not possible at this stage. * * @example * @@ -49,67 +51,64 @@ class Package * * add_action( * $package->hookName(Package::ACTION_INIT), - * $callback + * fn (Package $package) => // do something, * ); * */ public const ACTION_INIT = 'init'; /** - * Custom action which is triggered after the application - * is booted to access container and properties. + * Very similar to `ACTION_INIT`, but it is static, so not dependent on package name. + * It passes package name as first argument. * * @example - * - * $package = Package::new(); - * - * add_action( - * $package->hookName(Package::ACTION_READY), - * $callback - * ); - * + * + * add_action( + * Package::ACTION_MODULARITY_INIT, + * fn (string $packageName, Package $package) => // do something, + * 10, + * 2 + * ); + * */ - public const ACTION_READY = 'ready'; + public const ACTION_MODULARITY_INIT = self::HOOK_PREFIX . self::ACTION_INIT; /** - * Custom action which is triggered when a failure happens during the building stage. - * - * @example - * - * $package = Package::new(); - * - * add_action( - * $package->hookName(Package::ACTION_FAILED_BUILD), - * $callback - * ); - * + * Action fired when it is safe to access container. + * Add more modules is not anymore possible at this stage. + */ + public const ACTION_INITIALIZED = 'initialized'; + + /** + * Action fired when plugin finished its bootstrapping process, all its hooks are added. + * Add more modules is not anymore possible at this stage. + */ + public const ACTION_BOOTED = 'ready'; + + /** + * Action fired when anything went wrong during the "build" procedure. */ public const ACTION_FAILED_BUILD = 'failed-build'; /** - * Custom action which is triggered when a failure happens during the booting stage. - * - * @example - * - * $package = Package::new(); - * - * add_action( - * $package->hookName(Package::ACTION_FAILED_BOOT), - * $callback - * ); - * + * Action fired when anything went wrong during the "boot" procedure. */ public const ACTION_FAILED_BOOT = 'failed-boot'; /** - * Custom action which is triggered when a package is connected. + * Action fired when adding a module failed. */ - public const ACTION_PACKAGE_CONNECTED = 'package-connected'; + public const ACTION_FAILED_ADD_MODULE = 'failed-add-module'; /** - * Custom action which is triggered when a package cannot be connected. + * Action fired when a package connection failed. */ - public const ACTION_FAILED_CONNECTION = 'failed-connection'; + public const ACTION_FAILED_CONNECT = 'failed-connection'; + + /** + * Action fired when a package is connected successfully. + */ + public const ACTION_PACKAGE_CONNECTED = 'package-connected'; /** * Module states can be used to get information about your module. @@ -118,7 +117,7 @@ class Package * * $package = Package::new(); * $package->moduleIs(SomeModule::class, Package::MODULE_ADDED); // false - * $package->boot(new SomeModule()); + * $package->addModule(new SomeModule()); * $package->moduleIs(SomeModule::class, Package::MODULE_ADDED); // true * */ @@ -137,90 +136,81 @@ class Package * @example * * $package = Package::new(); - * $package->statusIs(Package::IDLE); // true + * $package->statusIs(Package::STATUS_IDLE); // true + * $package->build(); + * $package->statusIs(Package::STATUS_INITIALIZED); // true * $package->boot(); - * $package->statusIs(Package::BOOTED); // true + * $package->statusIs(Package::STATUS_DONE); // true * */ public const STATUS_IDLE = 2; + public const STATUS_INITIALIZING = 3; public const STATUS_INITIALIZED = 4; - public const STATUS_MODULES_ADDED = 5; - public const STATUS_READY = 7; - public const STATUS_BOOTED = 8; + public const STATUS_BOOTING = 5; + public const STATUS_BOOTED = 7; + public const STATUS_DONE = 8; public const STATUS_FAILED = -8; - /** - * Current state of the application. - * - * @see Package::STATUS_* - * - * @var int - */ - private $status = self::STATUS_IDLE; + // Deprecated flags + /** @deprecated */ + public const STATUS_MODULES_ADDED = self::STATUS_BOOTING; + /** @deprecated */ + public const ACTION_READY = self::ACTION_BOOTED; + /** @deprecated */ + public const ACTION_FAILED_CONNECTION = self::ACTION_FAILED_CONNECT; - /** - * Contains the progress of all modules. - * - * @see Package::moduleProgress() - * - * @var array> - */ - private $moduleStatus = [self::MODULES_ALL => []]; + // Map of status to package-specific and global hook, both optional (i..e, null). + private const STATUSES_ACTIONS_MAP = [ + self::STATUS_INITIALIZING => [self::ACTION_INIT, self::ACTION_MODULARITY_INIT], + self::STATUS_INITIALIZED => [self::ACTION_INITIALIZED, null], + self::STATUS_BOOTED => [self::ACTION_BOOTED, null], + ]; - /** - * Hashmap of where keys are names of connected packages, and values are boolean, true - * if connection was successful. - * - * @see Package::connect() - * - * @var array - */ - private $connectedPackages = []; + private const SUCCESS_STATUSES = [ + self::STATUS_IDLE => self::STATUS_IDLE, + self::STATUS_INITIALIZING => self::STATUS_INITIALIZING, + self::STATUS_INITIALIZED => self::STATUS_INITIALIZED, + self::STATUS_BOOTING => self::STATUS_BOOTING, + self::STATUS_BOOTED => self::STATUS_BOOTED, + self::STATUS_DONE => self::STATUS_DONE, + ]; - /** - * @var list - */ - private $executables = []; + private const OPERATORS = [ + '<' => '<', + '<=' => '<=', + '>' => '>', + '>=' => '>=', + '==' => '==', + '!=' => '!=', + ]; - /** - * @var Properties - */ - private $properties; - - /** - * @var ContainerConfigurator - */ - private $containerConfigurator; - - /** - * @var bool - */ - private $built = false; - - /** - * @var bool - */ - private $hasContainer = false; - - /** - * @var \Throwable|null - */ - private $lastError = null; + /** @var Package::STATUS_* */ + private int $status = self::STATUS_IDLE; + /** @var array> */ + private array $moduleStatus = [self::MODULES_ALL => []]; + /** @var array */ + private array $connectedPackages = []; + /** @var list */ + private array $executables = []; + private Properties $properties; + private ContainerConfigurator $containerConfigurator; + private bool $built = false; + private bool $hasContainer = false; + private ?\Throwable $lastError = null; /** * @param Properties $properties - * @param ContainerInterface[] $containers - * + * @param ContainerInterface ...$containers * @return Package */ - public static function new(Properties $properties, ContainerInterface ...$containers): Package + public static function new(Properties $properties, ContainerInterface ...$containers): Package { return new self($properties, ...$containers); } /** * @param Properties $properties - * @param ContainerInterface[] $containers + * @param ContainerInterface ...$containers */ private function __construct(Properties $properties, ContainerInterface ...$containers) { @@ -229,7 +219,7 @@ class Package $this->containerConfigurator = new ContainerConfigurator($containers); $this->containerConfigurator->addService( self::PROPERTIES, - static function () use ($properties) { + static function () use ($properties): Properties { return $properties; } ); @@ -237,14 +227,14 @@ class Package /** * @param Module $module - * * @return static - * @throws \Exception */ public function addModule(Module $module): Package { try { - $this->assertStatus(self::STATUS_IDLE, sprintf('add module %s', $module->id())); + $reason = sprintf('add module %s', $module->id()); + $this->assertStatus(self::STATUS_FAILED, $reason, '!='); + $this->assertStatus(self::STATUS_INITIALIZING, $reason, '<='); $registeredServices = $this->addModuleServices( $module, @@ -271,7 +261,7 @@ class Package $status = $added ? self::MODULE_ADDED : self::MODULE_NOT_ADDED; $this->moduleProgress($module->id(), $status); } catch (\Throwable $throwable) { - $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); + $this->handleFailure($throwable, self::ACTION_FAILED_ADD_MODULE); } return $this; @@ -280,7 +270,6 @@ class Package /** * @param Package $package * @return bool - * @throws \Exception */ public function connect(Package $package): bool { @@ -290,33 +279,17 @@ class Package } $packageName = $package->name(); - $errorData = ['package' => $packageName, 'status' => $this->status]; - $errorMessage = "Failed connecting package {$packageName}"; // Don't connect, if already connected if (array_key_exists($packageName, $this->connectedPackages)) { - $error = "{$errorMessage} because it was already connected."; - do_action( - $this->hookName(self::ACTION_FAILED_CONNECTION), - $packageName, - new \WP_Error('already_connected', $error, $errorData) - ); - - throw new \Exception($error, 0, $this->lastError); + return $this->handleConnectionFailure($packageName, 'already connected', false); } // Don't connect, if already booted or boot failed - $failed = $this->statusIs(self::STATUS_FAILED); - if ($failed || $this->statusIs(self::STATUS_BOOTED)) { - $status = $failed ? 'errored' : 'booted'; - $error = "{$errorMessage} to a {$status} package."; - do_action( - $this->hookName(self::ACTION_FAILED_CONNECTION), - $packageName, - new \WP_Error("no_connect_on_{$status}", $error, $errorData) - ); - - throw new \Exception($error, 0, $this->lastError); + $failed = $this->hasFailed(); + if ($failed || $this->hasReachedStatus(self::STATUS_INITIALIZED)) { + $reason = $failed ? 'is errored' : 'has a built container already'; + $this->handleConnectionFailure($packageName, "current package {$reason}", true); } $this->connectedPackages[$packageName] = true; @@ -330,9 +303,10 @@ class Package } ); - // If the other package is booted, we can obtain a container, otherwise - // we build a proxy container - $container = $package->statusIs(self::STATUS_BOOTED) + // If we can obtain a container we do, otherwise we build a proxy container + $packageHasContainer = $package->hasReachedStatus(self::STATUS_INITIALIZED) + || $package->hasContainer(); + $container = $packageHasContainer ? $package->container() : new PackageProxyContainer($package); @@ -347,7 +321,10 @@ class Package return true; } catch (\Throwable $throwable) { - if (isset($packageName)) { + if ( + isset($packageName) + && (($this->connectedPackages[$packageName] ?? false) !== true) + ) { $this->connectedPackages[$packageName] = false; } $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); @@ -362,17 +339,26 @@ class Package public function build(): Package { try { - // Don't allow building the application multiple times. + // Be tolerant about things like `$package->build()->build()`. + // Sometimes, from the extern, we might want to call `build()` to ensure the container + // is ready before accessing a service. And in that case we don't want to throw an + // exception if the container is already built. + if ($this->built && $this->statusIs(self::STATUS_INITIALIZED)) { + return $this; + } + + // We expect `build` to be called only after `addModule()` or `connect()` which do + // not change the status, so we expect status to be still "IDLE". + // This will prevent invalid things like calling `build()` from inside something + // hooking ACTION_INIT OR ACTION_INITIALIZED. $this->assertStatus(self::STATUS_IDLE, 'build package'); - do_action( - $this->hookName(self::ACTION_INIT), - $this - ); - // Changing the status here ensures we can not call this method again, and also we can not - // add new modules, because both this and `addModule()` methods check for idle status. - // For backward compatibility, adding new modules via `boot()` will still be possible, even - // if deprecated, at the condition that the container was not yet accessed at that point. + // This will change the status to "INITIALIZING" then fire the action that allow other + // packages to add modules or connect packages. + $this->progress(self::STATUS_INITIALIZING); + + // This will change the status to "INITIALIZED" then fire an action when it is safe to + // access the container, because from this moment on, container is locked from change. $this->progress(self::STATUS_INITIALIZED); } catch (\Throwable $throwable) { $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); @@ -386,37 +372,47 @@ class Package /** * @param Module ...$defaultModules Deprecated, use `addModule()` to add default modules. * @return bool - * - * @throws \Throwable */ public function boot(Module ...$defaultModules): bool { try { + // When package is done, nothing should happen to it calling boot again, but we call + // false to signal something is off. + if ($this->statusIs(self::STATUS_DONE)) { + return false; + } + // Call build() if not called yet, and ensure any new module passed here is added // as well, throwing if the container was already built. $this->doBuild(...$defaultModules); - // Don't allow booting the application multiple times. - $this->assertStatus(self::STATUS_MODULES_ADDED, 'boot application', '<'); - $this->assertStatus(self::STATUS_FAILED, 'boot application', '!='); + // Make sure we call boot() on a non-failed instance, and also make a sanity check + // on the status flow, e.g. prevent calling boot() from an action hook. + $this->assertStatus(self::STATUS_INITIALIZED, 'boot application'); - $this->progress(self::STATUS_MODULES_ADDED); + // This will change status to STATUS_BOOTING "locking" subsequent call to `boot()`, but + // no hook is fired here, because at this point we can not do anything more or less than + // what can be done on the ACTION_INITIALIZED hook, so that hook is sufficient. + $this->progress(self::STATUS_BOOTING); $this->doExecute(); - $this->progress(self::STATUS_READY); - - do_action( - $this->hookName(self::ACTION_READY), - $this - ); + // This will change status to STATUS_BOOTED and then fire an action that make it + // possible to hook on a package that has finished its bootstrapping process, so all its + // "executable" modules have been executed. + $this->progress(self::STATUS_BOOTED); } catch (\Throwable $throwable) { $this->handleFailure($throwable, self::ACTION_FAILED_BOOT); return false; } - $this->progress(self::STATUS_BOOTED); + // This will change the status to DONE and will not fire any action. + // This is a status that proves that everything went well, not only the Package itself, + // but also anything hooking Package's hooks. + // The only way to move out of this status is a failure that might only happen directly + // calling `addModule()`, `connect()` or `build()`. + $this->progress(self::STATUS_DONE); return true; } @@ -439,39 +435,67 @@ class Package ); } + // We expect `boot()` to be called either: + // 1. Directly after `addModule()`/`connect()`, without any `build()` call in between, so + // status is IDLE and `$this->built` is `false`. + // 2. After `build()` is called, so status is INITIALIZED and `$this->built` is `true`. + // Any other usage is not allowed (e.g. calling `boot()` from an hook callback) and in that + // case we return here, giving back control to `boot()` which will throw. + $validFlows = (!$this->built && $this->statusIs(self::STATUS_IDLE)) + || ($this->built && $this->statusIs(self::STATUS_INITIALIZED)); + + if (!$validFlows) { + // If none of the two supported flows happened, we just return handling control back + // to `boot()`, that will throw. + return; + } + if (!$this->built) { - array_map([$this, 'addModule'], $defaultModules); + // First valid flow: `boot()` was called directly after `addModule()`/`connect()` + // without any call to `build()`. We can call `build()` and return, handing control + // back to `boot()`. Before returning, if we had default modules passed to `boot()` we + // already have fired a deprecation, so here we just add them dealing with back-compat. + foreach ($defaultModules as $defaultModule) { + $this->addModule($defaultModule); + } $this->build(); return; } - if ( - !$defaultModules - || ($this->status >= self::STATUS_MODULES_ADDED) - || ($this->statusIs(self::STATUS_FAILED)) - ) { - // if we don't have default modules, there's nothing to do, and if the status is beyond - // "modules added" or is failed, we do nothing as well and let `boot()` throw. + // Second valid flow: we have called `boot()` after `build()`. If we did it correctly, + // without default modules passed to `boot()`, we can just return handing control back + // to `boot()`. + if (!$defaultModules) { return; } + // If here, we have done something like: `$package->build()->boot($module1, $module2)`. + // Passing modules to `boot()` was deprecated when `build()` was introduced, so whoever + // added `build()` should have removed modules passed to `boot()`. + // But we want to keep 100% backward compatibility so we still support this behavior + // until the next major is released. To do that, we simulate IDLE status to prevent + // `addModule()` from throwing when adding default modules. + // But we can do that only if we don't have a compiled container yet. + // If anything hooking ACTION_INIT called `container()` we have a compiled container + // already, and we can't add modules, so we not going to simulate INIT status, which mean + // the `$this->addModule()` call below will throw. $backup = $this->status; - try { - // simulate idle status to prevent `addModule()` from throwing - // only if we don't have a container yet - $this->hasContainer or $this->status = self::STATUS_IDLE; - + if (!$this->hasContainer()) { + $this->status = self::STATUS_IDLE; + } foreach ($defaultModules as $defaultModule) { - // If a module was added by `build()` or `addModule()` we can skip it, a - // deprecation was trigger to make it noticeable without breakage + // If a module was already added via `addModule()` we can skip it, reducing the + // chances of throwing an exception if not needed. if (!$this->moduleIs($defaultModule->id(), self::MODULE_ADDED)) { $this->addModule($defaultModule); } } } finally { - $this->status = $backup; + if (!$this->hasFailed()) { + $this->status = $backup; + } } } @@ -482,7 +506,9 @@ class Package */ private function addModuleServices(Module $module, string $status): bool { + /** @var null|array $services */ $services = null; + /** @var null|callable(string, Service|ExtendingService): void $addCallback */ $addCallback = null; switch ($status) { case self::MODULE_REGISTERED: @@ -499,21 +525,16 @@ class Package break; } - if (!$services) { + if (($services === null) || ($services === []) || ($addCallback === null)) { return false; } $ids = []; - array_walk( - $services, - static function (callable $service, string $id) use ($addCallback, &$ids) { - /** @var callable(string, callable) $addCallback */ - $addCallback($id, $service); - /** @var list $ids */ - $ids[] = $id; - } - ); - /** @var list $ids */ + foreach ($services as $id => $service) { + $addCallback($id, $service); + $ids[] = $id; + } + $this->moduleProgress($module->id(), $status, $ids); return true; @@ -521,8 +542,6 @@ class Package /** * @return void - * - * @throws \Throwable */ private function doExecute(): void { @@ -530,9 +549,7 @@ class Package $success = $executable->run($this->container()); $this->moduleProgress( $executable->id(), - $success - ? self::MODULE_EXECUTED - : self::MODULE_EXECUTION_FAILED + $success ? self::MODULE_EXECUTED : self::MODULE_EXECUTION_FAILED, ); } } @@ -541,15 +558,20 @@ class Package * @param string $moduleId * @param string $status * @param list|null $serviceIds - * - * @return void + * @return void */ - private function moduleProgress(string $moduleId, string $status, ?array $serviceIds = null) - { - isset($this->moduleStatus[$status]) or $this->moduleStatus[$status] = []; + private function moduleProgress( + string $moduleId, + string $status, + ?array $serviceIds = null + ): void { + + if (!isset($this->moduleStatus[$status])) { + $this->moduleStatus[$status] = []; + } $this->moduleStatus[$status][] = $moduleId; - if (!$serviceIds || !$this->properties->isDebug()) { + if (($serviceIds === null) || ($serviceIds === []) || !$this->properties->isDebug()) { $this->moduleStatus[self::MODULES_ALL][] = "{$moduleId} {$status}"; return; @@ -605,10 +627,9 @@ class Package * `inpsyde.modularity.my-plugin` anyway, so the file name is not relevant. * * @param string $suffix - * * @return string - * @see Package::name() * + * @see Package::name() */ public function hookName(string $suffix = ''): string { @@ -631,8 +652,6 @@ class Package /** * @return ContainerInterface - * - * @throws \Exception */ public function container(): ContainerInterface { @@ -642,6 +661,14 @@ class Package return $this->containerConfigurator->createReadOnlyContainer(); } + /** + * @return bool + */ + public function hasContainer(): bool + { + return $this->hasContainer; + } + /** * @return string */ @@ -652,27 +679,94 @@ class Package /** * @param int $status - */ - private function progress(int $status): void - { - $this->status = $status; - } - - /** - * @param int $status - * * @return bool */ public function statusIs(int $status): bool { - return $this->status === $status; + return $this->checkStatus($status); + } + + /** + * @return bool + */ + public function hasFailed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + /** + * @param int $status + * @return bool + */ + public function hasReachedStatus(int $status): bool + { + if ($this->hasFailed()) { + return false; + } + + return isset(self::SUCCESS_STATUSES[$status]) && $this->checkStatus($status, '>='); + } + + /** + * @param int $status + * @param value-of $operator + * @return bool + */ + private function checkStatus(int $status, string $operator = '=='): bool + { + assert(isset(self::OPERATORS[$operator])); + + return version_compare((string) $this->status, (string) $status, $operator); + } + + /** + * @param Package::STATUS_* $status + */ + private function progress(int $status): void + { + $this->status = $status; + + [$packageHookSuffix, $globalHook] = self::STATUSES_ACTIONS_MAP[$status] ?? [null, null]; + if ($packageHookSuffix !== null) { + do_action($this->hookName($packageHookSuffix), $this); + } + if ($globalHook !== null) { + do_action($globalHook, $this->name(), $this); + } + } + + /** + * @param string $packageName + * @param string $reason + * @param bool $throw + * @return bool + */ + private function handleConnectionFailure(string $packageName, string $reason, bool $throw): bool + { + $errorData = ['package' => $packageName, 'status' => $this->status]; + $message = "Failed connecting package {$packageName} because {$reason}."; + + do_action( + $this->hookName(self::ACTION_FAILED_CONNECT), + $packageName, + new \WP_Error('failed_connection', $message, $errorData) + ); + + if ($throw) { + throw new \Exception( + esc_html($message), + 0, + $this->lastError // phpcs:ignore WordPress.Security.EscapeOutput + ); + } + + return false; } /** * @param \Throwable $throwable * @param Package::ACTION_FAILED_* $action * @return void - * @throws \Throwable */ private function handleFailure(\Throwable $throwable, string $action): void { @@ -690,18 +784,15 @@ class Package /** * @param int $status * @param string $action - * @param string $operator - * - * @throws \Exception - * @psalm-suppress ArgumentTypeCoercion + * @param value-of $operator */ private function assertStatus(int $status, string $action, string $operator = '=='): void { - if (!version_compare((string) $this->status, (string) $status, $operator)) { + if (!$this->checkStatus($status, $operator)) { throw new \Exception( - sprintf("Can't %s at this point of application.", $action), + sprintf("Can't %s at this point of application.", esc_html($action)), 0, - $this->lastError + $this->lastError // phpcs:ignore WordPress.Security.EscapeOutput ); } } @@ -713,7 +804,6 @@ class Package * @param string $message * @param string $function * @param string $version - * * @return void */ private function deprecatedArgument(string $message, string $function, string $version): void @@ -721,7 +811,9 @@ class Package do_action('deprecated_argument_run', $function, $message, $version); if (apply_filters('deprecated_argument_trigger_error', true)) { - trigger_error($message, \E_USER_DEPRECATED); + do_action('wp_trigger_error_run', $function, $message, \E_USER_DEPRECATED); + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + trigger_error(esc_html($message), \E_USER_DEPRECATED); } } } diff --git a/lib/packages/Inpsyde/Modularity/Properties/BaseProperties.php b/lib/packages/Inpsyde/Modularity/Properties/BaseProperties.php index 47bb4db74..2df5ae729 100644 --- a/lib/packages/Inpsyde/Modularity/Properties/BaseProperties.php +++ b/lib/packages/Inpsyde/Modularity/Properties/BaseProperties.php @@ -6,47 +6,30 @@ namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties; class BaseProperties implements Properties { - /** - * @var null|bool - */ - protected $isDebug = null; - - /** - * @var string - */ - protected $baseName; - - /** - * @var string - */ - protected $basePath; - - /** - * @var string|null - */ - protected $baseUrl; - - /** - * @var array - */ - protected $properties; + protected ?bool $isDebug = null; + protected string $baseName; + protected string $basePath; + protected ?string $baseUrl; + /** @var array */ + protected array $properties; /** * @param string $baseName * @param string $basePath * @param string|null $baseUrl - * @param array $properties + * @param array $properties */ protected function __construct( string $baseName, string $basePath, - string $baseUrl = null, + ?string $baseUrl = null, array $properties = [] ) { + $baseName = $this->sanitizeBaseName($baseName); - $basePath = (string) trailingslashit($basePath); - if ($baseUrl) { - $baseUrl = (string) trailingslashit($baseUrl); + $basePath = trailingslashit($basePath); + if ($baseUrl !== null) { + $baseUrl = trailingslashit($baseUrl); } $this->baseName = $baseName; @@ -58,11 +41,13 @@ class BaseProperties implements Properties /** * @param string $name * - * @return string + * @return lowercase-string */ protected function sanitizeBaseName(string $name): string { - substr_count($name, '/') and $name = dirname($name); + if (substr_count($name, '/')) { + $name = dirname($name); + } return strtolower(pathinfo($name, PATHINFO_FILENAME)); } @@ -162,7 +147,9 @@ class BaseProperties implements Properties { $value = $this->get(self::PROP_REQUIRES_WP); - return $value && is_string($value) ? $value : null; + return (($value !== '') && is_string($value)) + ? $value + : null; } /** @@ -172,11 +159,13 @@ class BaseProperties implements Properties { $value = $this->get(self::PROP_REQUIRES_PHP); - return $value && is_string($value) ? $value : null; + return (($value !== '') && is_string($value)) + ? $value + : null; } /** - * @return array + * @return string[] */ public function tags(): array { @@ -185,7 +174,8 @@ class BaseProperties implements Properties /** * @param string $key - * @param null $default + * @param mixed $default + * * @return mixed */ public function get(string $key, $default = null) @@ -195,6 +185,7 @@ class BaseProperties implements Properties /** * @param string $key + * * @return bool */ public function has(string $key): bool diff --git a/lib/packages/Inpsyde/Modularity/Properties/LibraryProperties.php b/lib/packages/Inpsyde/Modularity/Properties/LibraryProperties.php index 5c06577e1..612787e0f 100644 --- a/lib/packages/Inpsyde/Modularity/Properties/LibraryProperties.php +++ b/lib/packages/Inpsyde/Modularity/Properties/LibraryProperties.php @@ -5,17 +5,26 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties; /** - * Class LibraryProperties - * - * @package WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties + * @phpstan-type ComposerAuthor array{ + * name: string, + * email?: string, + * homepage?: string, + * role?: string, + * } + * @phpstan-type ComposerData array{ + * name: string, + * version?: string, + * require?: array, + * require-dev?: array, + * description?: string, + * keywords?: string[], + * authors?: ComposerAuthor[], + * extra?: array{modularity?: array}, + * } */ class LibraryProperties extends BaseProperties { - /** - * Allowed configuration in composer.json "extra.modularity". - * - * @var array - */ + /** Allowed configuration in composer.json "extra.modularity" */ public const EXTRA_KEYS = [ self::PROP_DOMAIN_PATH, self::PROP_NAME, @@ -31,32 +40,33 @@ class LibraryProperties extends BaseProperties * * @return LibraryProperties * - * @throws \Exception - * @psalm-suppress MixedArrayAccess + * phpcs:disable SlevomatCodingStandard.Complexity */ public static function new(string $composerJsonFile, ?string $baseUrl = null): LibraryProperties { - if (!\is_file($composerJsonFile) || !\is_readable($composerJsonFile)) { - throw new \Exception("File {$composerJsonFile} does not exist or is not readable."); - } + // phpcs:enable SlevomatCodingStandard.Complexity - $content = (string) file_get_contents($composerJsonFile); - /** @var array $composerJsonData */ - $composerJsonData = json_decode($content, true); + $composerJsonData = self::readComposerJsonData($composerJsonFile); $properties = Properties::DEFAULT_PROPERTIES; $properties[self::PROP_DESCRIPTION] = $composerJsonData['description'] ?? ''; $properties[self::PROP_TAGS] = $composerJsonData['keywords'] ?? []; $authors = $composerJsonData['authors'] ?? []; + if (!is_array($authors)) { + $authors = []; + } $names = []; - foreach ((array) $authors as $author) { - $name = $author['name'] ?? null; - if ($name && is_string($name)) { + foreach ($authors as $author) { + if (!is_array($author)) { + continue; + } + $name = $author['name'] ?? ''; + if (($name !== '') && is_string($name)) { $names[] = $name; } - $url = $author['homepage'] ?? null; - if ($url && !$properties['authorUri'] && is_string($url)) { + $url = $author['homepage'] ?? ''; + if (($url !== '') && ($properties[self::PROP_AUTHOR_URI] === '') && is_string($url)) { $properties[self::PROP_AUTHOR_URI] = $url; } } @@ -66,6 +76,9 @@ class LibraryProperties extends BaseProperties // Custom settings which can be stored in composer.json "extra.modularity" $extra = $composerJsonData['extra']['modularity'] ?? []; + if (!is_array($extra)) { + $extra = []; + } foreach (self::EXTRA_KEYS as $key) { $properties[$key] = $extra[$key] ?? ''; } @@ -74,39 +87,50 @@ class LibraryProperties extends BaseProperties $properties[self::PROP_REQUIRES_PHP] = self::extractPhpVersion($composerJsonData); // composer.json might have "version" in root - $version = $composerJsonData['version'] ?? null; - if ($version && is_string($version)) { + $version = $composerJsonData['version'] ?? ''; + if (($version !== '') && is_string($version)) { $properties[self::PROP_VERSION] = $version; } [$baseName, $name] = static::buildNames($composerJsonData); $basePath = dirname($composerJsonFile); - if (empty($properties[self::PROP_NAME])) { + if (($properties[self::PROP_NAME] === '') || !is_string($properties[self::PROP_NAME])) { $properties[self::PROP_NAME] = $name; } - return new self( - $baseName, - $basePath, - $baseUrl, - $properties - ); + return new self($baseName, $basePath, $baseUrl, $properties); } /** - * @param array $composerJsonData + * @param string $url + * + * @return static + */ + public function withBaseUrl(string $url): LibraryProperties + { + if ($this->baseUrl !== null) { + throw new \Exception(sprintf('%s::$baseUrl property is not overridable.', __CLASS__)); + } + + $this->baseUrl = trailingslashit($url); + + return $this; + } + + /** + * @param ComposerData $composerJsonData * * @return array{string, string} */ - private static function buildNames(array $composerJsonData): array + protected static function buildNames(array $composerJsonData): array { $composerName = (string) ($composerJsonData['name'] ?? ''); $packageNamePieces = explode('/', $composerName, 2); $basename = implode('-', $packageNamePieces); - // "inpsyde/foo-bar-baz" => "Inpsyde Foo Bar Baz" + // From "syde/foo-bar-baz" to "Syde Foo Bar Baz" $name = mb_convert_case( str_replace(['-', '_', '.'], ' ', implode(' ', $packageNamePieces)), - MB_CASE_TITLE + MB_CASE_TITLE, ); return [$basename, $name]; @@ -122,88 +146,111 @@ class LibraryProperties extends BaseProperties * `5.6 || >= 7.1` returns `5.6` * `>= 7.1 < 8` returns `7.1` * - * @param array $composerData + * @param ComposerData $composerData * @param string $key * - * @return string|null + * @return string */ - private static function extractPhpVersion(array $composerData, string $key = 'require'): ?string - { + protected static function extractPhpVersion( + array $composerData, + string $key = 'require' + ): string { + $nextKey = ($key === 'require') ? 'require-dev' : null; - $base = (array) ($composerData[$key] ?? []); - $requirement = $base['php'] ?? null; - $version = ($requirement && is_string($requirement)) + $base = $composerData[$key] ?? null; + $requirement = is_array($base) + ? ($base['php'] ?? '') + : ''; + $version = (($requirement !== '') && is_string($requirement)) ? trim($requirement) - : null; - if (!$version) { - return $nextKey + : ''; + if ($version === '') { + return ($nextKey !== null) ? static::extractPhpVersion($composerData, $nextKey) - : null; + : ''; } - static $matcher; - $matcher or $matcher = static function (string $version): ?string { - $version = trim($version); - if (!$version) { - return null; - } - - // versions range like `>= 7.2.4 < 8` - if (preg_match('{>=?([\s0-9\.]+)<}', $version, $matches)) { - return trim($matches[1], " \t\n\r\0\x0B."); - } - - // aliases like `dev-src#abcde as 7.4` - if (preg_match('{as\s*([\s0-9\.]+)}', $version, $matches)) { - return trim($matches[1], " \t\n\r\0\x0B."); - } - - // Basic requirements like 7.2, >=7.2, ^7.2, ~7.2 - if (preg_match('{^(?:[>=\s~\^]+)?([0-9\.]+)}', $version, $matches)) { - return trim($matches[1], " \t\n\r\0\x0B."); - } - - return null; - }; - // support for simpler requirements like `7.3`, `>=7.4` or alternative like `5.6 || >=7` $alternatives = explode('||', $version); + /** @var non-empty-string|null $found */ $found = null; foreach ($alternatives as $alternative) { - /** @var callable(string):?string $matcher */ - $itemFound = $matcher($alternative); - if ($itemFound && (!$found || version_compare($itemFound, $found, '<'))) { + $itemFound = static::parseVersion($alternative); + if ( + ($itemFound !== '') + && (($found === null) || version_compare($itemFound, $found, '<')) + ) { $found = $itemFound; } } - if ($found) { + if ($found !== null) { return $found; } - return $nextKey + return ($nextKey !== null) ? static::extractPhpVersion($composerData, $nextKey) - : null; + : ''; } /** - * @param string $url + * @param string $version * - * @return static - * - * @throws \Exception + * @return string */ - public function withBaseUrl(string $url): LibraryProperties + protected static function parseVersion(string $version): string { - if ($this->baseUrl !== null) { - throw new \Exception(sprintf('%s::$baseUrl property is not overridable.', __CLASS__)); + $version = trim($version); + if ($version === '') { + return ''; } - $this->baseUrl = trailingslashit($url); + // versions range like `>= 7.2.4 < 8` + if (preg_match('{>=?([\s0-9\.]+)<}', $version, $matches)) { + return trim($matches[1], " \t\n\r\0\x0B."); + } - return $this; + // aliases like `dev-src#abcde as 7.4` + if (preg_match('{as\s*([\s0-9\.]+)}', $version, $matches)) { + return trim($matches[1], " \t\n\r\0\x0B."); + } + + // Basic requirements like 7.2, >=7.2, ^7.2, ~7.2 + if (preg_match('{^(?:[>=\s~\^]+)?([0-9\.]+)}', $version, $matches)) { + return trim($matches[1], " \t\n\r\0\x0B."); + } + + return ''; + } + + /** + * @param string $composerJsonFile + * + * @return ComposerData + * @throws \Exception + */ + private static function readComposerJsonData(string $composerJsonFile): array + { + if (!\is_file($composerJsonFile) || !\is_readable($composerJsonFile)) { + throw new \Exception( + esc_html("File {$composerJsonFile} does not exist or is not readable."), + ); + } + + $content = (string) file_get_contents($composerJsonFile); + + /** @var ComposerData $composerJsonData */ + $composerJsonData = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception( + esc_html("Error reading file {$composerJsonFile}: " . json_last_error_msg()), + ); + } + + return $composerJsonData; } } diff --git a/lib/packages/Inpsyde/Modularity/Properties/PluginProperties.php b/lib/packages/Inpsyde/Modularity/Properties/PluginProperties.php index f3d3f5485..2ae20c806 100644 --- a/lib/packages/Inpsyde/Modularity/Properties/PluginProperties.php +++ b/lib/packages/Inpsyde/Modularity/Properties/PluginProperties.php @@ -4,24 +4,14 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties; -/** - * Class PluginProperties - * - * @package WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties - * - * @psalm-suppress PossiblyFalseArgument, InvalidArgument - */ class PluginProperties extends BaseProperties { - /** - * Custom properties for Plugins. - */ + // Custom properties for Plugins public const PROP_NETWORK = 'network'; + public const PROP_REQUIRES_PLUGINS = 'requiresPlugins'; + /** - * Available methods of Properties::__call() - * from plugin headers. - * - * @link https://developer.wordpress.org/reference/functions/get_plugin_data/ + * @see https://developer.wordpress.org/reference/functions/get_plugin_data/ */ protected const HEADERS = [ self::PROP_AUTHOR => 'Author', @@ -37,36 +27,17 @@ class PluginProperties extends BaseProperties // additional headers self::PROP_NETWORK => 'Network', + self::PROP_REQUIRES_PLUGINS => 'RequiresPlugins', ]; - /** - * @var string - */ - private $pluginMainFile; - - /** - * @var string - */ - private $pluginBaseName; - - /** - * @var bool|null - */ - protected $isMu; - - /** - * @var bool|null - */ - protected $isActive; - - /** - * @var bool|null - */ - protected $isNetworkActive; + private string $pluginMainFile; + private string $pluginBaseName; + protected ?bool $isMu = null; + protected ?bool $isActive = null; + protected ?bool $isNetworkActive = null; /** * @param string $pluginMainFile - * * @return PluginProperties */ public static function new(string $pluginMainFile): PluginProperties @@ -75,8 +46,6 @@ class PluginProperties extends BaseProperties } /** - * PluginProperties constructor. - * * @param string $pluginMainFile */ protected function __construct(string $pluginMainFile) @@ -85,7 +54,11 @@ class PluginProperties extends BaseProperties require_once ABSPATH . 'wp-admin/includes/plugin.php'; } - $pluginData = get_plugin_data($pluginMainFile); + // $markup = false, to avoid an incorrect early wptexturize call. + // $translate = false, to avoid loading translations too early + // @see https://core.trac.wordpress.org/ticket/49965 + // @see https://core.trac.wordpress.org/ticket/34114 + $pluginData = (array) get_plugin_data($pluginMainFile, false, false); $properties = Properties::DEFAULT_PROPERTIES; // Map pluginData to internal structure. @@ -93,6 +66,7 @@ class PluginProperties extends BaseProperties $properties[$key] = $pluginData[$pluginDataKey] ?? ''; unset($pluginData[$pluginDataKey]); } + /** @var array $properties */ $properties = array_merge($properties, $pluginData); $this->pluginMainFile = wp_normalize_path($pluginMainFile); @@ -119,14 +93,22 @@ class PluginProperties extends BaseProperties /** * @return bool - * - * @psalm-suppress PossiblyFalseArgument */ public function network(): bool { return (bool) $this->get(self::PROP_NETWORK, false); } + /** + * @return string[] + */ + public function requiresPlugins(): array + { + $value = $this->get(self::PROP_REQUIRES_PLUGINS); + + return $value && is_string($value) ? explode(',', $value) : []; + } + /** * @return bool */ @@ -163,10 +145,6 @@ class PluginProperties extends BaseProperties public function isMuPlugin(): bool { if ($this->isMu === null) { - /** - * @psalm-suppress UndefinedConstant - * @psalm-suppress MixedArgument - */ $muPluginDir = wp_normalize_path(WPMU_PLUGIN_DIR); $this->isMu = strpos($this->pluginMainFile, $muPluginDir) === 0; } diff --git a/lib/packages/Inpsyde/Modularity/Properties/Properties.php b/lib/packages/Inpsyde/Modularity/Properties/Properties.php index 8663d6883..df44fe994 100644 --- a/lib/packages/Inpsyde/Modularity/Properties/Properties.php +++ b/lib/packages/Inpsyde/Modularity/Properties/Properties.php @@ -17,9 +17,7 @@ interface Properties public const PROP_REQUIRES_WP = 'requiresWp'; public const PROP_REQUIRES_PHP = 'requiresPhp'; public const PROP_TAGS = 'tags'; - /** - * @var array - */ + public const DEFAULT_PROPERTIES = [ self::PROP_AUTHOR => '', self::PROP_AUTHOR_URI => '', @@ -36,15 +34,13 @@ interface Properties /** * @param string $key - * @param null $default - * + * @param mixed $default * @return mixed */ public function get(string $key, $default = null); /** * @param string $key - * * @return bool */ public function has(string $key): bool; @@ -103,6 +99,7 @@ interface Properties /** * The home page of the plugin, theme or library. + * * @return string */ public function uri(): string; @@ -122,7 +119,7 @@ interface Properties /** * Optional. Specify the minimum required PHP version. * - * @return string + * @return string|null */ public function requiresPhp(): ?string; @@ -130,10 +127,10 @@ interface Properties * Optional. Currently, only available for Theme and Library. * Plugins do not have support for "tags"/"keywords" in header. * - * @link https://developer.wordpress.org/reference/classes/wp_theme/#properties - * @link https://getcomposer.org/doc/04-schema.md#keywords + * @return string[] * - * @return array + * @see https://developer.wordpress.org/reference/classes/wp_theme/#properties + * @see https://getcomposer.org/doc/04-schema.md#keywords */ public function tags(): array; } diff --git a/lib/packages/Inpsyde/Modularity/Properties/ThemeProperties.php b/lib/packages/Inpsyde/Modularity/Properties/ThemeProperties.php index 24a464caa..353e9ac9a 100644 --- a/lib/packages/Inpsyde/Modularity/Properties/ThemeProperties.php +++ b/lib/packages/Inpsyde/Modularity/Properties/ThemeProperties.php @@ -4,26 +4,12 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties; -/** - * Class ThemeProperties - * - * @package WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties - * - * @psalm-suppress PossiblyFalseArgument, InvalidArgument - */ class ThemeProperties extends BaseProperties { - /** - * Additional properties specific for themes. - */ public const PROP_STATUS = 'status'; public const PROP_TEMPLATE = 'template'; - /** - * Available methods of Properties::__call() - * from theme headers. - * - * @link https://developer.wordpress.org/reference/classes/wp_theme/ - */ + + /** @see https://developer.wordpress.org/reference/classes/wp_theme/ */ protected const HEADERS = [ self::PROP_AUTHOR => 'Author', self::PROP_AUTHOR_URI => 'AuthorURI', @@ -53,8 +39,6 @@ class ThemeProperties extends BaseProperties } /** - * ThemeProperties constructor. - * * @param string $themeDirectory */ protected function __construct(string $themeDirectory) @@ -67,13 +51,15 @@ class ThemeProperties extends BaseProperties $properties = Properties::DEFAULT_PROPERTIES; foreach (self::HEADERS as $key => $themeKey) { - /** @psalm-suppress DocblockTypeContradiction */ - $properties[$key] = $theme->get($themeKey) ?? ''; + $property = $theme->get($themeKey); + if (is_string($property) || is_array($property)) { + $properties[$key] = $property; + } } $baseName = $theme->get_stylesheet(); $basePath = $theme->get_stylesheet_directory(); - $baseUrl = (string) trailingslashit($theme->get_stylesheet_directory_uri()); + $baseUrl = trailingslashit($theme->get_stylesheet_directory_uri()); parent::__construct( $baseName, @@ -84,8 +70,6 @@ class ThemeProperties extends BaseProperties } /** - * If the theme is published. - * * @return string */ public function status(): string @@ -93,6 +77,9 @@ class ThemeProperties extends BaseProperties return (string) $this->get(self::PROP_STATUS); } + /** + * @return string + */ public function template(): string { return (string) $this->get(self::PROP_TEMPLATE); @@ -120,7 +107,7 @@ class ThemeProperties extends BaseProperties public function parentThemeProperties(): ?ThemeProperties { $template = $this->template(); - if (!$template) { + if ($template === '') { return null; } diff --git a/lib/packages/Psr/Container/ContainerExceptionInterface.php b/lib/packages/Psr/Container/ContainerExceptionInterface.php index 78ee05e4a..1215883b7 100644 --- a/lib/packages/Psr/Container/ContainerExceptionInterface.php +++ b/lib/packages/Psr/Container/ContainerExceptionInterface.php @@ -2,9 +2,11 @@ namespace WooCommerce\PayPalCommerce\Vendor\Psr\Container; +use Throwable; + /** * Base interface representing a generic exception in a container. */ -interface ContainerExceptionInterface +interface ContainerExceptionInterface extends Throwable { } diff --git a/src/services.php b/src/services.php index 562d448be..4d076fb78 100644 --- a/src/services.php +++ b/src/services.php @@ -10,10 +10,10 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce; use Dhii\Versions\StringVersionFactory; -use Inpsyde\Modularity\Properties\Properties; use WooCommerce\PayPalCommerce\Http\RedirectorInterface; use WooCommerce\PayPalCommerce\Http\WpRedirector; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Package; +use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties\Properties; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WpOop\WordPress\Plugin\PluginInterface;