Skip to content

Commit

Permalink
Add ability to retrieve detailed package information from Packagist API
Browse files Browse the repository at this point in the history
  • Loading branch information
bennothommo committed Apr 9, 2024
1 parent 01a67a9 commit 7838979
Show file tree
Hide file tree
Showing 8 changed files with 288 additions and 10 deletions.
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
14 changes: 14 additions & 0 deletions src/Composer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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 <[email protected]>`
*/
public static function setAgent(string $agent): void
{
Packagist::setAgent($agent);
}
}
16 changes: 16 additions & 0 deletions src/Exceptions/PackagistException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Winter\Packager\Exceptions;

/**
* Packagist exception.
*
* Handles an exception thrown when communicating with the Packagist API.
*
* @author Ben Thomson
* @since 0.3.0
*/
class PackagistException extends PackagerException
{
protected $message = 'Unable to connect to or retrieve a valid response from Packagist.';
}
22 changes: 20 additions & 2 deletions src/Package/DetailedVersionedPackage.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* @author Ben Thomson <[email protected]>
* @since 0.3.0
*/
class DetailedVersionedPackage extends Package
class DetailedVersionedPackage extends DetailedPackage
{
protected string $versionNormalized;
protected string $latestVersionNormalized;
Expand Down Expand Up @@ -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);
}
Expand Down
29 changes: 25 additions & 4 deletions src/Package/Package.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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'] ?? ''
);
}
}
126 changes: 126 additions & 0 deletions src/Package/Packagist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace Winter\Packager\Package;

use Composer\MetadataMinifier\MetadataMinifier;
use Composer\Semver\VersionParser;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Discovery\Psr18ClientDiscovery;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Winter\Packager\Exceptions\PackagistException;

/**
* Packagist class.
*
* Handles connecting to and making requests against the Packagist API. The Packagist API (generally) contains more
* information about a package than Composer offers directly, thus we use it to augment the information retrieved from
* Composer.
*
* @author Ben Thomson <[email protected]>
* @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 <[email protected]>';

/**
* Get information on a package in the Packagist API.
*
* @return array<string, mixed>
*/
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 <[email protected]>`
*/
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 <[email protected]>`'
);
}

[$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;
}
}
29 changes: 28 additions & 1 deletion src/Package/VersionedPackage.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Winter\Packager\Package;

use Composer\Semver\VersionParser;
use Winter\Packager\Composer;
use Winter\Packager\Enums\VersionStatus;

/**
Expand All @@ -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
Expand Down Expand Up @@ -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'],
);
}
}
56 changes: 56 additions & 0 deletions tests/Cases/Package/PackageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Winter\Packager\Tests\Cases\Package;

use Winter\Packager\Composer;
use Winter\Packager\Tests\ComposerTestCase;

/**
* @testdox The Package class
* @coversDefaultClass \Winter\Packager\Package\Package
*/
class PackageTest extends ComposerTestCase
{
/**
* @test
* @testdox it can convert a package to a detailed package
* @covers \Winter\Packager\Package\Package::toDetailed
*/
public function itCanConvertAPackageToADetailedPackage()
{
$package = Composer::newPackage('winter', 'wn-pages-plugin');
$package = $package->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());
}
}

0 comments on commit 7838979

Please sign in to comment.