Skip to content
This repository was archived by the owner on Feb 10, 2023. It is now read-only.

Commit 866036c

Browse files
Add composer plugin
1 parent 2b14762 commit 866036c

File tree

5 files changed

+343
-4
lines changed

5 files changed

+343
-4
lines changed

.github/workflows/unit-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ jobs:
7070
- name: Install dependencies
7171
run: |
7272
echo "::group::composer update"
73+
composer remove --ansi --no-update --dev nyholm/psr7
7374
composer require --ansi --no-update guzzlehttp/promises php-http/message-factory ${{ matrix.psr7 }} ${{ matrix.client }}
7475
composer update --ansi
7576
echo "::endgroup::"

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ might enable better integration in debugging panels for example.
1919

2020
By requiring "friendsofphp/well-known-implementations" instead of
2121
"php-http/guzzle7-adapter", SDK maintainers can provide ideal experiences:
22-
because this package is also a composer-plugin (*TODO*), it will auto-install an
23-
actual implementation of the required abstraction when none is already installed,
24-
or reuse it if one is found.
22+
because this package is also a composer-plugin, it will auto-install an actual
23+
implementation of the required abstraction when none is already installed, or
24+
reuse it if one is found.
2525

2626
In their constructors, SDKs should then reference the provided "well-known"
2727
classes and they will get whatever implementation is available:

composer.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"composer-runtime-api": "^2.1"
2424
},
2525
"require-dev": {
26+
"composer/composer": "^2.1",
27+
"nyholm/psr7": "^1",
2628
"php-http/httplug": "^1|^2",
2729
"psr/http-client": "^1",
2830
"psr/http-factory": "^1",
@@ -39,10 +41,14 @@
3941
"guzzlehttp/psr7": "<1.4",
4042
"laminas/laminas-diactoros": "<2",
4143
"php-http/curl-client": "<2",
42-
"php-http/react-adapter": "<3"
44+
"php-http/react-adapter": "<3",
45+
"symfony/http-client": "<4.4"
4346
},
4447
"config": {
4548
"sort-packages": true
4649
},
50+
"extra": {
51+
"class": "FriendsOfPHP\\WellKnownImplementations\\Internal\\ComposerPlugin"
52+
},
4753
"minimum-stability": "dev"
4854
}

