diff --git a/composer.json b/composer.json index 86bd27e..1a2e1ee 100644 --- a/composer.json +++ b/composer.json @@ -29,15 +29,15 @@ "composer/composer": "^2.7.0", "php-http/discovery": "^1.0", "php-http/httplug": "^2.0", - "php-http/message-factory": "^1.0", - "psr/http-client-implementation": "^1.0", + "psr/http-client-implementation": "*", + "psr/http-factory": "^1.0", "psr/http-message": "^1.0", "symfony/process": "^4.3.4 || ^5.0 || ^6.0" }, "require-dev": { "nyholm/psr7": "^1.8", + "php-http/mock-client": "^1.6.0", "symfony/http-client": "^6.0 || ^7.0", - "php-http/message": "^1.0", "phpstan/phpstan": "^1.6", "phpunit/phpunit": "^10.5" }, diff --git a/src/Composer.php b/src/Composer.php index 99370e6..5a82205 100644 --- a/src/Composer.php +++ b/src/Composer.php @@ -9,6 +9,7 @@ use Winter\Packager\Package\DetailedPackage; use Winter\Packager\Package\DetailedVersionedPackage; use Winter\Packager\Package\Package; +use Winter\Packager\Package\Packagist; use Winter\Packager\Package\VersionedPackage; /** @@ -457,4 +458,17 @@ public static function newConstraint(mixed ...$arguments): Constraint $class = static::$packageClasses['constraint']; return new $class(...$arguments); } + + /** + * Set the user agent for the Packagist API requests. + * + * To comply with Packagist's requirements for use of their API, we require that agent names contain a name or + * reference to the system being used, and a contact email address in the format of: + * + * `Name or Reference ` + */ + public static function setAgent(string $agent): void + { + Packagist::setAgent($agent); + } } diff --git a/src/Exceptions/PackagistException.php b/src/Exceptions/PackagistException.php new file mode 100644 index 0000000..5bce76f --- /dev/null +++ b/src/Exceptions/PackagistException.php @@ -0,0 +1,16 @@ + * @since 0.3.0 */ -class DetailedVersionedPackage extends Package +class DetailedVersionedPackage extends DetailedPackage { protected string $versionNormalized; protected string $latestVersionNormalized; @@ -51,7 +51,25 @@ public function __construct( protected string $latestVersion = '', protected VersionStatus $updateStatus = VersionStatus::UP_TO_DATE, ) { - parent::__construct($namespace, $name, $description); + parent::__construct( + $namespace, + $name, + $description, + $type, + $keywords, + $homepage, + $authors, + $licenses, + $support, + $funding, + $requires, + $devRequires, + $extras, + $suggests, + $conflicts, + $replaces, + $readme, + ); $this->versionNormalized = $this->normalizeVersion($version); } diff --git a/src/Package/Package.php b/src/Package/Package.php index e81fe30..50b06ab 100644 --- a/src/Package/Package.php +++ b/src/Package/Package.php @@ -2,6 +2,11 @@ namespace Winter\Packager\Package; +use Http\Discovery\Psr17FactoryDiscovery; +use Http\Discovery\Psr18ClientDiscovery; +use Winter\Packager\Composer; +use Winter\Packager\Exceptions\PackagistException; + /** * Base package class. * @@ -63,10 +68,26 @@ public function getDescription(): string public function toDetailed(): DetailedPackage { - return new DetailedPackage( - $this->namespace, - $this->name, - $this->description + $details = Packagist::getPackage($this->namespace, $this->name); + + return Composer::newDetailedPackage( + namespace: $this->namespace, + name: $this->name, + description: $this->description ?? '', + keywords: $details['keywords'] ?? [], + type: $details['type'] ?? 'library', + homepage: $details['homepage'] ?? '', + authors: $details['authors'] ?? [], + licenses: $details['licenses'] ?? [], + support: $details['support'] ?? [], + funding: $details['funding'] ?? [], + requires: $details['require'] ?? [], + devRequires: $details['require-dev'] ?? [], + extras: $details['extra'] ?? [], + suggests: $details['suggest'] ?? [], + conflicts: $details['conflict'] ?? [], + replaces: $details['replace'] ?? [], + readme: $details['readme'] ?? '' ); } } diff --git a/src/Package/Packagist.php b/src/Package/Packagist.php new file mode 100644 index 0000000..ba51c45 --- /dev/null +++ b/src/Package/Packagist.php @@ -0,0 +1,126 @@ + + * @since 0.3.0 + */ +class Packagist +{ + protected const PACKAGIST_API_URL = 'https://packagist.org/'; + protected const PACKAGIST_REPO_URL = 'https://repo.packagist.org/p2/'; + + protected static string $agent = 'Winter Packager '; + + /** + * Get information on a package in the Packagist API. + * + * @return array + */ + public static function getPackage(string $namespace, string $name, ?string $version = null): array + { + $client = static::getClient(); + $request = static::newRepoRequest($namespace . '/' . $name . '.json'); + + $response = $client->sendRequest($request); + + if ($response->getStatusCode() === 404) { + throw new PackagistException('Package not found'); + } + + if ($response->getStatusCode() !== 200) { + throw new PackagistException('Failed to retrieve package information'); + } + + $body = json_decode($response->getBody()->getContents(), true); + + if (is_null($version)) { + if (!isset($body['packages'][$namespace . '/' . $name][0])) { + throw new PackagistException('Package information not found'); + } + } else { + if (!isset($body['packages'][$namespace . '/' . $name])) { + throw new PackagistException('Package information not found'); + } + + $versions = MetadataMinifier::expand($body['packages'][$namespace . '/' . $name]); + $parser = new VersionParser; + $packageVersionNormalized = $parser->normalize($version); + + foreach ($versions as $packageVersion) { + if ($packageVersion['version_normalized'] === $packageVersionNormalized) { + return $packageVersion; + } + } + + throw new PackagistException('Package version not found'); + } + + return $body['packages'][$namespace . '/' . $name][0]; + } + + public static function getClient(): ClientInterface + { + return Psr18ClientDiscovery::find(); + } + + /** + * Set the user agent for the Packagist API requests. + * + * To comply with Packagist's requirements for use of their API, we require that agent names contain a name or + * reference to the system being used, and a contact email address in the format of: + * + * `Name or Reference ` + */ + public static function setAgent(string $agent): void + { + if (!preg_match('/^(.+) <(.+)>$/', $agent, $matches)) { + throw new \InvalidArgumentException( + 'Agent must be in the format of `Name or Reference `' + ); + } + + [$name, $email] = $matches; + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Agent email address is not valid'); + } + + static::$agent = trim($name) . ' <' . trim($email) . '>'; + } + + public static function newApiRequest(string $url = ''): RequestInterface + { + $request = Psr17FactoryDiscovery::findRequestFactory()->createRequest('GET', self::PACKAGIST_API_URL . ltrim($url, '/')); + $request->withHeader('Accept', 'application/json'); + $request->withHeader('Content-Type', 'application/json'); + $request->withHeader('User-Agent', static::$agent); + + return $request; + } + + public static function newRepoRequest(string $url = ''): RequestInterface + { + $request = Psr17FactoryDiscovery::findRequestFactory()->createRequest('GET', self::PACKAGIST_REPO_URL . ltrim($url, '/')); + $request->withHeader('Accept', 'application/json'); + $request->withHeader('Content-Type', 'application/json'); + $request->withHeader('User-Agent', static::$agent); + + return $request; + } +} diff --git a/src/Package/VersionedPackage.php b/src/Package/VersionedPackage.php index e666093..045963b 100644 --- a/src/Package/VersionedPackage.php +++ b/src/Package/VersionedPackage.php @@ -3,6 +3,7 @@ namespace Winter\Packager\Package; use Composer\Semver\VersionParser; +use Winter\Packager\Composer; use Winter\Packager\Enums\VersionStatus; /** @@ -24,7 +25,7 @@ public function __construct( ) { parent::__construct($namespace, $name, $description); - $this->versionNormalized = $this->normalizeVersion($version); + $this->versionNormalized = $this->normalizeVersion($this->version); } public function setVersion(string $version): void @@ -74,4 +75,30 @@ protected function normalizeVersion(string $version): string $parser = new VersionParser; return $parser->normalize($version); } + + public function toDetailed(): DetailedVersionedPackage + { + $details = Packagist::getPackage($this->namespace, $this->name, $this->version); + + return Composer::newDetailedVersionedPackage( + namespace: $this->namespace, + name: $this->name, + description: $this->description ?? '', + keywords: $details['keywords'] ?? [], + type: $details['type'] ?? 'library', + homepage: $details['homepage'] ?? '', + authors: $details['authors'] ?? [], + licenses: $details['licenses'] ?? [], + support: $details['support'] ?? [], + funding: $details['funding'] ?? [], + requires: $details['require'] ?? [], + devRequires: $details['require-dev'] ?? [], + extras: $details['extra'] ?? [], + suggests: $details['suggest'] ?? [], + conflicts: $details['conflict'] ?? [], + replaces: $details['replace'] ?? [], + readme: $details['readme'] ?? '', + version: $details['version'], + ); + } } diff --git a/tests/Cases/Package/PackageTest.php b/tests/Cases/Package/PackageTest.php new file mode 100644 index 0000000..0967f2b --- /dev/null +++ b/tests/Cases/Package/PackageTest.php @@ -0,0 +1,56 @@ +toDetailed(); + + $this->assertInstanceOf(\Winter\Packager\Package\DetailedPackage::class, $package); + $this->assertEquals('winter', $package->getNamespace()); + $this->assertEquals('wn-pages-plugin', $package->getName()); + $this->assertEquals('winter-plugin', $package->getType()); + $this->assertEquals('https://github.com/wintercms/wn-pages-plugin', $package->getHomepage()); + $this->assertArrayHasKey('installer-name', $package->getExtras()); + $this->assertArrayHasKey('winter', $package->getExtras()); + $this->assertEquals('pages', $package->getExtras()['installer-name']); + } + + /** + * @test + * @testdox it can convert a versioned package to a detailed versioned package + * @covers \Winter\Packager\Package\VersionedPackage::toDetailed + */ + public function itCanConvertAVersionedPackageToADetailedPackage() + { + $package = Composer::newVersionedPackage('winter', 'wn-pages-plugin', '', 'v2.0.3'); + $package = $package->toDetailed(); + + $this->assertInstanceOf(\Winter\Packager\Package\DetailedVersionedPackage::class, $package); + $this->assertEquals('winter', $package->getNamespace()); + $this->assertEquals('wn-pages-plugin', $package->getName()); + $this->assertEquals('winter-plugin', $package->getType()); + $this->assertEquals('https://github.com/wintercms/wn-pages-plugin', $package->getHomepage()); + $this->assertArrayHasKey('installer-name', $package->getExtras()); + $this->assertArrayNotHasKey('winter', $package->getExtras()); + $this->assertEquals('pages', $package->getExtras()['installer-name']); + $this->assertEquals('2.0.3.0', $package->getVersionNormalized()); + } +}