diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index aefd24d..5006be5 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -8,6 +8,8 @@ use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\Downloading\DownloadAndExtract; use Php\Pie\Installing\Install; +use Php\Pie\Installing\InstallNotification\FailedToSendInstallNotification; +use Php\Pie\Installing\InstallNotification\InstallNotification; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -25,6 +27,7 @@ public function __construct( private readonly DownloadAndExtract $downloadAndExtract, private readonly Build $build, private readonly Install $install, + private readonly InstallNotification $installNotification, ) { parent::__construct(); } @@ -62,6 +65,17 @@ public function execute(InputInterface $input, OutputInterface $output): int ($this->install)($downloadedPackage, $targetPlatform, $output); + try { + $this->installNotification->send($targetPlatform, $downloadedPackage); + } catch (FailedToSendInstallNotification $failedToSendInstallNotification) { + if ($output->isVeryVerbose()) { + $output->writeln('Install notification did not send.'); + if ($output->isDebug()) { + $output->writeln($failedToSendInstallNotification->__toString()); + } + } + } + return Command::SUCCESS; } } diff --git a/src/Container.php b/src/Container.php index 56f9a59..f865420 100644 --- a/src/Container.php +++ b/src/Container.php @@ -31,6 +31,8 @@ use Php\Pie\Downloading\UnixDownloadAndExtract; use Php\Pie\Downloading\WindowsDownloadAndExtract; use Php\Pie\Installing\Install; +use Php\Pie\Installing\InstallNotification\InstallNotification; +use Php\Pie\Installing\InstallNotification\SendInstallNotificationUsingGuzzle; use Php\Pie\Installing\UnixInstall; use Php\Pie\Installing\WindowsInstall; use Php\Pie\Platform\TargetPhp\ResolveTargetPhpToPlatformRepository; @@ -138,6 +140,8 @@ static function (ContainerInterface $container): Install { }, ); + $container->alias(SendInstallNotificationUsingGuzzle::class, InstallNotification::class); + return $container; } } diff --git a/src/DependencyResolver/Package.php b/src/DependencyResolver/Package.php index 3fab599..30d203b 100644 --- a/src/DependencyResolver/Package.php +++ b/src/DependencyResolver/Package.php @@ -27,6 +27,8 @@ public function __construct( public readonly string $version, public readonly string|null $downloadUrl, public readonly array $configureOptions, + public readonly string|null $notificationUrl, + public readonly string $notificationVersion, ) { } @@ -48,6 +50,8 @@ public static function fromComposerCompletePackage(CompletePackageInterface $com $completePackage->getPrettyVersion(), $completePackage->getDistUrl(), $configureOptions, + $completePackage->getNotificationUrl(), + $completePackage->getVersion(), ); } diff --git a/src/Installing/InstallNotification/FailedToSendInstallNotification.php b/src/Installing/InstallNotification/FailedToSendInstallNotification.php new file mode 100644 index 0000000..2a2da13 --- /dev/null +++ b/src/Installing/InstallNotification/FailedToSendInstallNotification.php @@ -0,0 +1,32 @@ +package->prettyNameAndVersion(), + $request->getUri(), + $response->getStatusCode(), + $request->getBody()->__toString(), + $response->getBody()->__toString(), + )); + } +} diff --git a/src/Installing/InstallNotification/InstallNotification.php b/src/Installing/InstallNotification/InstallNotification.php new file mode 100644 index 0000000..718b176 --- /dev/null +++ b/src/Installing/InstallNotification/InstallNotification.php @@ -0,0 +1,23 @@ +package->notificationUrl === null) { + return; + } + + $notificationRequest = new Request( + 'POST', + $package->package->notificationUrl, + [ + 'Content-Type' => 'application/json', + /** + * User agent format is important! If it isn't right, Packagist + * silently discards the payload. + * + * @link https://github.com/composer/packagist/blob/fb75c17d75bc032cc88b997275d40077511d0cd9/src/Controller/ApiController.php#L296 + * @link https://github.com/composer/packagist/blob/fb75c17d75bc032cc88b997275d40077511d0cd9/src/Util/UserAgentParser.php#L28-L38 + */ + 'User-Agent' => sprintf( + 'Composer/%s (%s; %s; %s; %s)', + Composer::getVersion(), + function_exists('php_uname') ? php_uname('s') : 'Unknown', + function_exists('php_uname') ? php_uname('r') : 'Unknown', + 'PHP ' . $targetPlatform->phpBinaryPath->version(), + 'cURL ' . TargetPlatform::getCurlVersion(), + ), + ], + /** + * @link https://github.com/composer/packagist/blob/main/src/Controller/ApiController.php#L248 + * @see \Composer\Installer\InstallationManager::notifyInstalls() + */ + json_encode([ + 'downloads' => [ + [ + 'name' => $package->package->name, + 'version' => $package->package->notificationVersion, + 'downloaded' => false, + ], + ], + ]), + ); + + $notificationResponse = $this->client->send( + $notificationRequest, + [RequestOptions::HTTP_ERRORS => false], + ); + + /** @var mixed $responseBody */ + $responseBody = json_decode($notificationResponse->getBody()->__toString(), true); + + if ( + ! is_array($responseBody) + || ! array_key_exists('status', $responseBody) + || $responseBody['status'] !== 'success' + ) { + throw FailedToSendInstallNotification::fromFailedResponse( + $package, + $notificationRequest, + $notificationResponse, + ); + } + } +} diff --git a/src/Platform/TargetPlatform.php b/src/Platform/TargetPlatform.php index 548aa08..ce7625e 100644 --- a/src/Platform/TargetPlatform.php +++ b/src/Platform/TargetPlatform.php @@ -7,8 +7,10 @@ use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use function array_key_exists; +use function curl_version; use function explode; use function function_exists; +use function is_string; use function posix_getuid; use function preg_match; use function trim; @@ -29,6 +31,20 @@ public function __construct( ) { } + public static function getCurlVersion(): string + { + static $curlVersion = null; + + if ($curlVersion === null) { + $curlVersionList = curl_version(); + $curlVersion = array_key_exists('version', $curlVersionList) && is_string($curlVersionList['version']) + ? $curlVersionList['version'] + : null; + } + + return (string) $curlVersion; + } + public static function isRunningAsRoot(): bool { return function_exists('posix_getuid') && posix_getuid() === 0; diff --git a/test/integration/Building/UnixBuildTest.php b/test/integration/Building/UnixBuildTest.php index 2605e9f..08e6b58 100644 --- a/test/integration/Building/UnixBuildTest.php +++ b/test/integration/Building/UnixBuildTest.php @@ -39,6 +39,8 @@ public function testUnixBuildCanBuildExtension(): void '0.1.0', null, [ConfigureOption::fromComposerJsonDefinition(['name' => 'enable-pie_test_ext'])], + null, + '0.1.0.0', ), self::TEST_EXTENSION_PATH, ); diff --git a/test/integration/Installing/UnixInstallTest.php b/test/integration/Installing/UnixInstallTest.php index afd28e4..efd9eb3 100644 --- a/test/integration/Installing/UnixInstallTest.php +++ b/test/integration/Installing/UnixInstallTest.php @@ -46,6 +46,8 @@ public function testUnixInstallCanInstallExtension(): void '0.1.0', null, [ConfigureOption::fromComposerJsonDefinition(['name' => 'enable-pie_test_ext'])], + null, + '0.1.0.0', ), self::TEST_EXTENSION_PATH, ); diff --git a/test/integration/Installing/WindowsInstallTest.php b/test/integration/Installing/WindowsInstallTest.php index 011394a..9703a84 100644 --- a/test/integration/Installing/WindowsInstallTest.php +++ b/test/integration/Installing/WindowsInstallTest.php @@ -52,6 +52,8 @@ public function testWindowsInstallCanInstallExtension(): void '1.2.3', null, [], + null, + '1.2.3.0', ), self::TEST_EXTENSION_PATH, ); diff --git a/test/unit/Command/CommandHelperTest.php b/test/unit/Command/CommandHelperTest.php index bbdd5c0..bd49cbb 100644 --- a/test/unit/Command/CommandHelperTest.php +++ b/test/unit/Command/CommandHelperTest.php @@ -109,6 +109,8 @@ public function testDownloadPackage(): void '1.2.3', 'https://test-uri/', [], + null, + '1.2.3.0', )); $downloadAndExtract->expects(self::once()) @@ -155,6 +157,8 @@ public function testProcessingConfigureOptionsFromInput(): void ]), ConfigureOption::fromComposerJsonDefinition(['name' => 'enable-thing']), ], + null, + '1.0.0.0', ); $inputDefinition = new InputDefinition(); $inputDefinition->addOption(new InputOption('with-stuff', null, InputOption::VALUE_REQUIRED)); diff --git a/test/unit/Downloading/AddAuthenticationHeaderTest.php b/test/unit/Downloading/AddAuthenticationHeaderTest.php index 03f3a5c..4ba1e22 100644 --- a/test/unit/Downloading/AddAuthenticationHeaderTest.php +++ b/test/unit/Downloading/AddAuthenticationHeaderTest.php @@ -40,6 +40,8 @@ public function testAuthorizationHeaderIsAdded(): void '1.2.3', $downloadUrl, [], + null, + '1.2.3.0', ), $authHelper, ); @@ -63,6 +65,8 @@ public function testExceptionIsThrownWhenPackageDoesNotHaveDownloadUrl(): void '1.2.3', null, [], + null, + '1.2.3.0', ); $this->expectException(RuntimeException::class); diff --git a/test/unit/Downloading/DownloadedPackageTest.php b/test/unit/Downloading/DownloadedPackageTest.php index 3419f4e..de2ade8 100644 --- a/test/unit/Downloading/DownloadedPackageTest.php +++ b/test/unit/Downloading/DownloadedPackageTest.php @@ -25,6 +25,8 @@ public function testFromPackageAndExtractedPath(): void '1.2.3', null, [], + null, + '1.2.3.0', ); $extractedSourcePath = uniqid('/path/to/downloaded/package', true); diff --git a/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php b/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php index 7b1311b..ef8fe60 100644 --- a/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php +++ b/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php @@ -28,6 +28,8 @@ public function testForPackage(): void '1.2.3', null, [], + null, + '1.2.3.0', ); $exception = CouldNotFindReleaseAsset::forPackage($package, ['something.zip', 'something2.zip']); @@ -44,6 +46,8 @@ public function testForPackageWithMissingTag(): void '1.2.3', null, [], + null, + '1.2.3.0', ); $exception = CouldNotFindReleaseAsset::forPackageWithMissingTag($package); diff --git a/test/unit/Downloading/GithubPackageReleaseAssetsTest.php b/test/unit/Downloading/GithubPackageReleaseAssetsTest.php index 21e3dc1..51b548d 100644 --- a/test/unit/Downloading/GithubPackageReleaseAssetsTest.php +++ b/test/unit/Downloading/GithubPackageReleaseAssetsTest.php @@ -74,6 +74,8 @@ public function testUrlIsReturnedWhenFindingWindowsDownloadUrl(): void '1.2.3', 'https://test-uri/' . uniqid('downloadUrl', true), [], + null, + '1.2.3.0', ); $releaseAssets = new GithubPackageReleaseAssets($authHelper, $guzzleMockClient, 'https://test-github-api-base-url.thephp.foundation'); @@ -126,6 +128,8 @@ public function testUrlIsReturnedWhenFindingWindowsDownloadUrlWithCompilerAndThr '1.2.3', 'https://test-uri/' . uniqid('downloadUrl', true), [], + null, + '1.2.3.0', ); $releaseAssets = new GithubPackageReleaseAssets($authHelper, $guzzleMockClient, 'https://test-github-api-base-url.thephp.foundation'); @@ -164,6 +168,8 @@ public function testFindWindowsDownloadUrlForPackageThrowsExceptionWhenAssetNotF '1.2.3', 'https://test-uri/' . uniqid('downloadUrl', true), [], + null, + '1.2.3.0', ); $releaseAssets = new GithubPackageReleaseAssets($authHelper, $guzzleMockClient, 'https://test-github-api-base-url.thephp.foundation'); diff --git a/test/unit/Downloading/UnixDownloadAndExtractTest.php b/test/unit/Downloading/UnixDownloadAndExtractTest.php index e460e9c..d650f0a 100644 --- a/test/unit/Downloading/UnixDownloadAndExtractTest.php +++ b/test/unit/Downloading/UnixDownloadAndExtractTest.php @@ -67,6 +67,8 @@ public function testInvoke(): void '1.2.3', $downloadUrl, [], + null, + '1.2.3.0', ); $downloadedPackage = $unixDownloadAndExtract->__invoke($targetPlatform, $requestedPackage); diff --git a/test/unit/Downloading/WindowsDownloadAndExtractTest.php b/test/unit/Downloading/WindowsDownloadAndExtractTest.php index 36e0cc5..d266fb9 100644 --- a/test/unit/Downloading/WindowsDownloadAndExtractTest.php +++ b/test/unit/Downloading/WindowsDownloadAndExtractTest.php @@ -82,6 +82,8 @@ public function testInvoke(): void '1.2.3', 'https://test-uri/' . uniqid('downloadUrl', true), [], + null, + '1.2.3.0', ); $downloadedPackage = $windowsDownloadAndExtract->__invoke($targetPlatform, $requestedPackage); diff --git a/test/unit/Installing/InstallNotification/SendInstallNotificationUsingGuzzleTest.php b/test/unit/Installing/InstallNotification/SendInstallNotificationUsingGuzzleTest.php new file mode 100644 index 0000000..97858ac --- /dev/null +++ b/test/unit/Installing/InstallNotification/SendInstallNotificationUsingGuzzleTest.php @@ -0,0 +1,161 @@ +client = $this->createMock(ClientInterface::class); + + $phpBinaryPath = $this->createMock(PhpBinaryPath::class); + $phpBinaryPath->expects(self::any()) + ->method('version') + ->willReturn(self::FAKE_PHP_VERSION); + + $this->targetPlatform = new TargetPlatform( + OperatingSystem::Windows, + $phpBinaryPath, + Architecture::x86, + ThreadSafetyMode::ThreadSafe, + WindowsCompiler::VC14, + ); + } + + private function downloadedPackageWithNotificationUrl(string|null $notificationUrl): DownloadedPackage + { + return DownloadedPackage::fromPackageAndExtractedPath( + new Package( + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('foo'), + 'bar/foo', + '1.2.3', + null, + [], + $notificationUrl, + '1.2.3.0', + ), + '/path/to/extracted', + ); + } + + public function testNullNotificationUrlDoesNoNotification(): void + { + $this->client->expects(self::never()) + ->method('send'); + + $sender = new SendInstallNotificationUsingGuzzle($this->client); + $sender->send( + $this->targetPlatform, + $this->downloadedPackageWithNotificationUrl(null), + ); + } + + public function testSuccessfulPayload(): void + { + $this->client->expects(self::once()) + ->method('send') + ->with(self::callback(static function (RequestInterface $request) { + self::assertSame('http://example.com/notification', $request->getUri()->__toString()); + self::assertSame('POST', $request->getMethod()); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame( + sprintf( + 'Composer/%s (%s; %s; %s; %s)', + Composer::getVersion(), + function_exists('php_uname') ? php_uname('s') : 'Unknown', + function_exists('php_uname') ? php_uname('r') : 'Unknown', + 'PHP ' . self::FAKE_PHP_VERSION, + 'cURL ' . TargetPlatform::getCurlVersion(), + ), + $request->getHeaderLine('User-Agent'), + ); + + return true; + })) + ->willReturn(new Response( + 201, + [], + json_encode(['status' => 'success']), + )); + + $sender = new SendInstallNotificationUsingGuzzle($this->client); + $sender->send( + $this->targetPlatform, + $this->downloadedPackageWithNotificationUrl('http://example.com/notification'), + ); + } + + public function testPartialSuccessThrowsException(): void + { + $this->client->expects(self::once()) + ->method('send') + ->with(self::callback(static function (RequestInterface $request) { + self::assertSame('http://example.com/notification', $request->getUri()->__toString()); + self::assertSame('POST', $request->getMethod()); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame( + sprintf( + 'Composer/%s (%s; %s; %s; %s)', + Composer::getVersion(), + function_exists('php_uname') ? php_uname('s') : 'Unknown', + function_exists('php_uname') ? php_uname('r') : 'Unknown', + 'PHP ' . self::FAKE_PHP_VERSION, + 'cURL ' . TargetPlatform::getCurlVersion(), + ), + $request->getHeaderLine('User-Agent'), + ); + + return true; + })) + /** @link https://github.com/composer/packagist/blob/fb75c17d75bc032cc88b997275d40077511d0cd9/src/Controller/ApiController.php#L326 */ + ->willReturn(new Response( + 200, + [], + json_encode(['status' => 'partial', 'message' => 'Packages (blah) not found']), + )); + + $sender = new SendInstallNotificationUsingGuzzle($this->client); + + $this->expectException(FailedToSendInstallNotification::class); + $sender->send( + $this->targetPlatform, + $this->downloadedPackageWithNotificationUrl('http://example.com/notification'), + ); + } +} diff --git a/test/unit/Platform/WindowsExtensionAssetNameTest.php b/test/unit/Platform/WindowsExtensionAssetNameTest.php index 29025df..6bcc448 100644 --- a/test/unit/Platform/WindowsExtensionAssetNameTest.php +++ b/test/unit/Platform/WindowsExtensionAssetNameTest.php @@ -45,6 +45,8 @@ public function setUp(): void '1.2.3', null, [], + null, + '1.2.3.0', ); }