diff --git a/README.md b/README.md index e8d90b0..cd9d840 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ $pkgFile = 'path/to/composer.json'; # or 'package.json' for NPM / Yarn $lockFile = 'path/to/composer.lock' # or 'package-lock.json' for NPM / 'yarn.lock' for Yarn try { - $obj = new Thx($pkgFile, $lockFile); + $obj = Thx::giveBack($pkgFile, $lockFile)->spreadLove(); # Dump package names var_dump($obj->packages()) @@ -49,7 +49,7 @@ try { ## Roadmap - [ ] Add (more sophisticated) tests -- [ ] Gather information using public APIs +- [x] Gather information using public APIs - [ ] Custom `Exception`s - [ ] Provide more methods - [x] Parse yarn v1 lockfiles diff --git a/composer.json b/composer.json index 85461de..932eb49 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Acknowledge the people behind your frontend dependencies - and give thanks!", "type": "library", "license": "MIT", - "version": "0.2.0", + "version": "0.3.0", "keywords": ["gratitude", "appreciation", "gratefulness", "thankfulness"], "homepage": "https://github.com/S1SYPHOS", "scripts": { @@ -18,6 +18,10 @@ "role": "Maintainer" } ], + "require": { + "shieldon/simple-cache": "^1.3", + "guzzlehttp/guzzle": "^7.3" + }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.14", "phpunit/phpunit": "^8.1" diff --git a/lib/Driver.php b/lib/Driver.php index 26c67b2..315a1db 100644 --- a/lib/Driver.php +++ b/lib/Driver.php @@ -3,7 +3,9 @@ namespace S1SYPHOS; +use S1SYPHOS\Traits\Caching; use S1SYPHOS\Traits\Helpers; +use S1SYPHOS\Traits\Remote; abstract class Driver @@ -12,7 +14,9 @@ abstract class Driver * Traits */ + use Caching; use Helpers; + use Remote; /** @@ -43,49 +47,89 @@ abstract class Driver public $mode; + /** + * List of packages not to be processed + * + * @var array + */ + public $blockList = []; + + /** * Constructor * * @param string $dataFile Path to data file * @param string $lockFile Lockfile stream + * @param string $cacheDriver Cache driver + * @param array $cacheSettings Cache settings * @return void */ - public function __construct(array $pkgData, string $lockFile) + public function __construct(array $pkgData, string $lockFile, string $cacheDriver, array $cacheSettings) { - # Load package data - # (1) Extract raw data + # Create cache instance + $this->createCache($cacheDriver, $cacheSettings); + + # Extract raw data $this->data = $this->extract($pkgData, $lockFile); + } - # (2) Process raw data - $this->pkgs = $this->process(); + + /** + * Setters & getters + */ + + public function setBlockList(int $blockList): void + { + $this->blockList = $blockList; + } + + + public function getBlockList(): array + { + return $this->blockList; } /** - * Required methods + * Shared methods */ /** - * Extracts raw data from input files + * Spreads love * - * @param string $dataFile Path to data file - * @param string $lockFile Lockfile contents - * @return array + * @return \S1SYPHOS\Driver */ - abstract protected function extract(array $pkgData, string $lockFile): array; + public function spreadLove(): \S1SYPHOS\Driver + { + # Process raw data + $this->pkgs = $this->process(); + + # Enable chaining + return $this; + } /** - * Processes raw data + * Exports raw package data * - * @return array Processed data + * @return array Raw package data */ - abstract protected function process(): array; + public function data(): array + { + return $this->data; + } /** - * Shared methods + * Exports processed package data + * + * @return array Processed package data */ + public function pkgs(): array + { + return $this->pkgs; + } + /** * Exports package names @@ -95,4 +139,26 @@ abstract protected function process(): array; public function packages(): array { return $this->pluck($this->pkgs, 'name'); } + + + /** + * Required methods + */ + + /** + * Extracts raw data from input files + * + * @param string $dataFile Path to data file + * @param string $lockFile Lockfile contents + * @return array + */ + abstract protected function extract(array $pkgData, string $lockFile): array; + + + /** + * Processes raw data + * + * @return array Processed data + */ + abstract protected function process(): array; } diff --git a/lib/Drivers/Composer.php b/lib/Drivers/Composer.php index bf2da6f..d64a828 100644 --- a/lib/Drivers/Composer.php +++ b/lib/Drivers/Composer.php @@ -20,6 +20,20 @@ class Composer extends Driver public $mode = 'php'; + /** + * List of packages not to be processed + * + * @var array + */ + public $blockList = [ + 'php', + ]; + + + /** + * Methods + */ + /** * Extracts raw data from input files * @@ -51,10 +65,42 @@ protected function extract(array $pkgData, string $lockFile): array protected function process(): array { return array_map(function($pkgName, $pkg) { - return [ - 'name' => $pkgName, - 'version' => str_replace('v', '', strtolower($pkg['version'])), - ]; + $data = []; + + # Build unique caching key + $hash = md5($pkgName); + + # Fetch information about package .. + if ($this->cache->has($hash)) { + # (1) .. from cache (if available) + $data = $this->cache->get($hash); + + $this->fromCache = true; + } + + if (empty($data)) { + # (2) .. from API + # Block unwanted libraries + if (in_array($pkgName, $this->blockList) === true) return false; + + # Prepare data for each repository + $data['name'] = $pkgName; + $data['version'] = str_replace('v', '', strtolower($pkg['version'])); + + # Fetch additional information from https://packagist.org + $response = $this->fetchRemote('https://repo.packagist.org/p/' . $pkgName . '.json'); + $response = json_decode($response, true)['packages'][$pkgName]; + + # Enrich data with results + $data['license'] = $response[$pkg['version']]['license'][0] ?? ''; + $data['description'] = $response[$pkg['version']]['description']; + $data['url'] = static::rtrim($response[$pkg['version']]['source']['url'], '.git'); + + # Cache result + $this->cache->set($hash, $data, $this->days2seconds($this->cacheDuration)); + } + + return $data; }, array_keys($this->data), $this->data); } } diff --git a/lib/Drivers/Node.php b/lib/Drivers/Node.php index 1978818..824df96 100644 --- a/lib/Drivers/Node.php +++ b/lib/Drivers/Node.php @@ -20,6 +20,10 @@ class Node extends Driver public $mode = 'npm'; + /** + * Methods + */ + /** * Extracts raw data from input files * @@ -55,10 +59,60 @@ protected function extract(array $pkgData, string $lockFile): array protected function process(): array { return array_map(function($pkgName, $pkg) { - return [ - 'name' => $pkgName, - 'version' => $pkg['version'], - ]; + return $this->_process($pkgName, $pkg); }, array_keys($this->data), $this->data); } + + + /** + * Processes raw data + * + * @return array Processed data + */ + protected function _process(string $pkgName, array $pkg): array + { + $data = []; + + # Build unique caching key + $hash = md5($pkgName); + + # Fetch information about package .. + if ($this->cache->has($hash)) { + # (1) .. from cache (if available) + $data = $this->cache->get($hash); + + $this->fromCache = true; + } + + if (empty($data)) { + # (2) .. from API + # Block unwanted libraries + if (in_array($pkgName, $this->blockList) === true) return false; + + # Prepare data for each repository + $data['name'] = $pkgName; + $data['version'] = $pkg['version']; + + # Fetch additional information from https://api.npms.io + $response = $this->fetchRemote('https://api.npms.io/v2/package/' . rawurlencode($pkgName)); + $response = json_decode($response)->collected->metadata; + + $data['license'] = $response->license ?? ''; + $data['description'] = $response->description; + $data['url'] = $response->links->repository; + $data['forked'] = false; + + # Check if it's a forked repository + if (preg_match('/(([0-9])+(\.{0,1}([0-9]))*)/', $data['version']) == false) { + # TODO: Check if that's even a thing + # $data['version'] = $data->version; + $data['forked'] = true; + } + + # Cache result + $this->cache->set($hash, $data, $this->days2seconds($this->cacheDuration)); + } + + return $data; + } } diff --git a/lib/Drivers/Yarn.php b/lib/Drivers/Yarn.php index 8b4220a..9dd09f9 100644 --- a/lib/Drivers/Yarn.php +++ b/lib/Drivers/Yarn.php @@ -3,23 +3,15 @@ namespace S1SYPHOS\Drivers; -use S1SYPHOS\Driver; +use S1SYPHOS\Drivers\Node; -class Yarn extends Driver +class Yarn extends Node { /** - * Properties + * Methods */ - /** - * Operating mode identifier - * - * @var string - */ - public $mode = null; - - /** * Extracts raw data from input files * @@ -69,26 +61,6 @@ protected function extract(array $pkgData, string $lockFile): array } - /** - * Processes raw data - * - * @return array Processed data - */ - protected function process(): array - { - return array_map(function($pkgName, $pkg) { - return [ - 'name' => $pkgName, - 'version' => $pkg['version'], - ]; - }, array_keys($this->data), $this->data); - } - - - /** - * Methods - */ - /** * Removes redundant characters from strings * diff --git a/lib/Thx.php b/lib/Thx.php index 36d0b1c..93a2bf4 100644 --- a/lib/Thx.php +++ b/lib/Thx.php @@ -10,17 +10,10 @@ namespace S1SYPHOS; -use S1SYPHOS\Drivers\Composer; -use S1SYPHOS\Drivers\Node; -use S1SYPHOS\Drivers\Yarn; - -use S1SYPHOS\Traits\Helpers; - - /** * Class Thx * - * Provides relevant data to show some love + * Helps to give back & spread the love * * @package php-thx */ @@ -29,46 +22,38 @@ class Thx /** * Current version */ - const VERSION = '0.2.0'; + const VERSION = '0.3.0'; /** * Traits */ - use Helpers; + use \S1SYPHOS\Traits\Helpers; /** - * Properties - */ - - /** - * Selected driver - * - * @var \S1SYPHOS\Driver + * Methods */ - public $driver = null; - /** - * Constructor + * Gives back & shows some love * * @param string $dataFile Datafile, eg 'composer.json' or 'package.json' * @param string $lockFile Lockfile, eg 'composer.lock', 'package-lock.json' or 'yarn.lock' - * @return void + * @return \S1SYPHOS\Driver */ - public function __construct(string $dataFile, string $lockFile) + public static function giveBack(string $dataFile, string $lockFile, string $cacheDriver = 'file', array $cacheSettings = []) { # Validate lockfile $lockFilename = basename($lockFile); if ( - !$this->contains($lockFilename, 'composer') && - !$this->contains($lockFilename, 'yarn') && - !$this->contains($lockFilename, 'package') + !static::contains($lockFilename, 'composer') && + !static::contains($lockFilename, 'yarn') && + !static::contains($lockFilename, 'package') ) { - throw new \Exception(sprintf('Lockfile "%s" could not be recognized.', $lockFilename)); + throw new \Exception(sprintf('Unknown lockfile: "%s".', $lockFilename)); } # Determine package manager @@ -84,7 +69,7 @@ public function __construct(string $dataFile, string $lockFile) throw new \Exception(sprintf('%s does not contain "require".', $dataFilename)); } - $this->driver = new Composer($pkgData, $lockFile); + $class = 'S1SYPHOS\\Drivers\\Composer'; } if ($dataFilename === 'package.json') { @@ -93,51 +78,20 @@ public function __construct(string $dataFile, string $lockFile) } # (1) Yarn - if ($this->contains($lockFilename, 'yarn')) { - $this->driver = new Yarn($pkgData, $lockFile); + if (static::contains($lockFilename, 'yarn')) { + $class = 'S1SYPHOS\\Drivers\\Yarn'; } # (2) NPM - if ($this->contains($lockFilename, 'package')) { - $this->driver = new Node($pkgData, $lockFile); + if (static::contains($lockFilename, 'package')) { + $class = 'S1SYPHOS\\Drivers\\Node'; } } - } - - - /** - * Methods - */ - - /** - * Exports raw package data - * - * @return array Raw package data - */ - public function data(): array - { - return $this->driver->data; - } - - - /** - * Exports processed package data - * - * @return array Processed package data - */ - public function pkgs(): array - { - return $this->driver->pkgs; - } + if (!isset($class)) { + throw new \Exception(sprintf('Unknown datafile: "%s".', $dataFilename)); + } - /** - * Exports package names - * - * @return array Package names - */ - public function packages(): array - { - return $this->driver->packages(); + return new $class($pkgData, $lockFile, $cacheDriver, $cacheSettings); } } diff --git a/lib/Traits/Caching.php b/lib/Traits/Caching.php new file mode 100644 index 0000000..1cdd48e --- /dev/null +++ b/lib/Traits/Caching.php @@ -0,0 +1,142 @@ +cacheDuration = $cacheDuration; + } + + + public function getCacheDuration(): string + { + return $this->cacheDuration; + } + + + /** + * Methods + */ + + /** + * Initializes cache instance + * + * @param string $cacheDriver Cache driver + * @param array $cacheSettings Cache settings + * @return void + */ + protected function createCache(string $cacheDriver = 'file', array $cacheSettings = []) + { + # Initialize cache + # (1) Validate provided cache driver + if (in_array($cacheDriver, $this->cacheDrivers) === false) { + throw new \Exception(sprintf('Cache driver "%s" cannot be initiated', $cacheDriver)); + } + + # (2) Merge caching options with defaults + $cacheSettings = array_merge(['storage' => './.cache'], $cacheSettings); + + # (2) Create path to caching directory (if not existent) when required by cache driver + if (in_array($cacheDriver, ['file', 'sqlite']) === true) { + $this->createDir($cacheSettings['storage']); + } + + # (4) Initialize new cache instance + $this->cache = new \Shieldon\SimpleCache\Cache($cacheDriver, $cacheSettings); + + # (5) Build database if using SQLite for the first time + # TODO: Add check for MySQL, see https://github.com/terrylinooo/simple-cache/issues/8 + if ($cacheDriver === 'sqlite' && !file_exists(join([$cacheSettings['storage'], 'cache.sqlite3']))) { + $cache->rebuild(); + } + } + + + /** + * Creates a new directory + * + * @param string $dir The path for the new directory + * @param bool $recursive Create all parent directories, which don't exist + * @return bool True: the dir has been created, false: creating failed + */ + protected function createDir(string $dir, bool $recursive = true): bool + { + if (empty($dir) === true) { + return false; + } + + if (is_dir($dir) === true) { + return true; + } + + $parent = dirname($dir); + + if ($recursive === true) { + if (is_dir($parent) === false) { + $this->createDir($parent, true); + } + } + + if (is_writable($parent) === false) { + throw new \Exception(sprintf('The directory "%s" cannot be created', $dir)); + } + + return mkdir($dir); + } +} diff --git a/lib/Traits/Helpers.php b/lib/Traits/Helpers.php index b1330f2..ef0adfd 100644 --- a/lib/Traits/Helpers.php +++ b/lib/Traits/Helpers.php @@ -3,7 +3,8 @@ namespace S1SYPHOS\Traits; -Trait Helpers { +Trait Helpers +{ /** * Strings */ @@ -14,7 +15,7 @@ * @param string $string * @return int */ - protected function length(string $string = null): int + protected static function length(string $string = null): int { return mb_strlen($string, 'UTF-8'); } @@ -31,7 +32,7 @@ protected function length(string $string = null): int * @param int $length The min length of values. * @return array An array of found values */ - protected function split($string, string $separator = ',', int $length = 1): array + protected static function split($string, string $separator = ',', int $length = 1): array { if (is_array($string) === true) { return $string; @@ -42,7 +43,7 @@ protected function split($string, string $separator = ',', int $length = 1): arr foreach ($parts as $p) { $p = trim($p); - if ($this->length($p) > 0 && $this->length($p) >= $length) { + if (static::length($p) > 0 && static::length($p) >= $length) { $out[] = $p; } } @@ -59,12 +60,25 @@ protected function split($string, string $separator = ',', int $length = 1): arr * @param bool $caseInsensitive * @return bool */ - protected function contains(string $string = null, string $needle, bool $caseInsensitive = false): bool + protected static function contains(string $string = null, string $needle, bool $caseInsensitive = false): bool { return call_user_func($caseInsensitive === true ? 'stripos' : 'strpos', $string, $needle) !== false; } + /** + * Safe rtrim alternative + * + * @param string $string + * @param string $trim + * @return string + */ + public static function rtrim(string $string, string $trim = ' '): string + { + return preg_replace('!(' . preg_quote($trim) . ')+$!', '', $string); + } + + /** * Arrays */ @@ -76,7 +90,7 @@ protected function contains(string $string = null, string $needle, bool $caseIns * @param string $key The key name of the column to extract * @return array The result array with all values from that column. */ - protected function pluck(array $array, string $key): array + protected static function pluck(array $array, string $key): array { $output = []; @@ -88,4 +102,20 @@ protected function pluck(array $array, string $key): array return $output; } + + + /** + * Miscellaneous + */ + + /** + * Converts days to seconds + * + * @param int $days + * @return int + */ + protected function days2seconds(int $days): int + { + return $days * 24 * 60 * 60; + } } diff --git a/lib/Traits/Remote.php b/lib/Traits/Remote.php new file mode 100644 index 0000000..0775f83 --- /dev/null +++ b/lib/Traits/Remote.php @@ -0,0 +1,78 @@ +timeout = $timeout; + } + + + public function getTimeout(): string + { + return $this->timeout; + } + + + public function setUserAgent(string $userAgent): void + { + $this->userAgent = $userAgent; + } + + + public function getUserAgent(): string + { + return $this->userAgent; + } + + + /** + * Methods + */ + + protected function fetchRemote(string $apiURL): string + { + # Initialize HTTP client + $client = new \GuzzleHttp\Client(['timeout' => $this->timeout]); + + try { + $response = $client->get($apiURL, ['headers' => ['User-Agent' => $this->userAgent]]); + } catch (\GuzzleHttp\Exception\TransferException $e) { + return ''; + } + + if ($response->getStatusCode() === 200) { + return $response->getBody(); + } + + # (3) .. otherwise, transmission *may* have worked + return ''; + } +} diff --git a/test.php b/test.php index fb706e2..c9c16a3 100644 --- a/test.php +++ b/test.php @@ -5,19 +5,19 @@ if (isset($argv[1])) { if ($argv[1] === 'composer') { - $obj = new S1SYPHOS\Thx('tests/composer/composer.json', 'tests/composer/composer.lock'); + $obj = S1SYPHOS\Thx::giveBack('tests/composer/composer.json', 'tests/composer/composer.lock'); } if ($argv[1] === 'yarn1') { - $obj = new S1SYPHOS\Thx('tests/yarn-v1/package.json', 'tests/yarn-v1/yarn.lock'); + $obj = S1SYPHOS\Thx::giveBack('tests/yarn-v1/package.json', 'tests/yarn-v1/yarn.lock'); } if ($argv[1] === 'yarn2') { - $obj = new S1SYPHOS\Thx('tests/yarn-v2/package.json', 'tests/yarn-v2/yarn.lock'); + $obj = S1SYPHOS\Thx::giveBack('tests/yarn-v2/package.json', 'tests/yarn-v2/yarn.lock'); } if ($argv[1] === 'npm') { - $obj = new S1SYPHOS\Thx('tests/npm/package.json', 'tests/npm/package-lock.json'); + $obj = S1SYPHOS\Thx::giveBack('tests/npm/package.json', 'tests/npm/package-lock.json'); } } @@ -33,15 +33,15 @@ echo sprintf('Loading tests for "%s" ..%s', $argv[1], "\n"); if ($argv[2] === 'data') { - var_dump($obj->data()); + var_dump($obj->spreadLove()->data()); } if ($argv[2] === 'pkgs') { - var_dump($obj->pkgs()); + var_dump($obj->spreadLove()->pkgs()); } if ($argv[2] === 'packages') { - var_dump($obj->packages()); + var_dump($obj->spreadLove()->packages()); } } else {