diff --git a/src/Command/DownloadCommand.php b/src/Command/DownloadCommand.php index 9fe973a..4a61d51 100644 --- a/src/Command/DownloadCommand.php +++ b/src/Command/DownloadCommand.php @@ -79,15 +79,14 @@ public function execute(InputInterface $input, OutputInterface $output): int $requestedNameAndVersionPair['version'], ); - $output->writeln(sprintf('Found package: %s (version: %s)', $package->name, $package->version)); + $output->writeln(sprintf('Found package: %s', $package->prettyNameAndVersion())); $output->writeln(sprintf('Dist download URL: %s', $package->downloadUrl ?? '(none)')); $downloadedPackage = ($this->downloadAndExtract)($package); $output->writeln(sprintf( - 'Extracted %s:%s source to: %s', - $downloadedPackage->package->name, - $downloadedPackage->package->version, + 'Extracted %s source to: %s', + $downloadedPackage->package->prettyNameAndVersion(), $downloadedPackage->extractedSourcePath, )); diff --git a/src/Container.php b/src/Container.php index b3a954f..3839cc7 100644 --- a/src/Container.php +++ b/src/Container.php @@ -13,13 +13,17 @@ use Composer\Util\AuthHelper; use Composer\Util\Platform; use GuzzleHttp\Client; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\RequestOptions; use Illuminate\Container\Container as IlluminateContainer; use Php\Pie\Command\DownloadCommand; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\ResolveDependencyWithComposer; use Php\Pie\Downloading\DownloadAndExtract; use Php\Pie\Downloading\DownloadZip; -use Php\Pie\Downloading\ExtractZip; +use Php\Pie\Downloading\DownloadZipWithGuzzle; +use Php\Pie\Downloading\GithubPackageReleaseAssets; +use Php\Pie\Downloading\PackageReleaseAssets; use Php\Pie\Downloading\UnixDownloadAndExtract; use Php\Pie\Downloading\WindowsDownloadAndExtract; use Php\Pie\TargetPhp\ResolveTargetPhpToPlatformRepository; @@ -74,39 +78,23 @@ static function (ContainerInterface $container): DependencyResolver { ); }, ); - $container->singleton( - UnixDownloadAndExtract::class, - static function (ContainerInterface $container): UnixDownloadAndExtract { - return new UnixDownloadAndExtract( - new DownloadZip( - new Client(), - ), - new ExtractZip(), - new AuthHelper( - $container->get(IOInterface::class), - $container->get(Composer::class)->getConfig(), - ), - ); + $container->bind( + ClientInterface::class, + static function (): ClientInterface { + return new Client([RequestOptions::HTTP_ERRORS => false]); }, ); $container->singleton( - WindowsDownloadAndExtract::class, - static function (ContainerInterface $container): WindowsDownloadAndExtract { - $guzzleClient = new Client(); - - return new WindowsDownloadAndExtract( - new DownloadZip( - $guzzleClient, - ), - new ExtractZip(), - new AuthHelper( - $container->get(IOInterface::class), - $container->get(Composer::class)->getConfig(), - ), - $guzzleClient, + AuthHelper::class, + static function (ContainerInterface $container): AuthHelper { + return new AuthHelper( + $container->get(IOInterface::class), + $container->get(Composer::class)->getConfig(), ); }, ); + $container->alias(DownloadZipWithGuzzle::class, DownloadZip::class); + $container->alias(GithubPackageReleaseAssets::class, PackageReleaseAssets::class); $container->singleton( DownloadAndExtract::class, static function (ContainerInterface $container): DownloadAndExtract { diff --git a/src/DependencyResolver/Package.php b/src/DependencyResolver/Package.php index d6c1151..0551e72 100644 --- a/src/DependencyResolver/Package.php +++ b/src/DependencyResolver/Package.php @@ -31,4 +31,9 @@ public static function fromComposerCompletePackage(CompletePackageInterface $com $completePackage->getDistUrl(), ); } + + public function prettyNameAndVersion(): string + { + return $this->name . ':' . $this->version; + } } diff --git a/src/Downloading/DownloadZip.php b/src/Downloading/DownloadZip.php index 4b13acc..0f9b0b5 100644 --- a/src/Downloading/DownloadZip.php +++ b/src/Downloading/DownloadZip.php @@ -4,42 +4,15 @@ namespace Php\Pie\Downloading; -use GuzzleHttp\ClientInterface; -use GuzzleHttp\RequestOptions; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; - -use function assert; -use function file_put_contents; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ -final class DownloadZip +interface DownloadZip { - public function __construct( - private readonly ClientInterface $client, - ) { - } - - public function downloadZipAndReturnLocalPath(RequestInterface $request, string $localPath): string - { - $response = $this->client - ->sendAsync( - $request, - [ - RequestOptions::ALLOW_REDIRECTS => true, - RequestOptions::HTTP_ERRORS => false, - RequestOptions::SYNCHRONOUS => true, - ], - ) - ->wait(); - assert($response instanceof ResponseInterface); - - // @todo check response was successful - - // @todo handle this writing better - $tmpZipFile = $localPath . '/downloaded.zip'; - file_put_contents($tmpZipFile, $response->getBody()->__toString()); - - return $tmpZipFile; - } + /** + * @param non-empty-string $localPath + * + * @return non-empty-string + */ + public function downloadZipAndReturnLocalPath(RequestInterface $request, string $localPath): string; } diff --git a/src/Downloading/DownloadZipWithGuzzle.php b/src/Downloading/DownloadZipWithGuzzle.php new file mode 100644 index 0000000..ab1873d --- /dev/null +++ b/src/Downloading/DownloadZipWithGuzzle.php @@ -0,0 +1,45 @@ +client + ->sendAsync( + $request, + [ + RequestOptions::ALLOW_REDIRECTS => true, + RequestOptions::HTTP_ERRORS => false, + RequestOptions::SYNCHRONOUS => true, + ], + ) + ->wait(); + assert($response instanceof ResponseInterface); + + // @todo check response was successful + + // @todo handle this writing better + $tmpZipFile = $localPath . '/downloaded.zip'; + file_put_contents($tmpZipFile, $response->getBody()->__toString()); + + return $tmpZipFile; + } +} diff --git a/src/Downloading/Exception/CouldNotFindReleaseAsset.php b/src/Downloading/Exception/CouldNotFindReleaseAsset.php new file mode 100644 index 0000000..ec1da61 --- /dev/null +++ b/src/Downloading/Exception/CouldNotFindReleaseAsset.php @@ -0,0 +1,22 @@ +prettyNameAndVersion(), + $expectedAssetName, + )); + } +} diff --git a/src/Downloading/GithubPackageReleaseAssets.php b/src/Downloading/GithubPackageReleaseAssets.php new file mode 100644 index 0000000..497d90d --- /dev/null +++ b/src/Downloading/GithubPackageReleaseAssets.php @@ -0,0 +1,126 @@ +selectMatchingReleaseAsset( + $package, + $this->getReleaseAssetsForPackage($package), + ); + + return $releaseAsset['browser_download_url']; + } + + /** @return non-empty-string */ + private function expectedWindowsAssetName(Package $package): string + { + // @todo source these from the right places... + $arch = 'x86'; + $ts = 'nts'; + $compiler = 'vs16'; + $phpVersion = '8.3'; + $extensionName = str_replace('-', '_', 'example-pie-extension'); + + return sprintf( + 'php_%s-%s-%s-%s-%s-%s.zip', + $extensionName, + $package->version, + $phpVersion, + $compiler, + $ts, + $arch, + ); + } + + /** @link https://github.com/squizlabs/PHP_CodeSniffer/issues/3734 */ + // phpcs:disable Squiz.Commenting.FunctionComment.MissingParamName + /** + * @param list $releaseAssets + * + * @return array{name: non-empty-string, browser_download_url: non-empty-string, ...} + */ + // phpcs:enable + private function selectMatchingReleaseAsset(Package $package, array $releaseAssets): array + { + $expectedAssetName = $this->expectedWindowsAssetName($package); + + foreach ($releaseAssets as $releaseAsset) { + if ($releaseAsset['name'] === $expectedAssetName) { + return $releaseAsset; + } + } + + throw Exception\CouldNotFindReleaseAsset::forPackage($package, $expectedAssetName); + } + + /** @return list */ + private function getReleaseAssetsForPackage(Package $package): array + { + // @todo dynamic URL, don't hard code it... + // @todo confirm prettyName will always match the repo name - it might not + $request = AddAuthenticationHeader::withAuthHeaderFromComposer( + new Request('GET', 'https://api.github.com/repos/' . $package->name . '/releases/tags/' . $package->version), + $package, + $this->authHelper, + ); + + $response = $this->client + ->sendAsync( + $request, + [ + RequestOptions::ALLOW_REDIRECTS => true, + RequestOptions::HTTP_ERRORS => false, + RequestOptions::SYNCHRONOUS => true, + ], + ) + ->wait(); + assert($response instanceof ResponseInterface); + + // @todo check response was successful + + $releaseAssets = Json\typed( + (string) $response->getBody(), + Type\shape( + [ + 'assets' => Type\vec(Type\shape( + [ + 'name' => Type\non_empty_string(), + 'browser_download_url' => Type\non_empty_string(), + ], + true, + )), + ], + true, + ), + ); + + return $releaseAssets['assets']; + } +} diff --git a/src/Downloading/PackageReleaseAssets.php b/src/Downloading/PackageReleaseAssets.php new file mode 100644 index 0000000..5872949 --- /dev/null +++ b/src/Downloading/PackageReleaseAssets.php @@ -0,0 +1,14 @@ +selectMatchingReleaseAsset( - $package, - $this->getReleaseAssetsForPackage($package), - ); + $windowsDownloadUrl = $this->packageReleaseAssets->findWindowsDownloadUrlForPackage($package); // @todo extract to a static util $localTempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_downloader_', true); @@ -50,7 +39,7 @@ public function __invoke(Package $package): DownloadedPackage $tmpZipFile = $this->downloadZip->downloadZipAndReturnLocalPath( AddAuthenticationHeader::withAuthHeaderFromComposer( - new Request('GET', $releaseAsset['browser_download_url']), + new Request('GET', $windowsDownloadUrl), $package, $this->authHelper, ), @@ -61,83 +50,4 @@ public function __invoke(Package $package): DownloadedPackage return DownloadedPackage::fromPackageAndExtractedPath($package, $localTempPath); } - - /** @link https://github.com/squizlabs/PHP_CodeSniffer/issues/3734 */ - // phpcs:disable Squiz.Commenting.FunctionComment.MissingParamName - /** - * @param list $releaseAssets - * - * @return array{name: non-empty-string, browser_download_url: non-empty-string, ...} - */ - // phpcs:enable - private function selectMatchingReleaseAsset(Package $package, array $releaseAssets): array - { - // @todo source these from the right places... - $arch = 'x86'; - $ts = 'nts'; - $compiler = 'vs16'; - $phpVersion = '8.3'; - $extensionName = str_replace('-', '_', 'example-pie-extension'); - $expectedAssetName = sprintf( - 'php_%s-%s-%s-%s-%s-%s.zip', - $extensionName, - $package->version, - $phpVersion, - $compiler, - $ts, - $arch, - ); - - foreach ($releaseAssets as $releaseAsset) { - if ($releaseAsset['name'] === $expectedAssetName) { - return $releaseAsset; - } - } - - throw new RuntimeException('Could not find release asset for ' . $package->version . ' named: ' . $expectedAssetName); - } - - /** @return list */ - private function getReleaseAssetsForPackage(Package $package): array - { - // @todo dynamic URL, don't hard code it... - // @todo confirm prettyName will always match the repo name - it might not - $request = AddAuthenticationHeader::withAuthHeaderFromComposer( - new Request('GET', 'https://api.github.com/repos/' . $package->name . '/releases/tags/' . $package->version), - $package, - $this->authHelper, - ); - - $response = $this->client - ->sendAsync( - $request, - [ - RequestOptions::ALLOW_REDIRECTS => true, - RequestOptions::HTTP_ERRORS => false, - RequestOptions::SYNCHRONOUS => true, - ], - ) - ->wait(); - assert($response instanceof ResponseInterface); - - // @todo check response was successful - - $releaseAssets = Json\typed( - (string) $response->getBody(), - Type\shape( - [ - 'assets' => Type\vec(Type\shape( - [ - 'name' => Type\non_empty_string(), - 'browser_download_url' => Type\non_empty_string(), - ], - true, - )), - ], - true, - ), - ); - - return $releaseAssets['assets']; - } } diff --git a/test/integration/Command/DownloadCommandTest.php b/test/integration/Command/DownloadCommandTest.php index def32e0..02ec7af 100644 --- a/test/integration/Command/DownloadCommandTest.php +++ b/test/integration/Command/DownloadCommandTest.php @@ -36,7 +36,7 @@ public function testDownloadCommand(): void $this->commandTester->assertCommandIsSuccessful(); $outputString = $this->commandTester->getDisplay(); - self::assertStringContainsString('Found package: asgrim/example-pie-extension (version: 1.0.0)', $outputString); + self::assertStringContainsString('Found package: asgrim/example-pie-extension:1.0.0', $outputString); self::assertStringContainsString('Dist download URL: https://api.github.com/repos/asgrim/example-pie-extension/zipball/', $outputString); self::assertStringContainsString('Extracted asgrim/example-pie-extension:1.0.0 source', $outputString); } diff --git a/test/unit/DependencyResolver/PackageTest.php b/test/unit/DependencyResolver/PackageTest.php index b052cff..a18ed83 100644 --- a/test/unit/DependencyResolver/PackageTest.php +++ b/test/unit/DependencyResolver/PackageTest.php @@ -20,6 +20,7 @@ public function testFromComposerCompletePackage(): void self::assertSame('foo', $package->name); self::assertSame('1.2.3', $package->version); + self::assertSame('foo:1.2.3', $package->prettyNameAndVersion()); self::assertNull($package->downloadUrl); } } diff --git a/test/unit/Downloading/DownloadZipTest.php b/test/unit/Downloading/DownloadZipWithGuzzleTest.php similarity index 86% rename from test/unit/Downloading/DownloadZipTest.php rename to test/unit/Downloading/DownloadZipWithGuzzleTest.php index a7e0473..ac58dcb 100644 --- a/test/unit/Downloading/DownloadZipTest.php +++ b/test/unit/Downloading/DownloadZipWithGuzzleTest.php @@ -9,7 +9,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -use Php\Pie\Downloading\DownloadZip; +use Php\Pie\Downloading\DownloadZipWithGuzzle; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -21,8 +21,8 @@ use const DIRECTORY_SEPARATOR; -#[CoversClass(DownloadZip::class)] -final class DownloadZipTest extends TestCase +#[CoversClass(DownloadZipWithGuzzle::class)] +final class DownloadZipWithGuzzleTest extends TestCase { public function testDownloadZipAndReturnLocalPath(): void { @@ -43,7 +43,7 @@ public function testDownloadZipAndReturnLocalPath(): void $localPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_', true); mkdir($localPath, 0777, true); - $downloadedZipFile = (new DownloadZip($guzzleMockClient)) + $downloadedZipFile = (new DownloadZipWithGuzzle($guzzleMockClient)) ->downloadZipAndReturnLocalPath( new Request('GET', 'http://test-uri/'), $localPath, diff --git a/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php b/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php new file mode 100644 index 0000000..f24da83 --- /dev/null +++ b/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php @@ -0,0 +1,23 @@ +getMessage()); + } +} diff --git a/test/unit/Downloading/ExtractZipTest.php b/test/unit/Downloading/ExtractZipTest.php index f6d7149..cbd8a3a 100644 --- a/test/unit/Downloading/ExtractZipTest.php +++ b/test/unit/Downloading/ExtractZipTest.php @@ -18,7 +18,6 @@ use const DIRECTORY_SEPARATOR; -/** @covers \Php\Pie\Downloading\ExtractZip */ #[CoversClass(ExtractZip::class)] final class ExtractZipTest extends TestCase { diff --git a/test/unit/Downloading/GithubPackageReleaseAssetsTest.php b/test/unit/Downloading/GithubPackageReleaseAssetsTest.php new file mode 100644 index 0000000..82c9c23 --- /dev/null +++ b/test/unit/Downloading/GithubPackageReleaseAssetsTest.php @@ -0,0 +1,75 @@ +createMock(AuthHelper::class); + + $mockHandler = new MockHandler([ + new Response( + 200, + [], + json_encode([ + 'assets' => [ + [ + 'name' => 'php_example_pie_extension-1.2.3-8.3-vs16-nts-x86.zip', + 'browser_download_url' => 'actual_download_url', + ], + ], + ]), + ), + ]); + + $guzzleMockClient = new Client(['handler' => HandlerStack::create($mockHandler)]); + + $package = new Package('asgrim/example-pie-extension', '1.2.3', 'https://test-uri/' . uniqid('downloadUrl', true)); + + $releaseAssets = new GithubPackageReleaseAssets($authHelper, $guzzleMockClient); + + self::assertSame('actual_download_url', $releaseAssets->findWindowsDownloadUrlForPackage($package)); + } + + public function testFindWindowsDownloadUrlForPackageThrowsExceptionWhenAssetNotFound(): void + { + $authHelper = $this->createMock(AuthHelper::class); + + $mockHandler = new MockHandler([ + new Response( + 200, + [], + json_encode([ + 'assets' => [], + ]), + ), + ]); + + $guzzleMockClient = new Client(['handler' => HandlerStack::create($mockHandler)]); + + $package = new Package('asgrim/example-pie-extension', '1.2.3', 'https://test-uri/' . uniqid('downloadUrl', true)); + + $releaseAssets = new GithubPackageReleaseAssets($authHelper, $guzzleMockClient); + + $this->expectException(CouldNotFindReleaseAsset::class); + $releaseAssets->findWindowsDownloadUrlForPackage($package); + } +} diff --git a/test/unit/Downloading/UnixDownloadAndExtractTest.php b/test/unit/Downloading/UnixDownloadAndExtractTest.php new file mode 100644 index 0000000..1f3b240 --- /dev/null +++ b/test/unit/Downloading/UnixDownloadAndExtractTest.php @@ -0,0 +1,55 @@ +createMock(DownloadZip::class); + $extractZip = $this->createMock(ExtractZip::class); + $authHelper = $this->createMock(AuthHelper::class); + $unixDownloadAndExtract = new UnixDownloadAndExtract($downloadZip, $extractZip, $authHelper); + + $tmpZipFile = uniqid('tmpZipFile', true); + $extractedPath = uniqid('extractedPath', true); + + $downloadZip->expects(self::once()) + ->method('downloadZipAndReturnLocalPath') + ->with( + self::isInstanceOf(RequestInterface::class), + self::isType('string'), + ) + ->willReturn($tmpZipFile); + + $extractZip->expects(self::once()) + ->method('to') + ->with( + $tmpZipFile, + self::isType('string'), + ) + ->willReturn($extractedPath); + + $downloadUrl = 'https://test-uri/' . uniqid('downloadUrl', true); + $requestedPackage = new Package('foo/bar', '1.2.3', $downloadUrl); + + $downloadedPackage = $unixDownloadAndExtract->__invoke($requestedPackage); + + self::assertSame($requestedPackage, $downloadedPackage->package); + self::assertSame($extractedPath, $downloadedPackage->extractedSourcePath); + } +} diff --git a/test/unit/Downloading/WindowsDownloadAndExtractTest.php b/test/unit/Downloading/WindowsDownloadAndExtractTest.php new file mode 100644 index 0000000..041dfa7 --- /dev/null +++ b/test/unit/Downloading/WindowsDownloadAndExtractTest.php @@ -0,0 +1,69 @@ +createMock(DownloadZip::class); + $extractZip = $this->createMock(ExtractZip::class); + $authHelper = $this->createMock(AuthHelper::class); + $packageReleaseAssets = $this->createMock(PackageReleaseAssets::class); + $windowsDownloadAndExtract = new WindowsDownloadAndExtract( + $downloadZip, + $extractZip, + $authHelper, + $packageReleaseAssets, + ); + + $packageReleaseAssets->expects(self::once()) + ->method('findWindowsDownloadUrlForPackage') + ->with(self::isInstanceOf(Package::class)) + ->willReturn(uniqid('windowsDownloadUrl', true)); + + $tmpZipFile = uniqid('tmpZipFile', true); + $extractedPath = uniqid('extractedPath', true); + + $downloadZip->expects(self::once()) + ->method('downloadZipAndReturnLocalPath') + ->with( + self::isInstanceOf(RequestInterface::class), + self::isType('string'), + ) + ->willReturn($tmpZipFile); + + $extractZip->expects(self::once()) + ->method('to') + ->with( + $tmpZipFile, + self::isType('string'), + ) + ->willReturn($extractedPath); + + $requestedPackage = new Package('foo/bar', '1.2.3', 'https://test-uri/' . uniqid('downloadUrl', true)); + + $downloadedPackage = $windowsDownloadAndExtract->__invoke($requestedPackage); + + self::assertSame($requestedPackage, $downloadedPackage->package); + self::assertStringContainsString(sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pie_downloader_', $downloadedPackage->extractedSourcePath); + } +}