Skip to content

Commit

Permalink
Refactor indirection support
Browse files Browse the repository at this point in the history
Intercept bad configuration, requests, and responses, to throw contextual exceptions to identify the affected private package.
  • Loading branch information
mcaskill committed Aug 9, 2024
1 parent 6ddb3db commit a3e881f
Show file tree
Hide file tree
Showing 3 changed files with 1,231 additions and 37 deletions.
164 changes: 127 additions & 37 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@
namespace FFraenz\PrivateComposerInstaller;

use Composer\Composer;
use Composer\Downloader\TransportException;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Installer\PackageEvent;
use Composer\Installer\PackageEvents;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginEvents;
use Composer\Plugin\PluginInterface;
use Composer\Plugin\PreFileDownloadEvent;
use Composer\Util\Http\Response;
use Composer\Util\HttpDownloader;
use FFraenz\PrivateComposerInstaller\Environment\LoaderFactory;
use FFraenz\PrivateComposerInstaller\Environment\LoaderInterface;
use FFraenz\PrivateComposerInstaller\Environment\RepositoryInterface;
use InvalidArgumentException;
use UnexpectedValueException;

use function array_key_exists;
use function array_merge;
Expand All @@ -24,7 +29,6 @@
use function count;
use function explode;
use function is_array;
use function json_encode;
use function mb_strpos;
use function parse_url;
use function preg_match_all;
Expand Down Expand Up @@ -234,7 +238,7 @@ public function handlePreDownloadEvent(PreFileDownloadEvent $event): void
$version = $package->getPrettyVersion();
$extra = $package->getExtra()['private-composer-installer'] ?? $presetConfig ?? [];

