diff --git a/.github/workflows/plugin.yml b/.github/workflows/plugin.yml new file mode 100644 index 0000000..9624c0d --- /dev/null +++ b/.github/workflows/plugin.yml @@ -0,0 +1,36 @@ +name: Plugin +on: + push: + branches: + - master + pull_request: + +jobs: + plugin: + name: Plugin test with Composer ${{ matrix.composer }} + runs-on: ubuntu-latest + + strategy: + matrix: + include: + - composer: v1 + - composer: v2 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.1 + tools: composer:${{ matrix.composer }} + + - name: Check Plugin + run: | + mkdir /tmp/plugin + # replace the relative path for the repository url with an absolute path for composer v1 compatibility + jq '.repositories[0].url="'$(pwd)'"' tests/plugin/composer.json > /tmp/plugin/composer.json + cd /tmp/plugin + composer update + composer show http-interop/http-factory-guzzle -q diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3c592..def088b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Change Log -## 1.15.0 - 2023-01-XX +## 1.15.0 - 2023-02-09 - [#209](https://github.com/php-http/discovery/pull/209) - Add generic `Psr17Factory` class +- [#208](https://github.com/php-http/discovery/pull/208) - Add composer plugin to auto-install missing implementations. + When libraries require an http implementation but no packages providing that implementation is installed in the application, the plugin will automatically install one. + This is only done for libraries that directly require php-http/discovery to avoid unexpected dependency installation. ## 1.14.3 - 2022-07-11 diff --git a/composer.json b/composer.json index 4668fbe..486750a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "php-http/discovery", - "description": "Finds installed HTTPlug implementations and PSR-7 message factories", + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "type": "composer-plugin", "license": "MIT", "keywords": ["http", "discovery", "client", "adapter", "message", "factory", "psr7", "psr17"], "homepage": "http://php-http.org", @@ -10,19 +11,25 @@ "email": "mark.sagikazar@gmail.com" } ], + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, "require": { - "php": "^7.1 || ^8.0" + "php": "^7.1 || ^8.0", + "composer-plugin-api": "^1.0|^2.0" }, "require-dev": { + "composer/composer": "^1.0.2|^2.0", "graham-campbell/phpspec-skip-example-extension": "^5.0", "php-http/httplug": "^1.0 || ^2.0", "php-http/message-factory": "^1.0", "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", "symfony/phpunit-bridge": "^6.2" }, - "suggest": { - "php-http/message": "Allow to use Guzzle, Diactoros or Slim Framework factories" - }, "autoload": { "psr-4": { "Http\\Discovery\\": "src/" @@ -40,6 +47,9 @@ ], "test-ci": "vendor/bin/phpspec run -c phpspec.ci.yml" }, + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin" + }, "conflict": { "nyholm/psr7": "<1.0" }, diff --git a/src/Composer/Plugin.php b/src/Composer/Plugin.php new file mode 100644 index 0000000..a59cf95 --- /dev/null +++ b/src/Composer/Plugin.php @@ -0,0 +1,359 @@ + + * + * @internal + */ +class Plugin implements PluginInterface, EventSubscriberInterface +{ + /** + * Describes, for every supported virtual implementation, which packages + * provide said implementation and which extra dependencies each package + * requires to provide the implementation. + */ + private const PROVIDE_RULES = [ + 'php-http/async-client-implementation' => [ + 'symfony/http-client' => ['guzzlehttp/promises', 'php-http/message-factory', 'psr/http-factory-implementation'], + 'php-http/guzzle7-adapter' => [], + 'php-http/guzzle6-adapter' => [], + 'php-http/curl-client' => [], + 'php-http/react-adapter' => [], + ], + 'php-http/client-implementation' => [ + 'symfony/http-client' => ['php-http/message-factory', 'psr/http-factory-implementation'], + 'php-http/guzzle7-adapter' => [], + 'php-http/guzzle6-adapter' => [], + 'php-http/cakephp-adapter' => [], + 'php-http/curl-client' => [], + 'php-http/react-adapter' => [], + 'php-http/buzz-adapter' => [], + 'php-http/artax-adapter' => [], + 'kriswallsmith/buzz:^1' => [], + ], + 'psr/http-client-implementation' => [ + 'symfony/http-client' => ['psr/http-factory-implementation'], + 'guzzlehttp/guzzle' => [], + 'kriswallsmith/buzz:^1' => [], + ], + 'psr/http-message-implementation' => [ + 'psr/http-factory-implementation' => [], + ], + 'psr/http-factory-implementation' => [ + 'nyholm/psr7' => [], + 'guzzlehttp/psr7:>=2' => [], + 'slim/psr7' => [], + 'laminas/laminas-diactoros' => [], + 'phalcon/cphalcon:^4' => [], + 'zendframework/zend-diactoros:>=2' => [], + 'http-interop/http-factory-guzzle' => [], + 'http-interop/http-factory-diactoros' => [], + 'http-interop/http-factory-slim' => [], + ], + ]; + + /** + * Describes which package should be preferred on the left side + * depending on which one is already installed on the right side. + */ + private const STICKYNESS_RULES = [ + 'symfony/http-client' => 'symfony/framework-bundle', + 'php-http/guzzle7-adapter' => 'guzzlehttp/guzzle:^7', + 'php-http/guzzle6-adapter' => 'guzzlehttp/guzzle:^6', + 'php-http/guzzle5-adapter' => 'guzzlehttp/guzzle:^5', + 'php-http/cakephp-adapter' => 'cakephp/cakephp', + 'php-http/react-adapter' => 'react/event-loop', + 'php-http/buzz-adapter' => 'kriswallsmith/buzz:^0.15.1', + 'php-http/artax-adapter' => 'amphp/artax:^3', + 'http-interop/http-factory-guzzle' => 'guzzlehttp/psr7:^1', + 'http-interop/http-factory-diactoros' => 'zendframework/zend-diactoros:^1', + 'http-interop/http-factory-slim' => 'slim/slim:^3', + ]; + + public static function getSubscribedEvents(): array + { + return [ + ScriptEvents::POST_UPDATE_CMD => 'postUpdate', + ]; + } + + public function activate(Composer $composer, IOInterface $io): void + { + } + + public function deactivate(Composer $composer, IOInterface $io) + { + } + + public function uninstall(Composer $composer, IOInterface $io) + { + } + + public function postUpdate(Event $event) + { + $composer = $event->getComposer(); + $repo = $composer->getRepositoryManager()->getLocalRepository(); + $requires = [ + $composer->getPackage()->getRequires(), + $composer->getPackage()->getDevRequires(), + ]; + + $missingRequires = $this->getMissingRequires($repo, $requires, 'project' === $composer->getPackage()->getType()); + $missingRequires = [ + 'require' => array_fill_keys(array_merge([], ...array_values($missingRequires[0])), '*'), + 'require-dev' => array_fill_keys(array_merge([], ...array_values($missingRequires[1])), '*'), + 'remove' => array_fill_keys(array_merge([], ...array_values($missingRequires[2])), '*'), + ]; + + if (!$missingRequires = array_filter($missingRequires)) { + return; + } + + $composerJsonContents = file_get_contents(Factory::getComposerFile()); + $this->updateComposerJson($missingRequires, $composer->getConfig()->get('sort-packages')); + + $installer = null; + // Find the composer installer, hack borrowed from symfony/flex + foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT) as $trace) { + if (isset($trace['object']) && $trace['object'] instanceof Installer) { + $installer = $trace['object']; + break; + } + } + + if (!$installer) { + return; + } + + $event->stopPropagation(); + + $dispatcher = $composer->getEventDispatcher(); + $disableScripts = !method_exists($dispatcher, 'setRunScripts') || !((array) $dispatcher)["\0*\0runScripts"]; + $composer = Factory::create($event->getIO(), null, false, $disableScripts); + + /** @var Installer $installer */ + $installer = clone $installer; + if (method_exists($installer, 'setAudit')) { + $trace['object']->setAudit(false); + } + // we need a clone of the installer to preserve its configuration state but with our own service objects + $installer->__construct( + $event->getIO(), + $composer->getConfig(), + $composer->getPackage(), + $composer->getDownloadManager(), + $composer->getRepositoryManager(), + $composer->getLocker(), + $composer->getInstallationManager(), + $composer->getEventDispatcher(), + $composer->getAutoloadGenerator() + ); + + if (0 !== $installer->run()) { + file_put_contents(Factory::getComposerFile(), $composerJsonContents); + + return; + } + + $versionSelector = new VersionSelector(class_exists(RepositorySet::class) ? new RepositorySet() : new Pool()); + $updateComposerJson = false; + + foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $package) { + foreach (['require', 'require-dev'] as $key) { + if (!isset($missingRequires[$key][$package->getName()])) { + continue; + } + $updateComposerJson = true; + $missingRequires[$key][$package->getName()] = $versionSelector->findRecommendedRequireVersion($package); + } + } + + if ($updateComposerJson) { + $this->updateComposerJson($missingRequires, $composer->getConfig()->get('sort-packages')); + $this->updateComposerLock($composer, $event->getIO()); + } + } + + public function getMissingRequires(InstalledRepositoryInterface $repo, array $requires, bool $isProject): array + { + $allPackages = []; + $devPackages = method_exists($repo, 'getDevPackageNames') ? array_flip($repo->getDevPackageNames()) : []; + + // One must require "php-http/discovery" or "friendsofphp/well-known-implementations" + // to opt-in for auto-installation of virtual package implementations + if (!isset($requires[0]['php-http/discovery']) && !isset($requires[0]['friendsofphp/well-known-implementations'])) { + $requires = [[], []]; + } + + foreach ($repo->getPackages() as $package) { + $allPackages[$package->getName()] = $package; + + if (isset($package->getRequires()['php-http/discovery']) || isset($package->getRequires()['friendsofphp/well-known-implementations'])) { + $requires[(int) isset($devPackages[$package->getName()])] += $package->getRequires(); + } + } + + $missingRequires = [[], [], []]; + $versionParser = new VersionParser(); + + if (class_exists(\Phalcon\Http\Message\RequestFactory::class, false)) { + $missingRequires[0]['psr/http-factory-implementation'] = []; + $missingRequires[1]['psr/http-factory-implementation'] = []; + } + + foreach ($requires as $dev => $rules) { + $abstractions = []; + $rules = array_intersect_key(self::PROVIDE_RULES, $rules); + + while ($rules) { + $abstractions[] = $abstraction = key($rules); + + foreach (array_shift($rules) as $candidate => $deps) { + [$candidate, $version] = explode(':', $candidate, 2) + [1 => null]; + + if (!isset($allPackages[$candidate])) { + continue; + } + if (null !== $version && !$repo->findPackage($candidate, $versionParser->parseConstraints($version))) { + continue; + } + if ($isProject && !$dev && isset($devPackages[$candidate])) { + $missingRequires[0][$abstraction] = [$candidate]; + $missingRequires[2][$abstraction] = [$candidate]; + } else { + $missingRequires[$dev][$abstraction] = []; + } + + foreach ($deps as $dep) { + if (isset(self::PROVIDE_RULES[$dep])) { + $rules[$dep] = self::PROVIDE_RULES[$dep]; + } elseif (!isset($allPackages[$dep])) { + $missingRequires[$dev][$abstraction][] = $dep; + } elseif ($isProject && !$dev && isset($devPackages[$dep])) { + $missingRequires[0][$abstraction][] = $dep; + $missingRequires[2][$abstraction][] = $dep; + } + } + break; + } + } + + while ($abstractions) { + $abstraction = array_shift($abstractions); + + if (isset($missingRequires[$dev][$abstraction])) { + continue; + } + $candidates = self::PROVIDE_RULES[$abstraction]; + + foreach ($candidates as $candidate => $deps) { + [$candidate, $version] = explode(':', $candidate, 2) + [1 => null]; + + if (null !== $version && !$repo->findPackage($candidate, $versionParser->parseConstraints($version))) { + continue; + } + if (isset($allPackages[$candidate]) && (!$isProject || $dev || !isset($devPackages[$candidate]))) { + continue 2; + } + } + + foreach (array_intersect_key(self::STICKYNESS_RULES, $candidates) as $candidate => $stickyRule) { + [$stickyName, $stickyVersion] = explode(':', $stickyRule, 2) + [1 => null]; + if (!isset($allPackages[$stickyName]) || ($isProject && !$dev && isset($devPackages[$stickyName]))) { + continue; + } + if (null !== $stickyVersion && !$repo->findPackage($stickyName, $versionParser->parseConstraints($stickyVersion))) { + continue; + } + + $candidates = [$candidate => $candidates[$candidate]]; + break; + } + + $dep = key($candidates); + $missingRequires[$dev][$abstraction] = [$dep]; + + if ($isProject && !$dev && isset($devPackages[$dep])) { + $missingRequires[2][$abstraction][] = $dep; + } + + foreach (current($candidates) as $dep) { + if (isset(self::PROVIDE_RULES[$dep])) { + $abstractions[] = $dep; + } elseif (!isset($allPackages[$dep])) { + $missingRequires[$dev][$abstraction][] = $dep; + } elseif ($isProject && !$dev && isset($devPackages[$dep])) { + $missingRequires[0][$abstraction][] = $dep; + $missingRequires[2][$abstraction][] = $dep; + } + } + } + } + + $missingRequires[1] = array_diff_key($missingRequires[1], $missingRequires[0]); + + return $missingRequires; + } + + private function updateComposerJson(array $missingRequires, bool $sortPackages) + { + $file = Factory::getComposerFile(); + $contents = file_get_contents($file); + + $manipulator = new JsonManipulator($contents); + + foreach ($missingRequires as $key => $packages) { + foreach ($packages as $package => $constraint) { + if ('remove' === $key) { + $manipulator->removeSubNode('require-dev', $package); + } else { + $manipulator->addLink($key, $package, $constraint, $sortPackages); + } + } + } + + file_put_contents($file, $manipulator->getContents()); + } + + private function updateComposerLock(Composer $composer, IOInterface $io) + { + $lock = substr(Factory::getComposerFile(), 0, -4).'lock'; + $composerJson = file_get_contents(Factory::getComposerFile()); + $lockFile = new JsonFile($lock, null, $io); + $locker = class_exists(RepositorySet::class) + ? new Locker($io, $lockFile, $composer->getInstallationManager(), $composerJson) + : new Locker($io, $lockFile, $composer->getRepositoryManager(), $composer->getInstallationManager(), $composerJson); + $lockData = $locker->getLockData(); + $lockData['content-hash'] = Locker::getContentHash($composerJson); + $lockFile->write($lockData); + } +} diff --git a/src/Strategy/CommonClassesStrategy.php b/src/Strategy/CommonClassesStrategy.php index 8bddbe8..8126e12 100644 --- a/src/Strategy/CommonClassesStrategy.php +++ b/src/Strategy/CommonClassesStrategy.php @@ -47,6 +47,8 @@ * @internal * * @author Tobias Nyholm + * + * Don't miss updating src/Composer/Plugin.php when adding a new supported class. */ final class CommonClassesStrategy implements DiscoveryStrategy { diff --git a/src/Strategy/CommonPsr17ClassesStrategy.php b/src/Strategy/CommonPsr17ClassesStrategy.php index fc26778..0a0b8c8 100644 --- a/src/Strategy/CommonPsr17ClassesStrategy.php +++ b/src/Strategy/CommonPsr17ClassesStrategy.php @@ -13,6 +13,8 @@ * @internal * * @author Tobias Nyholm + * + * Don't miss updating src/Composer/Plugin.php when adding a new supported class. */ final class CommonPsr17ClassesStrategy implements DiscoveryStrategy { diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php new file mode 100644 index 0000000..a992fe5 --- /dev/null +++ b/tests/Composer/PluginTest.php @@ -0,0 +1,64 @@ +assertSame($expected, $plugin->getMissingRequires($repo, [$rootRequires, $rootDevRequires], true)); + } + + public static function provideMissingRequires() + { + $link = new Link('source', 'target', new Constraint(Constraint::STR_OP_GE, '1')); + $repo = new InstalledArrayRepository([]); + + yield 'empty' => [[[], [], []], $repo, [], []]; + + $rootRequires = [ + 'php-http/discovery' => $link, + 'php-http/async-client-implementation' => $link, + ]; + $expected = [[ + 'php-http/async-client-implementation' => [ + 'symfony/http-client', + 'guzzlehttp/promises', + 'php-http/message-factory', + ], + 'psr/http-factory-implementation' => [ + 'nyholm/psr7', + ], + ], [], []]; + + yield 'async-httplug' => [$expected, $repo, $rootRequires, []]; + + $repo = new InstalledArrayRepository([ + 'nyholm/psr7' => new Package('nyholm/psr7', '1.0.0.0', '1.0'), + ]); + $repo->setDevPackageNames(['nyholm/psr7']); + + $expected[2] = [ + 'psr/http-factory-implementation' => [ + 'nyholm/psr7', + ], + ]; + + yield 'move-to-require' => [$expected, $repo, $rootRequires, []]; + } +} diff --git a/tests/plugin/.gitignore b/tests/plugin/.gitignore new file mode 100644 index 0000000..de4a392 --- /dev/null +++ b/tests/plugin/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock diff --git a/tests/plugin/composer.json b/tests/plugin/composer.json new file mode 100644 index 0000000..4554516 --- /dev/null +++ b/tests/plugin/composer.json @@ -0,0 +1,23 @@ +{ + "repositories": [ + { + "type": "path", + "url": "../..", + "options": { + "versions": { + "php-http/discovery": "99.99.x-dev" + } + } + } + ], + "require": { + "guzzlehttp/psr7": "^1", + "php-http/discovery": "99.99.x-dev", + "psr/http-factory-implementation": "*" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + } + } +}