* {
* "extra": {
* "zf": {
* "component": "Zend\\Form",
* "module": "ZF\\Apigility\\ContentNegotiation",
* "config-provider": "Zend\\Expressive\\PlatesRenderer\\ConfigProvider"
* }
* }
* }
*
*
* With regards to components and modules, for this to work correctly, the
* package MUST define a `Module` in the namespace listed in either the
* extra.zf.component or extra.zf.module definition.
*
* Components are added to the TOP of the modules list, to ensure that userland
* code and/or modules can override the settings. Modules are added to the
* BOTTOM of the modules list. Config providers are added to the TOP of
* configuration providers.
*
* In either case, you can edit the appropriate configuration file when
* complete to create a specific order.
*/
class ComponentInstaller implements
EventSubscriberInterface,
PluginInterface
{
/**
* Cached injectors to re-use for packages installed later in the current process.
*
* @var Injector\InjectorInterface[]
*/
private $cachedInjectors = [];
/**
* @var Composer
*/
private $composer;
/**
* @var IOInterface
*/
private $io;
/**
* Map of known package types to composer config keys.
*
* @var string[]
*/
private $packageTypes = [
Injector\InjectorInterface::TYPE_CONFIG_PROVIDER => 'config-provider',
Injector\InjectorInterface::TYPE_COMPONENT => 'component',
Injector\InjectorInterface::TYPE_MODULE => 'module',
];
/**
* Project root in which to install.
*
* @var string
*/
private $projectRoot;
/**
* Constructor
*
* Optionally accept the project root into which to install.
*
* @param string $projectRoot
*/
public function __construct($projectRoot = '')
{
if (is_string($projectRoot) && ! empty($projectRoot) && is_dir($projectRoot)) {
$this->projectRoot = $projectRoot;
}
}
/**
* Activate plugin.
*
* Sets internal pointers to Composer and IOInterface instances, and resets
* cached injector map.
*
* @param Composer $composer
* @param IOInterface $io
* @return void
*/
public function activate(Composer $composer, IOInterface $io)
{
$this->composer = $composer;
$this->io = $io;
$this->cachedInjectors = [];
}
/**
* Return list of event handlers in this class.
*
* @return string[]
*/
public static function getSubscribedEvents()
{
return [
'post-package-install' => 'onPostPackageInstall',
'post-package-uninstall' => 'onPostPackageUninstall',
];
}
/**
* post-package-install event hook.
*
* This routine exits early if any of the following conditions apply:
*
* - Executed in non-development mode
* - No config/application.config.php is available
* - The composer.json does not define one of either extra.zf.component
* or extra.zf.module
* - The value used for either extra.zf.component or extra.zf.module are
* empty or not strings.
*
* Otherwise, it will attempt to update the application configuration
* using the value(s) discovered in extra.zf.component and/or extra.zf.module,
* writing their values into the `modules` list.
*
* @param PackageEvent $event
* @return void
*/
public function onPostPackageInstall(PackageEvent $event)
{
if (! $event->isDevMode()) {
// Do nothing in production mode.
return;
}
$package = $event->getOperation()->getPackage();
$name = $package->getName();
$extra = $this->getExtraMetadata($package->getExtra());
if (empty($extra)) {
// Package does not define anything of interest; do nothing.
return;
}
$packageTypes = $this->discoverPackageTypes($extra);
$options = (new ConfigDiscovery())
->getAvailableConfigOptions($packageTypes, $this->projectRoot);
if ($options->isEmpty()) {
// No configuration options found; do nothing.
return;
}
$dependencies = $this->loadModuleClassesDependencies($package);
$applicationModules = $this->findApplicationModules();
$this->marshalInstallableModules($extra, $options)
->each(function ($module) use ($name) {
})
// Create injectors
->reduce(function ($injectors, $module) use ($options, $packageTypes, $name) {
// Get extra from root package
$rootExtra = $this->getExtraMetadata($this->composer->getPackage()->getExtra());
$whitelist = $rootExtra['component-whitelist'] ?? [];
$packageType = $packageTypes[$module];
$injectors[$module] = $this->promptForConfigOption($module, $options, $packageType, $name, $whitelist);
return $injectors;
}, new Collection([]))
// Inject modules into configuration
->each(function ($injector, $module) use ($name, $packageTypes, $applicationModules, $dependencies) {
if (isset($dependencies[$module])) {
$injector->setModuleDependencies($dependencies[$module]);
}
$injector->setApplicationModules($applicationModules);
$this->injectModuleIntoConfig($name, $module, $injector, $packageTypes[$module]);
});
}
/**
* Find all Module classes in the package and their dependencies
* via method `getModuleDependencies` of Module class.
*
* These dependencies are used later
* @see \Zend\ComponentInstaller\Injector\AbstractInjector::injectAfterDependencies
* to add component in a correct order on the module list - after dependencies.
*
* It works with PSR-0, PSR-4, 'classmap' and 'files' composer autoloading.
*
* @param PackageInterface $package
* @return array
*/
private function loadModuleClassesDependencies(PackageInterface $package)
{
$dependencies = new ArrayObject([]);
$installer = $this->composer->getInstallationManager();
$packagePath = $installer->getInstallPath($package);
$this->mapAutoloaders($package->getAutoload(), $dependencies, $packagePath);
return $dependencies->getArrayCopy();
}
/**
* Find all modules of the application.
*
* @return array
*/
private function findApplicationModules()
{
$modulePath = is_string($this->projectRoot) && ! empty($this->projectRoot)
? sprintf('%s/module', $this->projectRoot)
: 'module';
$modules = [];
if (is_dir($modulePath)) {
$directoryIterator = new DirectoryIterator($modulePath);
foreach ($directoryIterator as $file) {
if ($file->isDot() || ! $file->isDir()) {
continue;
}
$modules[] = $file->getBasename();
}
}
return $modules;
}
/**
* post-package-uninstall event hook
*
* This routine exits early if any of the following conditions apply:
*
* - Executed in non-development mode
* - No config/application.config.php is available
* - The composer.json does not define one of either extra.zf.component
* or extra.zf.module
* - The value used for either extra.zf.component or extra.zf.module are
* empty or not strings.
*
* Otherwise, it will attempt to update the application configuration
* using the value(s) discovered in extra.zf.component and/or extra.zf.module,
* removing their values from the `modules` list.
*
* @param PackageEvent $event
* @return void
*/
public function onPostPackageUninstall(PackageEvent $event)
{
if (! $event->isDevMode()) {
// Do nothing in production mode.
return;
}
$options = (new ConfigDiscovery())
->getAvailableConfigOptions(
new Collection(array_keys($this->packageTypes)),
$this->projectRoot
);
if ($options->isEmpty()) {
// No configuration options found; do nothing.
return;
}
$package = $event->getOperation()->getPackage();
$name = $package->getName();
$extra = $this->getExtraMetadata($package->getExtra());
$this->removePackageFromConfig($name, $extra, $options);
}
/**
* Retrieve the zf-specific metadata from the "extra" section
*
* @param array $extra
* @return array
*/
private function getExtraMetadata(array $extra)
{
return isset($extra['zf']) && is_array($extra['zf'])
? $extra['zf']
: []
;
}
/**
* Discover what package types are relevant based on what the package
* exposes in the extra configuration.
*
* @param string[] $extra
* @return Collection Collection of Injector\InjectorInterface::TYPE_* constants.
*/
private function discoverPackageTypes(array $extra)
{
$packageTypes = array_flip($this->packageTypes);
$knownTypes = array_keys($packageTypes);
return Collection::create($extra)
->filter(function ($packages, $type) use ($knownTypes) {
return in_array($type, $knownTypes, true);
})
->reduce(function ($discoveredTypes, $packages, $type) use ($packageTypes) {
$packages = is_array($packages) ? $packages : [$packages];
foreach ($packages as $package) {
$discoveredTypes[$package] = $packageTypes[$type];
}
return $discoveredTypes;
}, new Collection([]));
}
/**
* Marshal a collection of defined package types.
*
* @param array $extra extra.zf value
* @return Collection
*/
private function marshalPackageTypes(array $extra)
{
// Create a collection of types registered in the package.
return Collection::create($this->packageTypes)
->filter(function ($configKey, $type) use ($extra) {
return $this->metadataForKeyIsValid($configKey, $extra);
});
}
/**
* Marshal a collection of package modules.
*
* @param array $extra extra.zf value
* @param Collection $packageTypes
* @param Collection $options ConfigOption instances
* @return Collection
*/
private function marshalPackageModules(array $extra, Collection $packageTypes, Collection $options)
{
// We only want to list modules that the application can configure.
$supportedTypes = $options
->reduce(function ($allowed, $option) {
return $allowed->merge($option->getInjector()->getTypesAllowed());
}, new Collection([]))
->unique()
->toArray();
return $packageTypes
->reduce(function ($modules, $configKey, $type) use ($extra, $supportedTypes) {
if (! in_array($type, $supportedTypes, true)) {
return $modules;
}
return $modules->merge((array) $extra[$configKey]);
}, new Collection([]))
// Make sure the list is unique
->unique();
}
/**
* Prepare a list of modules to install/register with configuration.
*
* @param string[] $extra
* @param Collection $options
* @return string[] List of packages to install
*/
private function marshalInstallableModules(array $extra, Collection $options)
{
return $this->marshalPackageModules($extra, $this->marshalPackageTypes($extra), $options)
// Filter out modules that do not have a registered injector
->reject(function ($module) use ($options) {
return $options->reduce(function ($registered, $option) use ($module) {
return $registered || $option->getInjector()->isRegistered($module);
}, false);
});
}
/**
* Prompt for the user to select a configuration location to update.
*
* @param string $name
* @param Collection $options
* @param int $packageType
* @param string $packageName
* @param array $whitelist
* @return Injector\InjectorInterface
*/
private function promptForConfigOption(
string $name,
Collection $options,
int $packageType,
string $packageName,
array $whitelist
) {
if ($cachedInjector = $this->getCachedInjector($packageType)) {
return $cachedInjector;
}
// If package is whitelisted, don't ask...
if (in_array($packageName, $whitelist, true)) {
return $options[1]->getInjector();
}
// Default to first discovered option; index 0 is always "Do not inject"
$default = $options->count() > 1 ? 1 : 0;
$ask = $options->reduce(function ($ask, $option, $index) {
$ask[] = sprintf(
" [%d] %s\n",
$index,
$option->getPromptText()
);
return $ask;
}, []);
array_unshift($ask, sprintf(
"\n Please select which config file you wish to inject '%s' into:\n",
$name
));
$ask[] = sprintf(' Make your selection (default is %d):', $default);
while (true) {
$answer = $this->io->ask(implode($ask), $default);
if (is_numeric($answer) && isset($options[(int) $answer])) {
$injector = $options[(int) $answer]->getInjector();
$this->promptToRememberOption($injector, $packageType);
return $injector;
}
$this->io->write('Invalid selection');
}
}
/**
* Prompt the user to determine if the selection should be remembered for later packages.
*
* @todo Will need to store selection in filesystem and remove when all packages are complete
* @param Injector\InjectorInterface $injector
* @param int $packageType
* return void
*/
private function promptToRememberOption(Injector\InjectorInterface $injector, $packageType)
{
$ask = ["\n Remember this option for other packages of the same type? (Y/n)"];
while (true) {
$answer = strtolower($this->io->ask(implode($ask), 'y'));
switch ($answer) {
case 'y':
$this->cacheInjector($injector, $packageType);
return;
case 'n':
// intentionally fall-through
default:
return;
}
}
}
/**
* Inject a module into available configuration.
*
* @param string $package Package name
* @param string $module Module to install in configuration
* @param Injector\InjectorInterface $injector Injector to use.
* @param int $packageType
* @return void
*/
private function injectModuleIntoConfig($package, $module, Injector\InjectorInterface $injector, $packageType)
{
$this->io->write(sprintf(' Installing %s from package %s', $module, $package));
try {
if (! $injector->inject($module, $packageType)) {
$this->io->write(' Package is already registered; skipping');
}
} catch (Exception\RuntimeException $ex) {
$this->io->write(sprintf(
' %s',
$ex->getMessage()
));
}
}
/**
* Remove a package from configuration.
*
* @param string $package Package name
* @param array $metadata Metadata pulled from extra.zf
* @param Collection $configOptions Discovered configuration options from
* which to remove package.
* @return void
*/
private function removePackageFromConfig($package, array $metadata, Collection $configOptions)
{
// Create a collection of types registered in the package.
$packageTypes = $this->marshalPackageTypes($metadata);
// Create a collection of configured injectors for the package types
// registered.
$injectors = $configOptions
->map(function ($configOption) {
return $configOption->getInjector();
})
->filter(function ($injector) use ($packageTypes) {
return $packageTypes->reduce(function ($registered, $key, $type) use ($injector) {
return $registered || $injector->registersType($type);
}, false);
});
// Create a collection of unique modules based on the package types present,
// and remove each from configuration.
$this->marshalPackageModules($metadata, $packageTypes, $configOptions)
->each(function ($module) use ($package, $injectors) {
$this->removeModuleFromConfig($module, $package, $injectors);
});
}
/**
* Remove an individual module defined in a package from configuration.
*
* @param string $module Module to remove
* @param string $package Package in which module is defined
* @param Collection $injectors Injectors to use for removal
* @return void
*/
private function removeModuleFromConfig($module, $package, Collection $injectors)
{
$injectors->each(function (InjectorInterface $injector) use ($module, $package) {
$this->io->write(sprintf(' Removing %s from package %s', $module, $package));
if ($injector->remove($module)) {
$this->io->write(sprintf(
' Removed package from %s',
$this->getInjectorConfigFileName($injector)
));
}
});
}
/**
* @param InjectorInterface $injector
* @return string
* @todo remove after InjectorInterface has getConfigName defined
*/
private function getInjectorConfigFileName(InjectorInterface $injector)
{
if ($injector instanceof ConfigInjectorChain) {
return $this->getInjectorChainConfigFileName($injector);
} elseif ($injector instanceof AbstractInjector) {
return $this->getAbstractInjectorConfigFileName($injector);
}
return '';
}
/**
* @param ConfigInjectorChain $injector
* @return string
* @todo remove after InjectorInterface has getConfigName defined
*/
private function getInjectorChainConfigFileName(ConfigInjectorChain $injector)
{
return implode(', ', array_map(function ($item) {
return $this->getInjectorConfigFileName($item);
}, $injector->getCollection()->toArray()));
}
/**
* @param AbstractInjector $injector
* @return string
* @todo remove after InjectorInterface has getConfigName defined
*/
private function getAbstractInjectorConfigFileName(AbstractInjector $injector)
{
return $injector->getConfigFile();
}
/**
* Is a given module name valid?
*
* @param string $module
* @return bool
*/
private function moduleIsValid($module)
{
return is_string($module) && ! empty($module);
}
/**
* Is a given metadata value (extra.zf.*) valid?
*
* @param string $key Key to examine in metadata
* @param array $metadata
* @return bool
*/
private function metadataForKeyIsValid($key, array $metadata)
{
if (! isset($metadata[$key])) {
return false;
}
if (is_string($metadata[$key])) {
return $this->moduleIsValid($metadata[$key]);
}
if (! is_array($metadata[$key])) {
return false;
}
return Collection::create($metadata[$key])
->reduce(function ($valid, $value) {
if (false === $valid) {
return $valid;
}
return $this->moduleIsValid($value);
}, null);
}
/**
* Attempt to retrieve a cached injector for the current package type.
*
* @param int $packageType
* @return null|Injector\InjectorInterface
*/
private function getCachedInjector($packageType)
{
if (isset($this->cachedInjectors[$packageType])) {
return $this->cachedInjectors[$packageType];
}
return null;
}
/**
* Cache an injector for later use.
*
* @param Injector\InjectorInterface $injector
* @param int $packageType
* @return void
*/
private function cacheInjector(Injector\InjectorInterface $injector, $packageType)
{
$this->cachedInjectors[$packageType] = $injector;
}
/**
* Iterate through each autoloader type to find dependencies.
*
* @param array $autoload List of autoloader types and associated autoloader definitions.
* @param ArrayObject $dependencies Module dependencies defined by the module.
* @param string $packagePath Path to the package on the filesystem.
* @return void
*/
private function mapAutoloaders(array $autoload, ArrayObject $dependencies, $packagePath)
{
foreach ($autoload as $type => $map) {
$this->mapType($map, $type, $dependencies, $packagePath);
}
}
/**
* Iterate through a single autolaoder type to find dependencies.
*
* @param array $map Map of namespace => path(s) pairs.
* @param string $type Type of autoloader being iterated.
* @param ArrayObject $dependencies Module dependencies defined by the module.
* @param string $packagePath Path to the package on the filesystem.
* @return void
*/
private function mapType(array $map, $type, ArrayObject $dependencies, $packagePath)
{
foreach ($map as $namespace => $paths) {
$paths = (array) $paths;
$this->mapNamespacePaths($paths, $namespace, $type, $dependencies, $packagePath);
}
}
/**
* Iterate through the paths defined for a given namespace.
*
* @param array $paths Paths defined for the given namespace.
* @param string $namespace PHP namespace to which the paths map.
* @param string $type Type of autoloader being iterated.
* @param ArrayObject $dependencies Module dependencies defined by the module.
* @param string $packagePath Path to the package on the filesystem.
* @return void
*/
private function mapNamespacePaths(array $paths, $namespace, $type, ArrayObject $dependencies, $packagePath)
{
foreach ($paths as $path) {
$this->mapPath($path, $namespace, $type, $dependencies, $packagePath);
}
}
/**
* Find module dependencies for a given namespace for a given path.
*
* @param string $path Path to inspect.
* @param string $namespace PHP namespace to which the paths map.
* @param string $type Type of autoloader being iterated.
* @param ArrayObject $dependencies Module dependencies defined by the module.
* @param string $packagePath Path to the package on the filesystem.
* @return void
*/
private function mapPath($path, $namespace, $type, ArrayObject $dependencies, $packagePath)
{
switch ($type) {
case 'classmap':
$fullPath = sprintf('%s/%s', $packagePath, $path);
if (substr($path, -10) === 'Module.php') {
$modulePath = $fullPath;
break;
}
$modulePath = sprintf('%s/Module.php', rtrim($fullPath, '/'));
break;
case 'files':
if (substr($path, -10) !== 'Module.php') {
return;
}
$modulePath = sprintf('%s/%s', $packagePath, $path);
break;
case 'psr-0':
$modulePath = sprintf(
'%s/%s%s%s',
$packagePath,
$path,
str_replace('\\', '/', $namespace),
'Module.php'
);
break;
case 'psr-4':
$modulePath = sprintf(
'%s/%s%s',
$packagePath,
$path,
'Module.php'
);
break;
default:
return;
}
if (! file_exists($modulePath)) {
return;
}
$result = $this->getModuleDependencies($modulePath);
if (empty($result)) {
return;
}
// Mimic array + array operation in ArrayObject
$dependencies->exchangeArray($dependencies->getArrayCopy() + $result);
}
/**
* @param string $file
* @return array
*/
private function getModuleDependencies($file)
{
$content = file_get_contents($file);
if (preg_match('/namespace\s+([^\s]+)\s*;/', $content, $m)) {
$moduleName = $m[1];
// @codingStandardsIgnoreStart
$regExp = '/public\s+function\s+getModuleDependencies\s*\(\s*\)\s*{[^}]*return\s*(?:array\(|\[)([^})\]]*)(\)|\])/';
// @codingStandardsIgnoreEnd
if (preg_match($regExp, $content, $m)) {
$dependencies = array_filter(
explode(',', stripslashes(rtrim(preg_replace('/[\s"\']/', '', $m[1]), ',')))
);
if ($dependencies) {
return [$moduleName => $dependencies];
}
}
}
return [];
}
}