$filteredProcessedUrl = $filteredCacheKey = $this->fulfillVersionPlaceholder(
$filteredCacheKey = $filteredProcessedUrl = $this->fulfillVersionPlaceholder(
$filteredProcessedUrl,
$version
);
Expand All @@ -243,11 +247,12 @@ public function handlePreDownloadEvent(PreFileDownloadEvent $event): void
// Fulfill env placeholders
$filteredProcessedUrl = $this->fulfillPlaceholders($filteredProcessedUrl);

if (isset($extra['indirection'])) {
if (isset($package, $extra['indirection'])) {
$filteredProcessedUrl = $this->fetchIndirection(
$event,
$event->getHttpDownloader(),
$filteredProcessedUrl,
$extra
$package->getName(),
$extra['indirection']
);
}

Expand All @@ -274,61 +279,130 @@ public function handlePreDownloadEvent(PreFileDownloadEvent $event): void
/**
* Handle indirect package download URL.
*
* @param string $url The package intermediary URL.
* @param mixed[] $extra The package extra data.
* @param string $url The package intermediary URL.
* @param mixed[] $options The indirection settings.
* @return string Returns the package download URL on sucess or
* the serialized response from the intermediary on failure.
*/
public function fetchIndirection(PreFileDownloadEvent $event, string $url, array $extra): string
{
$options = [
'http' => array_replace_recursive(['method' => 'GET'], $extra['indirection']['http'] ?? []),
'ssl' => $extra['indirection']['ssl'] ?? [],
];
public function fetchIndirection(
HttpDownloader $httpDownloader,
string $url,
string $packageName,
array $options
): string {
$format = $options['parse']['format'] ?? false;
if ($format !== 'json') {
/**
* @todo [2024-08-06] Add support for RegExp of non-JSON body (such as XML).
* @todo [2024-08-06] Add support for PHP serialized body (such as Gravity Forms).
*/
throw new InvalidArgumentException(sprintf(
'Misconfigured package %s: Option "indirection.parse.format" must be one of "json", received %s',
$packageName,
var_export($format, true)
));
}

$response = $event->getHttpDownloader()->get($url, $options);
$key = $options['parse']['key'] ?? false;
if ($key === false) {
throw new InvalidArgumentException(sprintf(
'Misconfigured package %s: Option "indirection.parse.key" must be a valid property or property path, received %s',
$packageName,
var_export($key, true)
));
}

$format = $extra['indirection']['parse']['format'] ?? false;
if ($format !== 'json') {
// Raw HTML
// @TODO Throw error
// @TODO Future usage of regexp
return $response->getBody();
try {
$response = $httpDownloader->get($url, [
'http' => array_replace_recursive(['method' => 'GET'], $options['http'] ?? []),
'ssl' => $options['ssl'] ?? [],
]);
} catch (TransportException $e) {
if ($e->getCode() === 400) {
$message = sprintf(
'Invalid request to indirect URL for package %s: %s',
$packageName,
$e->getMessage()
);
} elseif (in_array($e->getCode(), [ 401, 403 ])) {
$message = sprintf(
'Invalid authentication to indirect URL for package %s: %s',
$packageName,
$e->getMessage()
);
} else {
$message = sprintf(
'Could not query indirect URL for package %s: %s',
$packageName,
$e->getMessage()
);
}

throw new TransportException($message, $e->getCode(), $e);
}

$data = $response->decodeJson();

$key = $extra['indirection']['parse']['key'] ?? false;
if ($key === false) {
// format=json but no key specified
// @TODO Throw error
return $response->getBody();
if (! $response->getBody()) {
throw new UnexpectedValueException(sprintf(
'Expected a data structure from indirect URL for package %s',
$packageName
));
}

// Look for a succession of possibly nested keys
// within a recursive array from the JSON object
$data = $response->decodeJson();

// Look for literal key in response.
if (array_key_exists($key, $data)) {
// @TODO Throw error
$data = $data[$key];
return is_array($data) ? json_encode($data) : $data;
if (is_string($data[$key]) && parse_url($data[$key], PHP_URL_SCHEME)) {
return $data[$key];
}

throw new UnexpectedValueException(sprintf(
'Expected a URL at property "%s" for package %s, found %s in:' . PHP_EOL . PHP_EOL . '%s',
$key,
$packageName,
var_export($data[$key], true),
self::excerptResponseBody($response)
));
}

// If not a key path, bail early.
if (mb_strpos($key, '.') === false) {
// format=json but no key found
// @TODO Throw error
return $response->getBody();
throw new UnexpectedValueException(sprintf(
'Expected property "%s" for package %s, not found in:' . PHP_EOL . PHP_EOL . '%s',
$key,
$packageName,
self::excerptResponseBody($response)
));
}

// Iterate segments of key path to traverse response.
foreach (explode('.', $key) as $segment) {
if (is_array($data) && array_key_exists($segment, $data)) {
$data = $data[$segment];
} else {
// @TODO Throw error
return $response->getBody();
throw new UnexpectedValueException(sprintf(
'Expected a property path "%s" for package %s, interrupted at "%s", found %s in:' . PHP_EOL . PHP_EOL . '%s',
$key,
$packageName,
$segment,
var_export($data, true),
self::excerptResponseBody($response)
));
}
}

return is_array($data) ? json_encode($data) : $data;
if (is_string($data) && parse_url($data, PHP_URL_SCHEME)) {
return $data;
}

throw new UnexpectedValueException(sprintf(
'Expected a URL at property path "%s" for package %s, found %s in:' . PHP_EOL . PHP_EOL . '%s',
$key,
$packageName,
var_export($data, true),
self::excerptResponseBody($response)
));
}

/**
Expand Down Expand Up @@ -402,6 +476,22 @@ public function identifyPlaceholders(?string $url): array
return array_unique($placeholders);
}

protected static function excerptResponseBody(Response $response): string
{
$body = $response->getBody();
if (! is_string($body)) {
return '<empty response>';
}

$body = trim($body);
$length = mb_strlen($body);
if ($length === 0) {
return '<empty response>';
}

return mb_substr($body, 0, 100) . ($length > 100 ? '...' : '');
}

/**
* Test if this plugin runs within Composer 2.
*/
Expand Down
Loading

0 comments on commit a3e881f

Please sign in to comment.