src/Internal/ComposerPlugin.php

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
<?php
2+
3+
/*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace FriendsOfPHP\WellKnownImplementations\Internal;
11+
12+
use Composer\Composer;
13+
use Composer\EventDispatcher\EventSubscriberInterface;
14+
use Composer\Factory;
15+
use Composer\Installer;
16+
use Composer\IO\IOInterface;
17+
use Composer\Json\JsonFile;
18+
use Composer\Json\JsonManipulator;
19+
use Composer\Package\Locker;
20+
use Composer\Package\Version\VersionParser;
21+
use Composer\Package\Version\VersionSelector;
22+
use Composer\Plugin\PluginInterface;
23+
use Composer\Repository\InstalledRepositoryInterface;
24+
use Composer\Repository\RepositorySet;
25+
use Composer\Script\Event;
26+
use Composer\Script\ScriptEvents;
27+
28+
/**
29+
* @internal
30+
*/
31+
class ComposerPlugin implements PluginInterface, EventSubscriberInterface
32+
{
33+
private const PROVIDE_RULES = [
34+
'php-http/async-client-implementation' => [
35+
'symfony/http-client' => ['guzzlehttp/promises', 'php-http/message-factory', 'psr/http-factory-implementation'],
36+
'php-http/guzzle7-adapter' => [],
37+
'php-http/guzzle6-adapter' => [],
38+
'php-http/curl-client' => [],
39+
'php-http/react-adapter' => [],
40+
],
41+
'php-http/client-implementation' => [
42+
'symfony/http-client' => ['php-http/message-factory', 'psr/http-factory-implementation'],
43+
'php-http/guzzle7-adapter' => [],
44+
'php-http/guzzle6-adapter' => [],
45+
'php-http/curl-client' => [],
46+
'php-http/react-adapter' => [],
47+
],
48+
'psr/http-client-implementation' => [
49+
'symfony/http-client' => ['psr/http-factory-implementation'],
50+
'guzzlehttp/guzzle' => [],
51+
],
52+
'psr/http-factory-implementation' => [
53+
'nyholm/psr7' => [],
54+
'guzzlehttp/psr7' => [],
55+
'slim/psr7' => [],
56+
'laminas/laminas-diactoros' => [],
57+
],
58+
'psr/http-message-implementation' => [
59+
'nyholm/psr7' => [],
60+
'guzzlehttp/psr7' => [],
61+
'slim/psr7' => [],
62+
'laminas/laminas-diactoros' => [],
63+
],
64+
];
65+
66+
private const STICKYNESS_RULES = [
67+
'symfony/http-client' => 'symfony/framework-bundle',
68+
'php-http/guzzle7-adapter' => 'guzzlehttp/guzzle:^7',
69+
'php-http/guzzle6-adapter' => 'guzzlehttp/guzzle:^6',
70+
'php-http/react-adapter' => 'react/event-loop',
71+
'slim/psr7' => 'slim/slim',
72+
];
73+
74+
public function activate(Composer $composer, IOInterface $io): void
75+
{
76+
}
77+
78+
public function deactivate(Composer $composer, IOInterface $io)
79+
{
80+
}
81+
82+
public function uninstall(Composer $composer, IOInterface $io)
83+
{
84+
}
85+
86+
public function postUpdate(Event $event)
87+
{
88+
$composer = $event->getComposer();
89+
$repo = $composer->getRepositoryManager()->getLocalRepository();
90+
$requires = [
91+
$composer->getPackage()->getRequires(),
92+
$composer->getPackage()->getDevRequires(),
93+
];
94+
95+
$missingRequires = $this->getMissingRequires($repo, $requires);
96+
$missingRequires = [
97+
'require' => array_fill_keys(array_merge([], ...array_values($missingRequires[0])), '*'),
98+
'require-dev' => array_fill_keys(array_merge([], ...array_values($missingRequires[1])), '*'),
99+
];
100+
101+
if (!$missingRequires = array_filter($missingRequires)) {
102+
return;
103+
}
104+
105+
$composerJsonContents = file_get_contents(Factory::getComposerFile());
106+
$this->updateComposerJson($missingRequires, $composer->getConfig()->get('sort-packages'));
107+
108+
$installer = null;
109+
foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT) as $trace) {
110+
if (isset($trace['object']) && $trace['object'] instanceof Installer) {
111+
$installer = $trace['object'];
112+
break;
113+
}
114+
}
115+
116+
if (!$installer) {
117+
return;
118+
}
119+
120+
$event->stopPropagation();
121+
122+
$ed = $composer->getEventDispatcher();
123+
$disableScripts = !method_exists($ed, 'setRunScripts') || !((array) $ed)["\0*\0runScripts"];
124+
$composer = Factory::create($event->getIO(), null, false, $disableScripts);
125+
126+
/** @var Installer $installer */
127+
$installer = clone $installer;
128+
$installer->__construct(
129+
$event->getIO(),
130+
$composer->getConfig(),
131+
$composer->getPackage(),
132+
$composer->getDownloadManager(),
133+
$composer->getRepositoryManager(),
134+
$composer->getLocker(),
135+
$composer->getInstallationManager(),
136+
$composer->getEventDispatcher(),
137+
$composer->getAutoloadGenerator()
138+
);
139+
140+
if (0 !== $installer->run()) {
141+
file_put_contents(Factory::getComposerFile(), $composerJsonContents);
142+
143+
return;
144+
}
145+
146+
$versionSelector = new VersionSelector(new RepositorySet());
147+
$updateComposerJson = false;
148+
149+
foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $package) {
150+
foreach (['require', 'require-dev'] as $key) {
151+
if (!isset($missingRequires[$key][$package->getName()])) {
152+
continue;
153+
}
154+
$updateComposerJson = true;
155+
$missingRequires[$key][$package->getName()] = $versionSelector->findRecommendedRequireVersion($package);
156+
}
157+
}
158+
159+
if ($updateComposerJson) {
160+
$this->updateComposerJson($missingRequires, $composer->getConfig()->get('sort-packages'));
161+
$this->updateComposerLock($composer, $event->getIO());
162+
}
163+
}
164+
165+
public function getMissingRequires(InstalledRepositoryInterface $repo, array $requires): array
166+
{
167+
$allPackages = [];
168+
$devPackages = array_flip($repo->getDevPackageNames());
169+
170+
foreach ($repo->getPackages() as $package) {
171+
$allPackages[$package->getName()] = $package;
172+
$requires[(int) isset($devPackages[$package->getName()])] += $package->getRequires();
173+
}
174+
175+
176+
$abstractions = [];
177+
$missingRequires = [[], []];
178+
$versionParser = new VersionParser();
179+
180+
foreach ($requires as $dev => $rules) {
181+
$rules = array_intersect_key(self::PROVIDE_RULES, $rules);
182+
183+
while ($rules) {
184+
$abstractions[] = $abstraction = key($rules);
185+
186+
foreach (array_shift($rules) as $candidate => $deps) {
187+
if (!isset($allPackages[$candidate])) {
188+
continue;
189+
}
190+
$missingRequires[$dev][$abstraction] = !$dev && isset($devPackages[$candidate]) ? [$candidate] : [];
191+
192+
foreach ($deps as $dep) {
193+
if (isset(self::PROVIDE_RULES[$dep])) {
194+
$rules[$dep] = self::PROVIDE_RULES[$dep];
195+
} elseif (!isset($allPackages[$dep]) || (!$dev && isset($devPackages[$dep]))) {
196+
$missingRequires[$dev][$abstraction][] = $dep;
197+
}
198+
}
199+
}
200+
}
201+
202+
while ($abstractions) {
203+
$abstraction = array_shift($abstractions);
204+
205+
if (isset($missingRequires[$dev][$abstraction])) {
206+
continue;
207+
}
208+
$candidates = self::PROVIDE_RULES[$abstraction];
209+
210+
foreach ($candidates as $candidate => $deps) {
211+
if (isset($allPackages[$candidate]) && (!$dev || isset($devPackages[$candidate]))) {
212+
continue 2;
213+
}
214+
}
215+
216+
foreach (array_intersect_key(self::STICKYNESS_RULES, $candidates) as $candidate => $stickyRule) {
217+
[$stickyName, $stickyVersion] = explode(':', $stickyRule, 2) + [1 => null];
218+
if (!isset($allPackages[$stickyName]) || (!$dev && isset($devPackages[$stickyName]))) {
219+
continue;
220+
}
221+
if (null !== $stickyVersion && !$repo->findPackage($stickyName, $versionParser->parseConstraints($stickyVersion))) {
222+
continue;
223+
}
224+
225+
$candidates = [$candidate => $candidates[$candidate]];
226+
break;
227+
}
228+
229+
$missingRequires[$dev][$abstraction] = [key($candidates)];
230+
231+
foreach (current($candidates) as $dep) {
232+
if (isset(self::PROVIDE_RULES[$dep])) {
233+
$abstractions[] = $dep;
234+
} elseif (!isset($allPackages[$dep]) || (!$dev && isset($devPackages[$dep]))) {
235+
$missingRequires[$dev][$abstraction][] = $dep;
236+
}
237+
}
238+
}
239+
}
240+
241+
$missingRequires[1] = array_diff_key($missingRequires[1], $missingRequires[0]);
242+
243+
return $missingRequires;
244+
}
245+
246+
public function updateComposerJson(array $missingRequires, bool $sortPackages)
247+
{
248+
$file = Factory::getComposerFile();
249+
$contents = file_get_contents($file);
250+
251+
$manipulator = new JsonManipulator($contents);
252+
253+
foreach ($missingRequires as $key => $packages) {
254+
foreach ($packages as $package => $constraint) {
255+
$manipulator->addLink($key, $package, $constraint, $sortPackages);
256+
}
257+
}
258+
259+
file_put_contents($file, $manipulator->getContents());
260+
}
261+
262+
public static function getSubscribedEvents(): array
263+
{
264+
return [
265+
ScriptEvents::POST_UPDATE_CMD => 'postUpdate',
266+
];
267+
}
268+
269+
private function updateComposerLock(Composer $composer, IOInterface $io)
270+
{
271+
$lock = substr(Factory::getComposerFile(), 0, -4).'lock';
272+
$composerJson = file_get_contents(Factory::getComposerFile());
273+
$lockFile = new JsonFile($lock, null, $io);
274+
$locker = new Locker($io, $lockFile, $composer->getInstallationManager(), $composerJson);
275+
$lockData = $locker->getLockData();
276+
$lockData['content-hash'] = Locker::getContentHash($composerJson);
277+
$lockFile->write($lockData);
278+
}
279+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace FriendsOfPHP\WellKnownImplementations\Tests\Internal;
11+
12+
use Composer\Package\Link;
13+
use Composer\Repository\InstalledArrayRepository;
14+
use Composer\Semver\Constraint\Constraint;
15+
use FriendsOfPHP\WellKnownImplementations\Internal\ComposerPlugin;
16+
use PHPUnit\Framework\TestCase;
17+
18+
class ComposerPluginTest extends TestCase
19+
{
20+
/**
21+
* @dataProvider provideMissingRequires
22+
*/
23+
public function testMissingRequires(array $expected, InstalledArrayRepository $repo, array $rootRequires, array $rootDevRequires)
24+
{
25+
$plugin = new ComposerPlugin();
26+
27+
$this->assertSame($expected, $plugin->getMissingRequires($repo, [$rootRequires, $rootDevRequires]));
28+
}
29+
30+
public function provideMissingRequires()
31+
{
32+
$link = new Link('source', 'target', new Constraint(Constraint::STR_OP_GE, '1'));
33+
$repo = new InstalledArrayRepository([]);
34+
35+
yield 'empty' => [[[], []], $repo, [], []];
36+
37+
$rootRequires = [
38+
'php-http/async-client-implementation' => $link,
39+
];
40+
$expected = [[
41+
'php-http/async-client-implementation' => [
42+
'symfony/http-client',
43+
'guzzlehttp/promises',
44+
'php-http/message-factory',
45+
],
46+
'psr/http-factory-implementation' => [
47+
'nyholm/psr7',
48+
],
49+
], []];
50+
51+
yield 'async-httplug' => [$expected, $repo, $rootRequires, []];
52+
}
53+
}

0 commit comments

Comments
 (0)