<?php declare(strict_types=1); 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\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; class Package { /** * All the hooks fired in this class use this prefix. * @var string */ private const HOOK_PREFIX = 'inpsyde.modularity.'; /** * Identifier to access Properties in Container. * * @example * <code> * $package = Package::new(); * $package->boot(); * * $container = $package->container(); * $container->has(Package::PROPERTIES); * $container->get(Package::PROPERTIES); * </code> * * @var string */ public const PROPERTIES = 'properties'; /** * Custom action to be used to add Modules to the package. * It might also be used to access package properties. * * @example * <code> * $package = Package::new(); * * add_action( * $package->hookName(Package::ACTION_INIT), * $callback * ); * </code> */ public const ACTION_INIT = 'init'; /** * Custom action which is triggered after the application * is booted to access container and properties. * * @example * <code> * $package = Package::new(); * * add_action( * $package->hookName(Package::ACTION_READY), * $callback * ); * </code> */ public const ACTION_READY = 'ready'; /** * Custom action which is triggered when a failure happens during the building stage. * * @example * <code> * $package = Package::new(); * * add_action( * $package->hookName(Package::ACTION_FAILED_BUILD), * $callback * ); * </code> */ public const ACTION_FAILED_BUILD = 'failed-build'; /** * Custom action which is triggered when a failure happens during the booting stage. * * @example * <code> * $package = Package::new(); * * add_action( * $package->hookName(Package::ACTION_FAILED_BOOT), * $callback * ); * </code> */ public const ACTION_FAILED_BOOT = 'failed-boot'; /** * Custom action which is triggered when a package is connected. */ public const ACTION_PACKAGE_CONNECTED = 'package-connected'; /** * Custom action which is triggered when a package cannot be connected. */ public const ACTION_FAILED_CONNECTION = 'failed-connection'; /** * Module states can be used to get information about your module. * * @example * <code> * $package = Package::new(); * $package->moduleIs(SomeModule::class, Package::MODULE_ADDED); // false * $package->boot(new SomeModule()); * $package->moduleIs(SomeModule::class, Package::MODULE_ADDED); // true * </code> */ public const MODULE_ADDED = 'added'; public const MODULE_NOT_ADDED = 'not-added'; public const MODULE_REGISTERED = 'registered'; public const MODULE_REGISTERED_FACTORIES = 'registered-factories'; public const MODULE_EXTENDED = 'extended'; public const MODULE_EXECUTED = 'executed'; public const MODULE_EXECUTION_FAILED = 'executed-failed'; public const MODULES_ALL = '*'; /** * Custom states for the class. * * @example * <code> * $package = Package::new(); * $package->statusIs(Package::IDLE); // true * $package->boot(); * $package->statusIs(Package::BOOTED); // true * </code> */ public const STATUS_IDLE = 2; public const STATUS_INITIALIZED = 4; public const STATUS_MODULES_ADDED = 5; public const STATUS_READY = 7; public const STATUS_BOOTED = 8; public const STATUS_FAILED = -8; /** * Current state of the application. * * @see Package::STATUS_* * * @var int */ private $status = self::STATUS_IDLE; /** * Contains the progress of all modules. * * @see Package::moduleProgress() * * @var array<string, list<string>> */ private $moduleStatus = [self::MODULES_ALL => []]; /** * Hashmap of where keys are names of connected packages, and values are boolean, true * if connection was successful. * * @see Package::connect() * * @var array<string, bool> */ private $connectedPackages = []; /** * @var list<ExecutableModule> */ private $executables = []; /** * @var Properties */ private $properties; /** * @var ContainerConfigurator */ private $containerConfigurator; /** * @var bool */ private $built = false; /** * @var bool */ private $hasContainer = false; /** * @var \Throwable|null */ private $lastError = null; /** * @param Properties $properties * @param ContainerInterface[] $containers * * @return Package */ public static function new(Properties $properties, ContainerInterface ...$containers): Package { return new self($properties, ...$containers); } /** * @param Properties $properties * @param ContainerInterface[] $containers */ private function __construct(Properties $properties, ContainerInterface ...$containers) { $this->properties = $properties; $this->containerConfigurator = new ContainerConfigurator($containers); $this->containerConfigurator->addService( self::PROPERTIES, static function () use ($properties) { return $properties; } ); } /** * @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())); $registeredServices = $this->addModuleServices( $module, self::MODULE_REGISTERED ); $registeredFactories = $this->addModuleServices( $module, self::MODULE_REGISTERED_FACTORIES ); $extended = $this->addModuleServices( $module, self::MODULE_EXTENDED ); $isExecutable = $module instanceof ExecutableModule; // ExecutableModules are collected and executed on Package::boot() // when the Container is being compiled. if ($isExecutable) { /** @var ExecutableModule $module */ $this->executables[] = $module; } $added = $registeredServices || $registeredFactories || $extended || $isExecutable; $status = $added ? self::MODULE_ADDED : self::MODULE_NOT_ADDED; $this->moduleProgress($module->id(), $status); } catch (\Throwable $throwable) { $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); } return $this; } /** * @param Package $package * @return bool * @throws \Exception */ public function connect(Package $package): bool { try { if ($package === $this) { return false; } $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); } // 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); } $this->connectedPackages[$packageName] = true; // We put connected package's properties in this package's container, so that in modules // "run" method we can access them if we need to. $this->containerConfigurator->addService( sprintf('%s.%s', $package->name(), self::PROPERTIES), static function () use ($package): Properties { return $package->properties(); } ); // If the other package is booted, we can obtain a container, otherwise // we build a proxy container $container = $package->statusIs(self::STATUS_BOOTED) ? $package->container() : new PackageProxyContainer($package); $this->containerConfigurator->addContainer($container); do_action( $this->hookName(self::ACTION_PACKAGE_CONNECTED), $packageName, $this->status, $container instanceof PackageProxyContainer ); return true; } catch (\Throwable $throwable) { if (isset($packageName)) { $this->connectedPackages[$packageName] = false; } $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); return false; } } /** * @return static */ public function build(): Package { try { // Don't allow building the application multiple times. $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->progress(self::STATUS_INITIALIZED); } catch (\Throwable $throwable) { $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); } finally { $this->built = true; } return $this; } /** * @param Module ...$defaultModules Deprecated, use `addModule()` to add default modules. * @return bool * * @throws \Throwable */ public function boot(Module ...$defaultModules): bool { try { // 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', '!='); $this->progress(self::STATUS_MODULES_ADDED); $this->doExecute(); $this->progress(self::STATUS_READY); do_action( $this->hookName(self::ACTION_READY), $this ); } catch (\Throwable $throwable) { $this->handleFailure($throwable, self::ACTION_FAILED_BOOT); return false; } $this->progress(self::STATUS_BOOTED); return true; } /** * @param Module ...$defaultModules * @return void */ private function doBuild(Module ...$defaultModules): void { if ($defaultModules) { $this->deprecatedArgument( sprintf( 'Passing default modules to %1$s::boot() is deprecated since version 1.7.0.' . ' Please add modules via %1$s::addModule().', __CLASS__ ), __METHOD__, '1.7.0' ); } if (!$this->built) { array_map([$this, 'addModule'], $defaultModules); $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. return; } $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; 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 (!$this->moduleIs($defaultModule->id(), self::MODULE_ADDED)) { $this->addModule($defaultModule); } } } finally { $this->status = $backup; } } /** * @param Module $module * @param string $status * @return bool */ private function addModuleServices(Module $module, string $status): bool { $services = null; $addCallback = null; switch ($status) { case self::MODULE_REGISTERED: $services = $module instanceof ServiceModule ? $module->services() : null; $addCallback = [$this->containerConfigurator, 'addService']; break; case self::MODULE_REGISTERED_FACTORIES: $services = $module instanceof FactoryModule ? $module->factories() : null; $addCallback = [$this->containerConfigurator, 'addFactory']; break; case self::MODULE_EXTENDED: $services = $module instanceof ExtendingModule ? $module->extensions() : null; $addCallback = [$this->containerConfigurator, 'addExtension']; break; } if (!$services) { 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<string> $ids */ $ids[] = $id; } ); /** @var list<string> $ids */ $this->moduleProgress($module->id(), $status, $ids); return true; } /** * @return void * * @throws \Throwable */ private function doExecute(): void { foreach ($this->executables as $executable) { $success = $executable->run($this->container()); $this->moduleProgress( $executable->id(), $success ? self::MODULE_EXECUTED : self::MODULE_EXECUTION_FAILED ); } } /** * @param string $moduleId * @param string $status * @param list<string>|null $serviceIds * * @return void */ private function moduleProgress(string $moduleId, string $status, ?array $serviceIds = null) { isset($this->moduleStatus[$status]) or $this->moduleStatus[$status] = []; $this->moduleStatus[$status][] = $moduleId; if (!$serviceIds || !$this->properties->isDebug()) { $this->moduleStatus[self::MODULES_ALL][] = "{$moduleId} {$status}"; return; } $description = sprintf('%s %s (%s)', $moduleId, $status, implode(', ', $serviceIds)); $this->moduleStatus[self::MODULES_ALL][] = $description; } /** * @return array<string, list<string>> */ public function modulesStatus(): array { return $this->moduleStatus; } /** * @return array<string, bool> */ public function connectedPackages(): array { return $this->connectedPackages; } /** * @param string $packageName * @return bool */ public function isPackageConnected(string $packageName): bool { return $this->connectedPackages[$packageName] ?? false; } /** * @param string $moduleId * @param string $status * * @return bool */ public function moduleIs(string $moduleId, string $status): bool { return in_array($moduleId, $this->moduleStatus[$status] ?? [], true); } /** * Return the filter name to be used to extend modules of the plugin. * * If the plugin is single file `my-plugin.php` in plugins folder the filter name will be: * `inpsyde.modularity.my-plugin`. * * If the plugin is in a sub-folder e.g. `my-plugin/index.php` the filter name will be: * `inpsyde.modularity.my-plugin` anyway, so the file name is not relevant. * * @param string $suffix * * @return string * @see Package::name() * */ public function hookName(string $suffix = ''): string { $filter = self::HOOK_PREFIX . $this->properties->baseName(); if ($suffix) { $filter .= '.' . $suffix; } return $filter; } /** * @return Properties */ public function properties(): Properties { return $this->properties; } /** * @return ContainerInterface * * @throws \Exception */ public function container(): ContainerInterface { $this->assertStatus(self::STATUS_INITIALIZED, 'obtain the container instance', '>='); $this->hasContainer = true; return $this->containerConfigurator->createReadOnlyContainer(); } /** * @return string */ public function name(): string { return $this->properties->baseName(); } /** * @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; } /** * @param \Throwable $throwable * @param Package::ACTION_FAILED_* $action * @return void * @throws \Throwable */ private function handleFailure(\Throwable $throwable, string $action): void { $this->progress(self::STATUS_FAILED); $hook = $this->hookName($action); did_action($hook) or do_action($hook, $throwable); if ($this->properties->isDebug()) { throw $throwable; } $this->lastError = $throwable; } /** * @param int $status * @param string $action * @param string $operator * * @throws \Exception * @psalm-suppress ArgumentTypeCoercion */ private function assertStatus(int $status, string $action, string $operator = '=='): void { if (!version_compare((string) $this->status, (string) $status, $operator)) { throw new \Exception( sprintf("Can't %s at this point of application.", $action), 0, $this->lastError ); } } /** * Similar to WP's `_deprecated_argument()`, but executes regardless of WP_DEBUG and without * translated message (so without attempting loading translation files). * * @param string $message * @param string $function * @param string $version * * @return void */ private function deprecatedArgument(string $message, string $function, string $version): void { do_action('deprecated_argument_run', $function, $message, $version); if (apply_filters('deprecated_argument_trigger_error', true)) { trigger_error($message, \E_USER_DEPRECATED); } } }