diff --git a/.github/workflows/validate-packages.yml b/.github/workflows/validate-packages.yml new file mode 100644 index 00000000..dce799ff --- /dev/null +++ b/.github/workflows/validate-packages.yml @@ -0,0 +1,28 @@ +name: Validate ecosystem packages JSON file + +on: + pull_request: + push: + branches: + tags: + +jobs: + validate-packages: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + packages=$(cat ./data/ecosystem/ecosystem-packages.json); + if [ ! -z "$packages" ] && [ $(echo $packages | jq empty > /dev/null 2>&1; echo $?) -eq 0 ]; then + for key in packagistUrl keywords homepage category usage; do + if ! $(echo $packages | jq ".[]" | jq "has(\"$key\")" | jq 'select(. == false)'); then + echo "Invalid JSON. Missing key \"$key\"." + exit 1; + fi + done + echo "Valid JSON." + exit 0; + else + echo "Invalid JSON." + exit 1; + fi diff --git a/.gitignore b/.gitignore index f406aa23..c01da115 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ phpunit.xml public/css/* public/js/* public/share/* +public/images/packages/* var/ vendor/ diff --git a/.platform.app.yaml b/.platform.app.yaml index 6a2aef55..7d248f48 100644 --- a/.platform.app.yaml +++ b/.platform.app.yaml @@ -22,6 +22,12 @@ mounts: 'data/cache': source: local source_path: data/cache + 'data/ecosystem/database': + source: local + source_path: data/ecosystem/database + 'public/images/packages': + source: local + source_path: public/images/packages 'public/share': source: local source_path: public/share @@ -54,6 +60,7 @@ hooks: rm -f data/cache/config-cache.php if [ ! -e data/cache/releases.rss ];then cp templates/releases.rss data/cache/ ;fi ./vendor/bin/laminas repository:generate-data "$PLATFORM_VARIABLES" | base64 --decode | jq '."REPO_TOKEN"' + ./vendor/bin/laminas ecosystem:create-db crons: snapshot: @@ -79,6 +86,21 @@ crons: ./vendor/bin/laminas repository:generate-data "$PLATFORM_VARIABLES" | base64 --decode | jq '."REPO_TOKEN"' fi shutdown_timeout: 20 + generateecosystem: + # Refresh repository data every 6 hours (UTC). + spec: '0 */6 * * *' + commands: + start: | + if [ "$PLATFORM_BRANCH" = master ]; then + ./vendor/bin/laminas ecosystem:seed-db + fi + shutdown_timeout: 20 + +operations: + rebuildEcosystemDatabase: + role: admin + commands: + start: ./vendor/bin/laminas ecosystem:create-db --force-rebuild web: locations: diff --git a/ADD_ECOSYSTEM_PACKAGE.md b/ADD_ECOSYSTEM_PACKAGE.md new file mode 100644 index 00000000..12009ada --- /dev/null +++ b/ADD_ECOSYSTEM_PACKAGE.md @@ -0,0 +1,57 @@ +# Adding your entry to the Laminas Ecosystem + +You can add packages **available via composer** to the `data/ecosystem/ecosystem-packages.json` file by following the steps below: + +- Entries must use the [template](#new-entry-template) as a guide. +- Submit a PR. + +> Use the following command to make sure your submission will be correctly built: + +```bash +composer build +``` + +> The following command can be run individually for testing: + +```bash +./vendor/bin/laminas ecosystem:create-db --github-token= [--force-rebuild] +``` + +> the optional "--force-rebuild" flag will regenerate the database completely, not only add and/or remove packages + +*Used for creating the database.* + +```bash +./vendor/bin/laminas ecosystem:seed-db --github-token= +``` + +*Used for updating the package data every X hours.* + +## New entry template + +```json +{ + "packagistUrl": "", + "keywords": [], + "homepage": "", + "category": "", + "usage": "" +} +``` + +### New entry fields description + +- `packagistUrl` + **string** - the packagist URL of the entry, with no query parameters + +- `keywords` + **array of strings** - user defined keywords used for filtering results + +- `homepage` + **string** - optional URL to package homepage, will overwrite "homepage" field from Packagist Api data + +- `category` + **string** - package category must be one of "skeleton", "integration", "tool" + +- `usage` + **string** - package usage must be one of "mezzio" or "mvc" diff --git a/README.md b/README.md index a58e03b7..9e0d37c2 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,7 @@ Everyone is welcome to post a blog entry. Once submitted, it will be reviewed by If it's rejected, the reason for the rejection will be included, so you can update it and resubmit the post if applicable. The submission process is described in the [ADD_BLOG_ENTRY](ADD_BLOG_ENTRY.md) file. + +## Adding packages to the Laminas Ecosystem + +The [ADD ECOSYSTEM PACKAGE](ADD_ECOSYSTEM_PACKAGE.md) file describes the process of adding packages to the [Laminas Ecosystem](https://getlaminas.org/ecosystem) diff --git a/bootstrap/gulpfile.mjs b/bootstrap/gulpfile.mjs index 5a8b0718..5654d511 100644 --- a/bootstrap/gulpfile.mjs +++ b/bootstrap/gulpfile.mjs @@ -58,7 +58,8 @@ function js() { 'node_modules/jquery/dist/jquery.slim.min.js', 'node_modules/@popperjs/core/dist/umd/popper.min.js', 'node_modules/bootstrap/dist/js/bootstrap.min.js', - 'js/base.js' + 'js/base.js', + 'js/_ecosystem.js' ])) .pipe(concat({path: 'laminas.js'})) .pipe(terser({mangle: false}).on('error', function (e) { diff --git a/bootstrap/js/_ecosystem.js b/bootstrap/js/_ecosystem.js new file mode 100644 index 00000000..7e2443ef --- /dev/null +++ b/bootstrap/js/_ecosystem.js @@ -0,0 +1,102 @@ +'use strict'; + +document.querySelectorAll('.package-button.type, .package-button.category, .package-button.usage').forEach(button => { + button.addEventListener('click', handleFilters); +}) + +document.querySelectorAll('.package-button.keyword').forEach(button => { + button.addEventListener('click', handleKeywords); +}) + +document.querySelectorAll('.ecosystem-filter').forEach(button => { + button.addEventListener('click', removeKeyword); +}) + +document.querySelectorAll('#ecosystem-pagination a').forEach(a => { + const url = new URL(a.href) + for (let [k,v] of new URLSearchParams(window.location.search).entries()) { + if (k === 'keywords[]' || k === 'q' || k === 'type' || k === 'category' || k === 'usage') { + url.searchParams.set(k,v); + } + } + a.href = url.toString(); +}) + +document.querySelector('#clear-filters-button')?.addEventListener('click', function () { + const url = new URL(window.location.href); + + for (let [k,v] of new URLSearchParams(window.location.search).entries()) { + if (k !== 'page') { + url.searchParams.delete(k); + } + } + + window.location.replace(url.toString()); +}); + +document.querySelector('#ecosystem-search-btn').addEventListener('click', function () { + setSearchQuery(document.querySelector('#ecosystem-search').value); +}); + +document.querySelector('#ecosystem-search').addEventListener('keypress', function (e) { + const search = this.value; + if (e.which === 13) { + setSearchQuery(search); + } +}) + +function handleFilters() { + for (const filter of ['type', 'category', 'usage']) { + if (this.classList.contains(filter)) { + handleParams(filter, this.dataset.value); + } + } +} + +function handleParams(filterKey, filterValue) { + const url = new URL(window.location.href); + const params = new URLSearchParams(url.search); + + if (! params.has(filterKey, filterValue)) { + url.searchParams.set(filterKey, filterValue); + + window.location.replace(url.toString()); + } else if (params.get(filterKey) === filterValue) { + url.searchParams.delete(filterKey); + + window.location.replace(url.toString()); + } +} + +function handleKeywords() { + const url = new URL(window.location.href); + const params = new URLSearchParams(url.search); + const keyword = this.dataset.value; + + if (! params.has("keywords[]", keyword)) { + params.append("keywords[]", keyword); + url.search = params.toString(); + + window.location.replace(url.toString()); + } +} + +function removeKeyword() { + const url = new URL(window.location.href); + const params = new URLSearchParams(url.search); + const keyword = this.dataset.value; + + if (params.has("keywords[]", keyword)) { + params.delete("keywords[]", keyword); + url.search = params.toString(); + + window.location.replace(url.toString()); + } +} + +function setSearchQuery(search) { + const url = new URL(window.location.href); + + url.searchParams.set('q', search); + window.location.replace(url.toString()); +} diff --git a/bootstrap/scss/_custom-styles.scss b/bootstrap/scss/_custom-styles.scss index fdb11b3d..c805aeca 100644 --- a/bootstrap/scss/_custom-styles.scss +++ b/bootstrap/scss/_custom-styles.scss @@ -358,3 +358,166 @@ pre[class*=language-] { } } } + +#ecosystem-section { + .keyword { + --filter-button-color: #678799; + } + + #ecosystem-pagination { + margin-top: 1em; + } + + a.package-button { + display: flex; + justify-content: space-between; + } + + .card { + height: 400px; + + .abandoned-package { + position: absolute; + top: 0; + padding-left: 10pt; + background: #ff9102; + width: 100%; + } + + .package-image { + width: 4em; + aspect-ratio: 1; + border-radius: 5%; + } + + .default-package-image { + font-size: 4em; + border-radius: 5%; + } + + .card-header { + display: flex; + flex-direction: row; + padding: .33em; + justify-content: space-between; + + .card-title { + display: flex; + align-items: center; + } + } + + .card-body { + overflow: hidden; + } + + .card-footer { + padding-top: .33em; + padding-bottom: .33em; + .card-homepage { + float: left; + } + .card-updated { + float: right; + } + } + } +} + +.ecosystem-container { + display: flex; + flex-direction: column; + gap: 1em; +} + +#ecosystem-navbar button { + color: #00131e +} + +.ecosystem-package { + display: flex; + gap: 1em; + + border: 1px solid grey; + border-radius: 5px; + + padding: 1em; + + .package-name { + font-size: 1.5em; + font-weight: bold; + } + + .ecosystem-package-details { + display: flex; + flex-direction: column; + flex: 3; + } + .ecosystem-package-statistics { + flex: 1; + border-left: 1px solid grey; + padding-left: 1em; + } + .statistics-item { + margin-bottom: .5em; + } +} + +.details-item { + margin-bottom: 0; +} + +.package-categories-container { + display: flex; + flex-wrap: wrap; + gap: .3em; + margin-bottom: .5em; + + .package-button { + border: 2px solid var(--filter-button-color); + border-radius: 5px; + padding: 0 .25em; + color: var(--filter-button-color); + font-weight: bold; + background-color: white; + } +} + +#ecosystem-filter-container { + display: flex; + flex-direction: row; + gap: .5em; + margin: .5em 0; + + .ecosystem-filter { + border-radius: 5px; + color: white; + background-color: var(--filter-button-color); + padding: 0 .5em; + } +} + +#ecosystem-search-container { + margin: .5em 0; + display: flex; + flex-direction: row; + justify-content: space-between; + + input { + margin-left: 1em; + } +} + +#packageNavbarToggle { + margin-left: 1em; + justify-content: space-between; +} + +#packageNavbarToggle.show, #packageNavbarToggle.collapsing { + @media(max-width:768px) { + display: flex; + flex-direction: column; + gap: .5em; + align-items: baseline; + } +} diff --git a/composer.json b/composer.json index 52434e9b..3f3a3715 100644 --- a/composer.json +++ b/composer.json @@ -35,8 +35,9 @@ }, "require": { "php": "~8.3.0", - "ext-pdo": "*", "ext-curl": "*", + "ext-pdo": "*", + "ext-sqlite3": "*", "dflydev/fig-cookies": "^3.1.0", "laminas/laminas-cli": "^1.11.0", "laminas/laminas-component-installer": "^3.5.0", @@ -76,12 +77,14 @@ "psr-4": { "App\\": "src/App/", "GetLaminas\\Blog\\": "src/Blog/", + "GetLaminas\\Ecosystem\\": "src/Ecosystem/", "GetLaminas\\ReleaseFeed\\": "src/ReleaseFeed/", "GetLaminas\\Security\\": "src/Security/" } }, "autoload-dev": { "psr-4": { + "LaminasTest\\Unit\\": "test/Unit" } }, "scripts": { diff --git a/composer.lock b/composer.lock index 3e51476f..4e34b328 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1ed74b4fb82d276ddf9c7aa5b58a3dbb", + "content-hash": "a36965512c279d30245a06c210966571", "packages": [ { "name": "brick/varexporter", @@ -8237,9 +8237,10 @@ "prefer-lowest": false, "platform": { "php": "~8.3.0", + "ext-curl": "*", "ext-pdo": "*", - "ext-curl": "*" + "ext-sqlite3": "*" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/config/autoload/global.php b/config/autoload/global.php index 28046348..0de25bd2 100644 --- a/config/autoload/global.php +++ b/config/autoload/global.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use GetLaminas\Ecosystem\Console\CreateEcosystemDatabase; + return [ 'release-feed' => [ 'verification_token' => getenv('RELEASE_FEED_TOKEN'), @@ -9,4 +11,9 @@ 'blog' => [ 'db' => 'sqlite:' . realpath(getcwd()) . '/var/blog/posts.db', ], + 'packages' => [ + 'db' => 'sqlite:' . realpath( + getcwd() + ) . sprintf('/%s/%s', CreateEcosystemDatabase::PACKAGES_DB_PATH, CreateEcosystemDatabase::PACKAGES_DB_FILE), + ], ]; diff --git a/config/config.php b/config/config.php index 30403bec..86ce7211 100644 --- a/config/config.php +++ b/config/config.php @@ -36,6 +36,7 @@ class_exists(\Mezzio\Swoole\ConfigProvider::class) // Default App module config GetLaminas\Blog\ConfigProvider::class, + GetLaminas\Ecosystem\ConfigProvider::class, GetLaminas\ReleaseFeed\ConfigProvider::class, GetLaminas\Security\ConfigProvider::class, App\ConfigProvider::class, diff --git a/config/routes.php b/config/routes.php index aac006f1..5747e65b 100644 --- a/config/routes.php +++ b/config/routes.php @@ -41,6 +41,9 @@ // Blog routes (new GetLaminas\Blog\ConfigProvider())->registerRoutes($app, '/blog'); + // Laminas ecosystem routes + (new GetLaminas\Ecosystem\ConfigProvider())->registerRoutes($app); + // Security advisory routes (new GetLaminas\Security\ConfigProvider())->registerRoutes($app, '/security'); diff --git a/data/.gitignore b/data/.gitignore index bead3384..c3e61a61 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1 +1,2 @@ assets.json +ecosystem/database diff --git a/data/ecosystem/ecosystem-packages.json b/data/ecosystem/ecosystem-packages.json new file mode 100644 index 00000000..a343c2c4 --- /dev/null +++ b/data/ecosystem/ecosystem-packages.json @@ -0,0 +1,79 @@ +[ + { + "packagistUrl": "https://packagist.org/packages/akrabat/ip-address-middleware", + "keywords": ["ip", "address", "middleware"], + "homepage": "", + "category": "tool", + "usage": "mezzio" + }, + { + "packagistUrl": "https://packagist.org/packages/netglue/laminas-messenger", + "keywords": ["laminas", "messenger"], + "homepage": "", + "category": "tool", + "usage": "mezzio" + }, + { + "packagistUrl": "https://packagist.org/packages/dotkernel/dot-errorhandler", + "keywords": ["error-handling"], + "homepage": "https://dotkernel.com", + "category": "integration", + "usage": "mezzio" + }, + { + "packagistUrl": "https://packagist.org/packages/roave/psr-container-doctrine", + "keywords": ["middleware", "doctrine"], + "homepage": "", + "category": "integration", + "usage": "mezzio" + }, + { + "packagistUrl": "https://packagist.org/packages/asgrim/mini-mezzio", + "keywords": ["mezzio"], + "homepage": "", + "category": "skeleton", + "usage": "mezzio" + }, + { + "packagistUrl": "https://packagist.org/packages/phly/phly-simple-page", + "keywords": ["mvc"], + "homepage": "https://github.com/phly/PhlySimplePage", + "category": "tool", + "usage": "mvc" + }, + { + "packagistUrl": "https://packagist.org/packages/mezzio/mezzio-aurarouter", + "keywords": ["middleware", "http"], + "homepage": "", + "category": "tool", + "usage": "mezzio" + }, + { + "packagistUrl": "https://packagist.org/packages/dotkernel/api", + "keywords": ["rest", "api"], + "homepage": "", + "category": "skeleton", + "usage": "mezzio" + }, + { + "packagistUrl": "https://packagist.org/packages/dotkernel/frontend", + "keywords": ["frontend-application"], + "homepage": "www.dotkernel.com", + "category": "skeleton", + "usage": "mezzio" + }, + { + "packagistUrl": "https://packagist.org/packages/lm-commons/lmc-rbac-mvc", + "keywords": ["mvc", "rbac", "permissions"], + "homepage": "", + "category": "tool", + "usage": "mvc" + }, + { + "packagistUrl": "https://packagist.org/packages/lm-commons/lmc-cors", + "keywords": ["mvc", "cors"], + "homepage": "", + "category": "tool", + "usage": "mvc" + } +] diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 247dad1f..7c3dde6e 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -225,6 +225,59 @@ template->url('blog.post', ['id' => $post->id])]]> + + + + + + ghToken]]> + + + + + + + + + + + + + ghToken]]> + + + + + + + + + + ghToken]]> + + + + + + + + + + + + + + + + + + + + + + CreateEcosystemPackageFromArray(...)]]> + + @@ -299,4 +352,41 @@ + + + config['dependencies']]]> + config['dependencies']]]> + config['dependencies']]]> + config['ecosystem']]]> + config['laminas-cli']]]> + config['laminas-cli']['commands']]]> + config['laminas-cli']['commands']]]> + config['templates']]]> + config['templates']['paths']['ecosystem'][0]]]> + + + config['dependencies']['delegators']]]> + config['dependencies']['factories']]]> + config['dependencies']['invokables']]]> + config['laminas-cli']['commands']]]> + config['laminas-cli']['commands']]]> + config['templates']['paths']]]> + config['templates']['paths']['ecosystem'][0]]]> + + + + + + + + + + + + + + + + + diff --git a/src/Ecosystem/ConfigProvider.php b/src/Ecosystem/ConfigProvider.php new file mode 100644 index 00000000..f8d19d31 --- /dev/null +++ b/src/Ecosystem/ConfigProvider.php @@ -0,0 +1,85 @@ + $this->getConfig(), + 'dependencies' => $this->getDependencies(), + 'laminas-cli' => $this->getConsoleConfig(), + 'templates' => $this->getTemplateConfig(), + ]; + } + + public function getConfig(): array + { + return [ + 'db' => null, + ]; + } + + public function getDependencies(): array + { + return [ + 'factories' => [ + 'config-packages' => ConfigFactory::class, + EcosystemHandler::class => EcosystemHandlerFactory::class, + PdoMapper::class => MapperFactory::class, + ], + // phpcs:enable + // @codingStandardsIgnoreEnd + 'invokables' => [ + SeedEcosystemDatabase::class => SeedEcosystemDatabase::class, + CreateEcosystemDatabase::class => CreateEcosystemDatabase::class, + ], + 'delegators' => [ + CreateEcosystemDatabase::class => [ + CreateEcosystemDatabaseDelegator::class, + ], + SeedEcosystemDatabase::class => [ + SeedEcosystemDatabaseDelegator::class, + ], + ], + ]; + } + + public function getTemplateConfig(): array + { + return [ + 'paths' => [ + 'ecosystem' => [__DIR__ . '/templates'], + ], + ]; + } + + public function getConsoleConfig(): array + { + return [ + 'commands' => [ + 'ecosystem:seed-db' => SeedEcosystemDatabase::class, + 'ecosystem:create-db' => CreateEcosystemDatabase::class, + ], + ]; + } + + public function registerRoutes(Application $app, string $basePath = '/ecosystem'): void + { + $app->get($basePath . '[/]', EcosystemHandler::class, 'app.ecosystem'); + } +} diff --git a/src/Ecosystem/Console/CreateEcosystemDatabase.php b/src/Ecosystem/Console/CreateEcosystemDatabase.php new file mode 100644 index 00000000..e369cb5a --- /dev/null +++ b/src/Ecosystem/Console/CreateEcosystemDatabase.php @@ -0,0 +1,405 @@ +mapper = $mapper; + } + + protected function configure(): void + { + $this->setName('ecosystem:seed-db'); + $this->setDescription('Generate and seed the "ecosystem packages" database.'); + $this->setHelp('Re-create the blog post database from the post entities.'); + + $this->addOption( + 'path', + 'p', + InputOption::VALUE_REQUIRED, + 'Base path of the application; defaults to current working directory', + realpath(getcwd()) + ); + + $this->addOption( + 'data-path', + 'd', + InputOption::VALUE_REQUIRED, + 'Path to the database file, relative to the --path.', + EcosystemHandler::ECOSYSTEM_DIRECTORY + ); + + $this->addOption( + 'data-file', + 'f', + InputOption::VALUE_REQUIRED, + 'Path to the blog posts, relative to the --path.', + EcosystemHandler::ECOSYSTEM_FILE + ); + + $this->addOption( + 'db-path', + 'b', + InputOption::VALUE_REQUIRED, + 'Path to the database file, relative to the --path.', + sprintf('%s/%s', self::PACKAGES_DB_PATH, self::PACKAGES_DB_FILE) + ); + + $this->addOption( + 'github-token', + 'gt', + InputOption::VALUE_OPTIONAL, + 'GitHub access token', + ); + + $this->addOption( + 'force-rebuild', + 'fr', + InputOption::VALUE_OPTIONAL, + 'Regenerate database file from scratch', + $this->forceRebuild + ); + } + + /** + * @throws Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $basePath = $input->getOption('path'); + assert(is_string($basePath)); + $dataPath = $input->getOption('data-path'); + assert(is_string($dataPath)); + $dataFile = $input->getOption('data-file'); + assert(is_string($dataFile)); + $dbFile = $input->getOption('db-path'); + assert(is_string($dbFile)); + $this->ghToken = $input->getOption('github-token'); + + $this->forceRebuild = $input->getOption('force-rebuild') !== false; + + $path = sprintf( + '%s%s/%s', + $basePath, + $dataPath, + $dataFile + ); + + $io->title('Generating ecosystem packages database'); + $userData = file_get_contents($path); + assert(is_string($userData)); + + $userDataArray = json_decode($userData, true); + assert(is_array($userDataArray)); + + $pdo = $this->createDatabase($dbFile); + $this->initCurl(); + + /** @var array{packagistUrl: string, keywords: array, homepage: string, category: string, usage: string} $userData */ + foreach ($userDataArray as $userData) { + $curlResult = $this->getPackageData($userData); + if ($curlResult === null) { + continue; + } + + $package = $this->createEcosystemPackageFromArray($curlResult); + if ($package === null) { + continue; + } + + $this->insertPackageInDatabase($package, $pdo); + } + + if (! $this->forceRebuild) { + $currentPackages = $this->mapper->getPackagesTitles(); + $removedPackages = array_diff($currentPackages, $this->validPackages); + /** @var string $package */ + foreach ($removedPackages as $package) { + $this->mapper->deletePackageByName($package); + } + } + + $io->success('Created ecosystem packages database'); + + return 0; + } + + public function createDatabase(string $path): PDO + { + if (file_exists($path) && file_get_contents($path) !== '') { + if ($this->forceRebuild) { + unlink($path); + } else { + return new PDO('sqlite:' . $path); + } + } + + if ($path[0] !== '/') { + $path = realpath(getcwd()) . '/' . $path; + } + + $pdo = new PDO('sqlite:' . $path); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->beginTransaction(); + $pdo->exec($this->table); + foreach ($this->indices as $index) { + $pdo->exec($index); + } + + $pdo->commit(); + + return $pdo; + } + + /** + * @phpcs:ignore + * @param array{ + * packagistUrl: string, + * keywords: array, + * homepage: string, + * category: string, + * usage: string + * } $userData + */ + private function getPackageData(array $userData): ?array + { + $urlComponents = []; + preg_match('/packagist.org\/packages\/((?>\w-?)+\/(?>\w-?)+)/i', $userData['packagistUrl'], $urlComponents); + + if (! $this->forceRebuild) { + $this->validPackages[] = $urlComponents[1]; + $existingPackage = $this->mapper->searchPackage($urlComponents[1]); + if ($existingPackage !== null && $existingPackage !== []) { + return null; + } + } + + $packagistUrl = sprintf( + 'https://repo.packagist.org/packages/%s.json', + $urlComponents[1] + ); + curl_setopt($this->curl, CURLOPT_URL, $packagistUrl); + $rawResult = curl_exec($this->curl); + assert(is_string($rawResult)); + + $packagistResult = json_decode($rawResult, true); + assert(is_array($packagistResult)); + /** + * @var array{ + * name: string, + * description: string, + * time: string, + * maintainers: array, + * versions: array, + * type: string, + * repository: string, + * github_stars: int, + * github_watchers: int, + * github_forks: int, + * github_open_issues: int, + * language: string, + * abandoned: string, + * dependents: int, + * suggesters: int, + * downloads: array{total: int, monthly: int, daily: int}, + * favers: int + * } $packageData + */ + $packageData = $packagistResult['package']; + + if (isset($packageData['abandoned'])) { + return null; + } + + if (! $userData['homepage'] || ! filter_var($userData['homepage'], FILTER_VALIDATE_URL)) { + $lastVersion = array_key_first($packageData['versions']); + if ($lastVersion === null) { + $website = ''; + } else { + $lastVersionData = $packageData['versions'][$lastVersion]; + /** @var array $lastVersionData */ + $website = $lastVersionData['homepage'] ?? ''; + } + } else { + $website = $userData['homepage']; + } + + $timestamp = (new DateTimeImmutable())->getTimestamp(); + + return [ + 'id' => uniqid($packageData['name']), + 'name' => $packageData['name'], + 'type' => $packageData['type'], + 'repository' => $packageData['repository'], + 'description' => $packageData['description'], + 'created' => $timestamp, + 'updated' => $timestamp, + 'stars' => $packageData['github_stars'], + 'issues' => $packageData['github_open_issues'], + 'downloads' => $packageData['downloads']['total'], + 'abandoned' => (int) isset($packageData['abandoned']), + 'usage' => $userData['usage'], + 'category' => $userData['category'], + 'packagistUrl' => $userData['packagistUrl'], + 'keywords' => $userData['keywords'] !== [] ? $userData['keywords'] : '', + 'website' => $website, + 'image' => $this->getPackageImage( + str_replace('https://github.com/', '', $packageData['repository']) + ), + ]; + } + + private function insertPackageInDatabase(EcosystemPackage $package, PDO $pdo): void + { + $statement = sprintf( + $this->initial, + $pdo->quote($package->id), + $pdo->quote($package->name), + $pdo->quote($package->type->value), + $pdo->quote($package->packagistUrl), + $pdo->quote($package->repository), + (int) $package->abandoned, + $pdo->quote($package->description), + $pdo->quote($package->usage->value), + $package->created->getTimestamp(), + $package->updated->getTimestamp(), + $pdo->quote($package->category->value), + ! empty($package->keywords) + ? $pdo->quote(strtolower(sprintf('|%s|', implode('|', $package->keywords)))) + : '', + $pdo->quote($package->website), + $package->downloads, + $package->stars, + $package->issues, + $pdo->quote($package->image), + ); + $pdo->exec($statement); + } +} diff --git a/src/Ecosystem/Console/CreateEcosystemDatabaseDelegator.php b/src/Ecosystem/Console/CreateEcosystemDatabaseDelegator.php new file mode 100644 index 00000000..be03ddba --- /dev/null +++ b/src/Ecosystem/Console/CreateEcosystemDatabaseDelegator.php @@ -0,0 +1,34 @@ +get(PdoMapper::class); + assert($pdoMapper instanceof PdoMapper); + + $command->setMapper($pdoMapper); + return $command; + } +} diff --git a/src/Ecosystem/Console/SeedEcosystemDatabase.php b/src/Ecosystem/Console/SeedEcosystemDatabase.php new file mode 100644 index 00000000..9071077c --- /dev/null +++ b/src/Ecosystem/Console/SeedEcosystemDatabase.php @@ -0,0 +1,236 @@ +mapper = $mapper; + } + + protected function configure(): void + { + $this->setName('ecosystem:seed-db'); + $this->setDescription('Generate and seed the "ecosystem packages" database.'); + $this->setHelp('Re-create the ecosystem packages database from the package entities.'); + + $this->addOption( + 'db-path', + 'b', + InputOption::VALUE_REQUIRED, + 'Path to the database file, relative to the --path.', + sprintf('%s/%s', CreateEcosystemDatabase::PACKAGES_DB_PATH, CreateEcosystemDatabase::PACKAGES_DB_FILE) + ); + + $this->addOption( + 'github-token', + 'gt', + InputOption::VALUE_OPTIONAL, + 'GitHub access token', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dbFile = $input->getOption('db-path'); + assert(is_string($dbFile)); + $this->ghToken = $input->getOption('github-token'); + + /** @var array{id: string, name: string, updated: int}|null $packagesDueUpdates */ + $packagesDueUpdates = $this->mapper->fetchPackagesDueUpdates( + new DateTimeImmutable(self::PACKAGE_UPDATE_TIME) + ); + if (empty($packagesDueUpdates)) { + $io->success('No packages need updates'); + + return 0; + } + + $io->title('Updating ecosystem packages database'); + + $pdo = realpath($dbFile); + assert($pdo !== false); + + $pdo = new PDO('sqlite:' . $pdo); + + $pdo->beginTransaction(); + $this->initCurl(); + + /** + * @var array{ + * id: string, + * name: string, + * updated: int + * } $package + */ + foreach ($packagesDueUpdates as $package) { + /** + * @phpcs:ignore + * @param array{ + * name: string, + * repository: string, + * abandoned: string, + * description: string, + * updated: int, + * stars: int, + * issues: int, + * downloads: int, + * image: string, + * id: string + * } $packageData + */ + $packageData = $this->getPackageData($package); + $this->updatePackage($packageData, $pdo); + } + + $pdo->commit(); + + $io->success('Updated ecosystem packages database'); + + return 0; + } + + /** + * @phpcs:ignore + * @param array{ + * id: string, + * name: string, + * updated: int + * } $package + */ + private function getPackageData(array $package): array + { + $packagistUrl = sprintf( + 'https://repo.packagist.org/packages/%s.json', + $package['name'], + ); + curl_setopt($this->curl, CURLOPT_URL, $packagistUrl); + $rawResult = curl_exec($this->curl); + assert(is_string($rawResult)); + $packagistResult = json_decode($rawResult, true); + assert(is_array($packagistResult)); + + /** + * @var array{ + * name: string, + * description: string, + * time: string, + * maintainers: array, + * versions: array, + * type: string, + * repository: string, + * github_stars: int, + * github_watchers: int, + * github_forks: int, + * github_open_issues: int, + * language: string, + * abandoned: string, + * dependents: int, + * suggesters: int, + * downloads: array{total: int, monthly: int, daily: int}, + * favers: int + * } $packageData + */ + $packageData = $packagistResult['package']; + + return [ + 'name' => $packageData['name'], + 'repository' => $packageData['repository'], + 'abandoned' => (int) isset($packageData['abandoned']), + 'description' => $packageData['description'], + 'updated' => (new DateTimeImmutable())->getTimestamp(), + 'stars' => $packageData['github_stars'], + 'issues' => $packageData['github_open_issues'], + 'downloads' => $packageData['downloads']['total'], + 'image' => $this->getPackageImage( + str_replace('https://github.com/', '', $packageData['repository']) + ), + 'id' => $package['id'], + ]; + } + + /** + * @phpcs:ignore + * @param array{ + * name: string, + * repository: string, + * abandoned: int, + * description: string, + * updated: int, + * stars: int, + * issues: int, + * downloads: int, + * image: string, + * id: string + * } $packageData + */ + private function updatePackage(array $packageData, PDO $pdo): void + { + $statement = sprintf( + $this->update, + $pdo->quote($packageData['name']), + $pdo->quote($packageData['repository']), + $packageData['abandoned'], + $pdo->quote($packageData['description']), + $packageData['updated'], + $packageData['downloads'], + $packageData['stars'], + $packageData['issues'], + $pdo->quote($packageData['image']), + $pdo->quote($packageData['id']) + ); + + $pdo->exec($statement); + } +} diff --git a/src/Ecosystem/Console/SeedEcosystemDatabaseDelegator.php b/src/Ecosystem/Console/SeedEcosystemDatabaseDelegator.php new file mode 100644 index 00000000..349cb26e --- /dev/null +++ b/src/Ecosystem/Console/SeedEcosystemDatabaseDelegator.php @@ -0,0 +1,28 @@ +get(PdoMapper::class); + assert($pdoMapper instanceof PdoMapper); + + $command->setMapper($pdoMapper); + return $command; + } +} diff --git a/src/Ecosystem/CreateEcosystemPackageFromArrayTrait.php b/src/Ecosystem/CreateEcosystemPackageFromArrayTrait.php new file mode 100644 index 00000000..f2e67194 --- /dev/null +++ b/src/Ecosystem/CreateEcosystemPackageFromArrayTrait.php @@ -0,0 +1,92 @@ +, + * website: string, + * license: string, + * image: string|null + * } $packageData + * @throws Exception + */ + protected function createEcosystemPackageFromArray(array $packageData): ?EcosystemPackage + { + $category = EcosystemCategoryEnum::tryFrom(trim($packageData['category'])); + $type = EcosystemTypeEnum::tryFrom(trim($packageData['type'])); + $usage = EcosystemUsageEnum::tryFrom(trim($packageData['usage'])); + + if ($category === null || $type === null || $usage === null) { + return null; + } + + $created = $this->createDateTimeFromString((string) $packageData['created']); + $updated = $packageData['updated'] && $packageData['updated'] !== $packageData['created'] + ? $this->createDateTimeFromString((string) $packageData['updated']) + : $created; + + return new EcosystemPackage( + $packageData['id'], + $packageData['name'], + $type, + $packageData['packagistUrl'], + $packageData['repository'], + (bool) $packageData['abandoned'], + $packageData['description'], + $usage, + $created, + $updated, + $category, + is_array($packageData['keywords']) + ? $packageData['keywords'] + : explode('|', trim($packageData['keywords'], '|')), + $packageData['website'] ?? '', + $packageData['downloads'], + $packageData['stars'], + $packageData['issues'], + $packageData['image'] ?? '0' + ); + } + + /** + * @throws Exception + */ + protected function createDateTimeFromString(string $dateString): DateTimeImmutable + { + return is_numeric($dateString) + ? new DateTimeImmutable('@' . $dateString, new DateTimeZone('America/Chicago')) + : new DateTimeImmutable($dateString); + } +} diff --git a/src/Ecosystem/EcosystemConnectionTrait.php b/src/Ecosystem/EcosystemConnectionTrait.php new file mode 100644 index 00000000..9d2a84b8 --- /dev/null +++ b/src/Ecosystem/EcosystemConnectionTrait.php @@ -0,0 +1,115 @@ +ghToken === null || $this->ghToken === '') { + $variables = json_decode(base64_decode($_ENV['PLATFORM_VARIABLES']), true); + assert(is_array($variables)); + assert(isset($variables['REPO_TOKEN'])); + + $this->ghToken = $variables['REPO_TOKEN']; + } + assert(is_string($this->ghToken)); + + $headers = [ + 'Accept: application/vnd.github+json', + 'X-GitHub-Api-Version: 2022-11-28', + 'User-Agent: getlaminas.org', + ]; + + $this->curl = curl_init(); + curl_setopt($this->curl, CURLOPT_HTTPHEADER, $headers); + curl_setopt($this->curl, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($this->curl, CURLOPT_RETURNTRANSFER, 1); + + $githubHeaders = [ + 'Accept: application/vnd.github+json', + 'Authorization: Bearer ' . $this->ghToken, + 'X-GitHub-Api-Version: 2022-11-28', + 'User-Agent: getlaminas.org', + ]; + + $this->githubCurl = curl_init(); + curl_setopt($this->githubCurl, CURLOPT_HTTPHEADER, $githubHeaders); + curl_setopt($this->githubCurl, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($this->githubCurl, CURLOPT_RETURNTRANSFER, 1); + } + + private function getPackageImage(string $package): string + { + $packageId = explode('/', $package); + $graphQlQuery = sprintf( + '{"query": "query {repository(owner: \"%s\", name: \"%s\"){owner{avatarUrl}}}"}', + $packageId[0], + $packageId[1] + ); + + curl_setopt($this->githubCurl, CURLOPT_URL, 'https://api.github.com/graphql'); + curl_setopt($this->githubCurl, CURLOPT_POST, true); + curl_setopt($this->githubCurl, CURLOPT_POSTFIELDS, $graphQlQuery); + + $rawResult = curl_exec($this->githubCurl); + assert(is_string($rawResult)); + + /** @var array{data: array{repository: array{owner: array{avatarUrl: string}}}}|null $githubResult */ + $githubResult = json_decode($rawResult, true); + $image = ''; + + if ($githubResult === null) { + return $image; + } + + return $this->cachePackageOwnerAvatar( + $githubResult['data']['repository']['owner']['avatarUrl'], + sprintf('%s-%s.png', $packageId[0], $packageId[1]) + ); + } + + private function cachePackageOwnerAvatar(string $avatarUrl, string $file): string + { + try { + $ch = curl_init($avatarUrl); + $fp = fopen('public/images/packages/' . $file, 'wb'); + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_HEADER, 0); + curl_exec($ch); + curl_close($ch); + fclose($fp); + } catch (Exception $exception) { + return ''; + } + + return $file; + } +} diff --git a/src/Ecosystem/EcosystemPackage.php b/src/Ecosystem/EcosystemPackage.php new file mode 100644 index 00000000..6b46e28a --- /dev/null +++ b/src/Ecosystem/EcosystemPackage.php @@ -0,0 +1,37 @@ + $keywords + */ + public function __construct( + public string $id, + public string $name, + public EcosystemTypeEnum $type, + public string $packagistUrl, + public string $repository, + public bool $abandoned, + public string $description, + public EcosystemUsageEnum $usage, + public DateTimeInterface $created, + public DateTimeInterface $updated, + public EcosystemCategoryEnum $category, + public array $keywords, + public string $website, + public int $downloads, + public int $stars, + public int $issues, + public string $image, + ) { + } +} diff --git a/src/Ecosystem/Enums/EcosystemCategoryEnum.php b/src/Ecosystem/Enums/EcosystemCategoryEnum.php new file mode 100644 index 00000000..0843fbab --- /dev/null +++ b/src/Ecosystem/Enums/EcosystemCategoryEnum.php @@ -0,0 +1,12 @@ +getQueryParams(); + + $keywords = $queryParams['keywords'] ?? []; + assert(is_array($keywords)); + $type = $queryParams['type'] ?? ''; + assert(is_string($type)); + $type = EcosystemTypeEnum::tryFrom($type)?->name; + $category = $queryParams['category'] ?? ''; + assert(is_string($category)); + $category = EcosystemCategoryEnum::tryFrom($category)?->name; + $usage = $queryParams['usage'] ?? ''; + assert(is_string($usage)); + $usage = EcosystemUsageEnum::tryFrom($usage)?->name; + $search = $queryParams['q'] ?? ''; + assert(is_string($search)); + + $packages = $this->ecosystemMapper->fetchAllByFilters( + [ + 'keywords' => $keywords !== [] + ? array_map( + fn (string $keyword) => strtolower($keyword), + $keywords + ) : null, + 'type' => [$type], + 'category' => [$category], + 'usage' => [$usage], + ], + $search + ); + + $path = $request->getAttribute('originalRequest', $request)->getUri()->getPath(); + assert(is_string($path)); + $page = $this->getPageFromRequest($request); + $packages->setItemCountPerPage(9); + + // If the requested page is later than the last, redirect to the last + // keep set keyword and search queries + if (count($packages) && $page > count($packages)) { + $keywordsQuery = ''; + if ($keywords !== []) { + $keywordsQuery = '&keywords[]=' . implode("&keywords[]=", $keywords); + } + + $searchQuery = ''; + if ($search !== '') { + $searchQuery = '&q=' . strtolower($search); + } + + $typeQuery = ''; + if ($type !== null) { + $typeQuery = '&type=' . strtolower($type); + } + + $categoryQuery = ''; + if ($category !== null) { + $categoryQuery = '&category=' . strtolower($category); + } + + $usageQuery = ''; + if ($usage !== null) { + $usageQuery = '&usage=' . strtolower($usage); + } + + return new RedirectResponse( + sprintf( + '%s?page=%d%s%s%s%s%s', + $path, + count($packages), + $keywordsQuery, + $searchQuery, + $typeQuery, + $categoryQuery, + $usageQuery + ) + ); + } + + $packages->setCurrentPageNumber($page); + + return new HtmlResponse($this->renderer->render( + 'ecosystem::list', + $this->prepareView( + $packages->getItemsByPage($page), + $this->preparePagination($path, $page, $packages->getPages()), + $keywords, + $search, + $type, + $category, + $usage + ), + )); + } + + private function getPageFromRequest(ServerRequestInterface $request): int + { + $page = $request->getQueryParams()['page'] ?? 1; + $page = (int) $page; + return max($page, 1); + } + + private function preparePagination(string $path, int $page, object $pagination): object + { + $pagination->base_path = $path; + $pagination->is_first = $page === $pagination->first; + $pagination->is_last = $page === $pagination->last; + + $pages = []; + for ($i = (int) $pagination->firstPageInRange; $i <= (int) $pagination->lastPageInRange; $i += 1) { + $pages[] = [ + 'base_path' => $path, + 'number' => $i, + 'current' => $page === $i, + ]; + } + $pagination->pages = $pages; + + return $pagination; + } + + /** + * @param iterable $entries + * @psalm-return array + */ + private function prepareView( + iterable $entries, + object $pagination, + array $keywords, + string $search, + ?string $typeQuery, + ?string $categoryQuery, + ?string $usageQuery + ): array { + return [ + ...[ + 'ecosystemPackages' => ArrayUtils::iteratorToArray($entries, false), + 'pagination' => $pagination, + 'keywords' => $keywords, + 'search' => $search, + 'type' => $typeQuery, + 'category' => $categoryQuery, + 'usage' => $usageQuery, + ], + ]; + } +} diff --git a/src/Ecosystem/Handler/EcosystemHandlerFactory.php b/src/Ecosystem/Handler/EcosystemHandlerFactory.php new file mode 100644 index 00000000..21d64b0a --- /dev/null +++ b/src/Ecosystem/Handler/EcosystemHandlerFactory.php @@ -0,0 +1,32 @@ +get(PdoMapper::class); + assert($mapper instanceof MapperInterface); + + $renderer = $container->get(TemplateRendererInterface::class); + assert($renderer instanceof TemplateRendererInterface); + + return new EcosystemHandler($mapper, $renderer); + } +} diff --git a/src/Ecosystem/Mapper/MapperFactory.php b/src/Ecosystem/Mapper/MapperFactory.php new file mode 100644 index 00000000..4af73bc1 --- /dev/null +++ b/src/Ecosystem/Mapper/MapperFactory.php @@ -0,0 +1,31 @@ +get('config-packages') ?? []; + assert(is_array($config)); + + $pdo = new PDO($config['db'] ?? ''); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + return new PdoMapper($pdo); + } +} diff --git a/src/Ecosystem/Mapper/MapperInterface.php b/src/Ecosystem/Mapper/MapperInterface.php new file mode 100644 index 00000000..ed2e5ef1 --- /dev/null +++ b/src/Ecosystem/Mapper/MapperInterface.php @@ -0,0 +1,27 @@ + */ + public function fetchAll(): Paginator; + + /** @return Paginator */ + public function fetchAllByFilters(array $filters, string $search = ''): Paginator; + + /** @return Paginator */ + public function fetchAllByKeyword(string $keyword): Paginator; + + public function getPackagesTitles(): array; + + public function deletePackageByName(string $package): bool; + + public function fetchPackagesDueUpdates(DateTimeImmutable $updated): ?array; +} diff --git a/src/Ecosystem/Mapper/PdoMapper.php b/src/Ecosystem/Mapper/PdoMapper.php new file mode 100644 index 00000000..b5a6e09a --- /dev/null +++ b/src/Ecosystem/Mapper/PdoMapper.php @@ -0,0 +1,139 @@ +preparePaginator($select, $count); + } + + public function fetchAllByFilters(array $filters, string $search = ''): Paginator + { + $select = 'SELECT * FROM packages'; + $count = 'SELECT COUNT(id) FROM packages'; + + $values = []; + + /** + * @var string $filterType + * @var array|null $filterValues + */ + foreach ($filters as $filterType => $filterValues) { + if ($filterValues === null) { + continue; + } + + if (property_exists(EcosystemPackage::class, $filterType)) { + foreach ($filterValues as $filterValue) { + $where = (empty($values) ? ' WHERE ' : ' AND ') . $filterType . ' LIKE :' . $filterType; + $select .= $where; + $count .= $where; + + $values[':' . $filterType] = '%' . $filterValue . '%'; + } + } + } + + if ($search !== '') { + $select .= (empty($values) ? ' WHERE ' : ' AND ') . 'name LIKE :search'; + $count .= (empty($values) ? ' WHERE ' : ' AND ') . 'name LIKE :search'; + $values[':search'] = '%' . $search . '%'; + } + + $select .= ' ORDER BY downloads DESC LIMIT :offset, :limit'; + + return $this->preparePaginator( + $select, + $count, + $values + ); + } + + public function fetchAllByKeyword(string $keyword): Paginator + { + $select = 'SELECT * FROM packages ' + . 'WHERE keywords LIKE :keyword ' + . 'ORDER BY downloads ' + . 'DESC LIMIT :offset, :limit'; + $count = 'SELECT COUNT(id) FROM packages WHERE keywords LIKE :keyword'; + return $this->preparePaginator($select, $count, [':tag' => sprintf('%%|%s|%%', $keyword)]); + } + + public function getPackagesTitles(): array + { + $select = $this->pdo->prepare('SELECT name from packages;'); + if (! $select->execute()) { + return []; + } + + return $select->fetchAll(PDO::FETCH_COLUMN); + } + + public function deletePackageByName(string $package): bool + { + $select = $this->pdo->prepare('DELETE from packages WHERE name = :name;'); + if (! $select->execute([':name' => $package])) { + return false; + } + + return true; + } + + /** + * @return Paginator + * @param array $params + */ + private function preparePaginator(string $select, string $count, array $params = []): Paginator + { + $select = $this->pdo->prepare($select); + $count = $this->pdo->prepare($count); + return new Paginator(new PdoPaginator( + $select, + $count, + $params + )); + } + + public function fetchPackagesDueUpdates(DateTimeImmutable $updated): ?array + { + $select = $this->pdo->prepare('SELECT id, name, updated FROM packages WHERE updated <= :updated '); + + if (! $select->execute([':updated' => $updated->getTimestamp()])) { + return null; + } + + return $select->fetchAll(); + } + + public function searchPackage(string $search): ?array + { + $select = $this->pdo->prepare('SELECT name FROM packages WHERE name = :search'); + + if (! $select->execute([':search' => $search])) { + return null; + } + + return $select->fetchAll(); + } +} diff --git a/src/Ecosystem/Mapper/PdoPaginator.php b/src/Ecosystem/Mapper/PdoPaginator.php new file mode 100644 index 00000000..b4f17a54 --- /dev/null +++ b/src/Ecosystem/Mapper/PdoPaginator.php @@ -0,0 +1,64 @@ + */ +class PdoPaginator implements AdapterInterface +{ + use CreateEcosystemPackageFromArrayTrait; + + /** @param array $params */ + protected array $params; + + public function __construct( + protected PDOStatement $select, + protected PDOStatement $count, + array $params = [], + ) { + $this->params = $params; + } + + /** @inheritDoc */ + #[Override] + public function getItems($offset, $itemCountPerPage): array + { + $params = array_merge($this->params, [ + ':offset' => $offset, + ':limit' => $itemCountPerPage, + ]); + + $result = $this->select->execute($params); + + if (! $result) { + throw new RuntimeException('Failed to fetch items from database'); + } + + return array_map( + $this->CreateEcosystemPackageFromArray(...), + $this->select->fetchAll(PDO::FETCH_ASSOC) + ); + } + + #[Override] + public function count(): int + { + $result = $this->count->execute($this->params); + if (! $result) { + throw new RuntimeException('Failed to fetch count from database'); + } + return (int) $this->count->fetchColumn(); + } +} diff --git a/src/Ecosystem/templates/list.phtml b/src/Ecosystem/templates/list.phtml new file mode 100644 index 00000000..98cbca61 --- /dev/null +++ b/src/Ecosystem/templates/list.phtml @@ -0,0 +1,190 @@ + $ecosystemPackages + * @var stdClass $pagination + * @var array $keywords + * @var string $search + * @var string $type + * @var string $category + * @var string $usage + */ + +use GetLaminas\Ecosystem\Enums\EcosystemCategoryEnum; +use GetLaminas\Ecosystem\Enums\EcosystemTypeEnum; +use GetLaminas\Ecosystem\Enums\EcosystemUsageEnum; + +$this->layout('layout::default', ['title' => 'Laminas Ecosystem']); +?> +
+
+
+

Laminas Ecosystem

+
Third party packages which provide explicit support for Laminas packages
+
+ + + +
+ +

+ +
+
+ +
+ +
+
+
+ abandoned) : ?> +

+ This package has been abandoned +

+ +
+
+ +

name ?>

+
+
+ image)) :?> + image ?>" class="col-md-3 package-image" alt="..."> + + category === EcosystemCategoryEnum::Skeleton) : ?> + + category === EcosystemCategoryEnum::Integration) : ?> + + category === EcosystemCategoryEnum::Tool) : ?> + + + +
+
+

Type: type->value ?>

+

Usage: usage->value ?>

+

Category: category->value ?>

+
+ + keywords)) :?> +
+ keywords as $keyword) : + if (empty($keyword)) { + continue; + } + ?> + + +
+ + +
+
+ repository) : ?> +

+ + Source +

+ +

downloads ?>

+

stars ?>

+
+

description ?>

+
+
+ +
+
+ +
+
+ insert('partials::pagination', ['pagination' => $pagination]) ?> +
+
diff --git a/templates/partials/footer.phtml b/templates/partials/footer.phtml index b461cf93..c58514f2 100644 --- a/templates/partials/footer.phtml +++ b/templates/partials/footer.phtml @@ -26,6 +26,7 @@ declare(strict_types=1);
  • MVC MVC for enterprise applications
  • API Tools Build RESTful APIs in minutes
  • Maintenance Overview Current maintenance status of Laminas & Mezzio packages
  • +
  • Laminas ecosystem List of packages using Laminas & Mezzio components
  • diff --git a/templates/partials/pagination.phtml b/templates/partials/pagination.phtml index edd3d0e5..226456e5 100644 --- a/templates/partials/pagination.phtml +++ b/templates/partials/pagination.phtml @@ -15,33 +15,33 @@ declare(strict_types=1); */ ?> pages) > 1): ?> -
    - +
    + diff --git a/test/Unit/Ecosystem/CommonTestCase.php b/test/Unit/Ecosystem/CommonTestCase.php new file mode 100644 index 00000000..d07784d2 --- /dev/null +++ b/test/Unit/Ecosystem/CommonTestCase.php @@ -0,0 +1,20 @@ +pdo = new PDO('sqlite:' . $this->testDb); + } +} diff --git a/test/Unit/Ecosystem/ConfigProviderTest.php b/test/Unit/Ecosystem/ConfigProviderTest.php new file mode 100644 index 00000000..1e0ca1e3 --- /dev/null +++ b/test/Unit/Ecosystem/ConfigProviderTest.php @@ -0,0 +1,85 @@ +config = (new ConfigProvider())(); + } + + public function testConfigHasKeys(): void + { + $this->assertArrayHasKey('ecosystem', $this->config); + $this->assertArrayHasKey('dependencies', $this->config); + $this->assertArrayHasKey('laminas-cli', $this->config); + $this->assertArrayHasKey('templates', $this->config); + } + + public function testEcosystemHasDb(): void + { + $this->assertArrayHasKey('db', $this->config['ecosystem']); + } + + public function testDependenciesHasFactories(): void + { + $this->assertArrayHasKey('factories', $this->config['dependencies']); + $this->assertIsArray($this->config['dependencies']['factories']); + $this->assertArrayHasKey('config-packages', $this->config['dependencies']['factories']); + $this->assertArrayHasKey(EcosystemHandler::class, $this->config['dependencies']['factories']); + $this->assertArrayHasKey(PdoMapper::class, $this->config['dependencies']['factories']); + } + + public function testDependenciesHasDelegators(): void + { + $this->assertArrayHasKey('delegators', $this->config['dependencies']); + $this->assertIsArray($this->config['dependencies']['delegators']); + $this->assertArrayHasKey(CreateEcosystemDatabase::class, $this->config['dependencies']['delegators']); + $this->assertArrayHasKey(SeedEcosystemDatabase::class, $this->config['dependencies']['delegators']); + } + + public function testDependenciesHasInvokables(): void + { + $this->assertArrayHasKey('invokables', $this->config['dependencies']); + $this->assertIsArray($this->config['dependencies']['invokables']); + $this->assertArrayHasKey(SeedEcosystemDatabase::class, $this->config['dependencies']['invokables']); + $this->assertArrayHasKey(CreateEcosystemDatabase::class, $this->config['dependencies']['invokables']); + } + + public function testCommandsAreRegistered(): void + { + $this->assertArrayHasKey('commands', $this->config['laminas-cli']); + $this->assertContains( + SeedEcosystemDatabase::class, + $this->config['laminas-cli']['commands'] + ); + $this->assertContains( + CreateEcosystemDatabase::class, + $this->config['laminas-cli']['commands'] + ); + } + + public function testGetTemplates(): void + { + $this->assertArrayHasKey('paths', $this->config['templates']); + $this->assertIsArray($this->config['templates']['paths']); + $this->assertArrayHasKey('ecosystem', $this->config['templates']['paths']); + $this->assertDirectoryExists($this->config['templates']['paths']['ecosystem'][0]); + } +} diff --git a/test/Unit/Ecosystem/Console/CreateEcosystemDatabaseDelegatorTest.php b/test/Unit/Ecosystem/Console/CreateEcosystemDatabaseDelegatorTest.php new file mode 100644 index 00000000..74b2dcd2 --- /dev/null +++ b/test/Unit/Ecosystem/Console/CreateEcosystemDatabaseDelegatorTest.php @@ -0,0 +1,49 @@ +createMock(ContainerInterface::class); + $pdoMapper = $this->createMock(PdoMapper::class); + + $container->expects($this->once()) + ->method('get') + ->willReturn($pdoMapper); + + $instance = new CreateEcosystemDatabase(); + + $command = (new CreateEcosystemDatabaseDelegator())( + $container, + '', + function () use ($instance) { + return $instance; + } + ); + + $reflectionMapper = (new ReflectionProperty($command, 'mapper'))->getValue($command); + + $this->assertInstanceOf(PdoMapper::class, $reflectionMapper); + } +} diff --git a/test/Unit/Ecosystem/Console/CreateEcosystemDatabaseTest.php b/test/Unit/Ecosystem/Console/CreateEcosystemDatabaseTest.php new file mode 100644 index 00000000..895ffbb1 --- /dev/null +++ b/test/Unit/Ecosystem/Console/CreateEcosystemDatabaseTest.php @@ -0,0 +1,31 @@ +subject = $this->createMock(CreateEcosystemDatabase::class); + } + + public function testWillCreateDatabase(): void + { + $this->assertInstanceOf(PDO::class, $this->subject->createDatabase( + 'sqlite:' . $this->testDb + )); + } +} diff --git a/test/Unit/Ecosystem/Console/SeedEcosystemDatabaseDelegatorTest.php b/test/Unit/Ecosystem/Console/SeedEcosystemDatabaseDelegatorTest.php new file mode 100644 index 00000000..1fcfbf4f --- /dev/null +++ b/test/Unit/Ecosystem/Console/SeedEcosystemDatabaseDelegatorTest.php @@ -0,0 +1,45 @@ +createMock(ContainerInterface::class); + $pdoMapper = $this->createMock(PdoMapper::class); + + $container->expects($this->once()) + ->method('get') + ->willReturn($pdoMapper); + + $instance = new SeedEcosystemDatabase(); + + $command = (new SeedEcosystemDatabaseDelegator())( + $container, + '', + function () use ($instance) { + return $instance; + } + ); + + $reflectionMapper = (new ReflectionProperty($command, 'mapper'))->getValue($command); + + $this->assertInstanceOf(PdoMapper::class, $reflectionMapper); + } +} diff --git a/test/Unit/Ecosystem/CreateEcosystemPackageFromArrayTraitTest.php b/test/Unit/Ecosystem/CreateEcosystemPackageFromArrayTraitTest.php new file mode 100644 index 00000000..a9351747 --- /dev/null +++ b/test/Unit/Ecosystem/CreateEcosystemPackageFromArrayTraitTest.php @@ -0,0 +1,93 @@ + [[ + 'category' => 'invalid category', + 'type' => EcosystemTypeEnum::Library->value, + 'usage' => EcosystemUsageEnum::Mezzio->value, + ]], + 'invalidTypeArray' => [[ + 'category' => EcosystemCategoryEnum::Integration->value, + 'type' => 'invalid type', + 'usage' => EcosystemUsageEnum::Mezzio->value, + ]], + 'invalidUsageArray' => [[ + 'category' => EcosystemCategoryEnum::Integration->value, + 'type' => EcosystemTypeEnum::Library->value, + 'usage' => 'invalid usage', + ]], + ]; + } + + /** + * @throws Exception + */ + #[DataProvider('failingPackageDataProvider')] + public function testWillNotCreateEcosystemPackageWithInvalidData(array $data): void + { + $this->assertNull($this->createEcosystemPackageFromArray($data)); + } + + /** + * @throws Exception + */ + public function testWillReturnValidObjectWithValidData(): void + { + $package = $this->createEcosystemPackageFromArray([ + 'id' => "uniqueId", + 'name' => "vendorName/packageName", + 'type' => "library", + 'packagistUrl' => "packagistUrl", + 'repository' => "repositoryUrl", + 'description' => "", + 'usage' => "mezzio", + 'created' => 1736408707, + 'updated' => 1736408707, + 'category' => "tool", + 'stars' => 10, + 'issues' => 1, + 'downloads' => 100, + 'abandoned' => 0, + 'keywords' => ['user-defined', 'keywords'], + 'website' => 'user-defined website', + 'license' => 'MIT', + 'image' => '' + ]); + + $this->assertInstanceOf(EcosystemPackage::class, $package); + $this->assertInstanceOf(EcosystemCategoryEnum::class, $package->category); + $this->assertInstanceOf(EcosystemTypeEnum::class, $package->type); + $this->assertInstanceOf(EcosystemUsageEnum::class, $package->usage); + } + + /** + * @throws Exception + */ + public function testWillCreateDateTime(): void + { + $this->assertEquals( + new DateTimeImmutable('01-01-2025'), + $this->createDateTimeFromString('01-01-2025') + ); + } +} diff --git a/test/Unit/Ecosystem/Handler/EcosystemHandlerFactoryTest.php b/test/Unit/Ecosystem/Handler/EcosystemHandlerFactoryTest.php new file mode 100644 index 00000000..33a5dcaf --- /dev/null +++ b/test/Unit/Ecosystem/Handler/EcosystemHandlerFactoryTest.php @@ -0,0 +1,36 @@ +createMock(ContainerInterface::class); + $pdoMapper = $this->createMock(PdoMapper::class); + $renderer = $this->createMock(TemplateRendererInterface::class); + + $container->expects($this->exactly(2)) + ->method('get') + ->willReturnOnConsecutiveCalls($pdoMapper, $renderer); + + $this->assertInstanceOf(EcosystemHandler::class, (new EcosystemHandlerFactory())($container)); + } +} diff --git a/test/Unit/Ecosystem/Mapper/MapperFactoryTest.php b/test/Unit/Ecosystem/Mapper/MapperFactoryTest.php new file mode 100644 index 00000000..e5a9c0f1 --- /dev/null +++ b/test/Unit/Ecosystem/Mapper/MapperFactoryTest.php @@ -0,0 +1,31 @@ +createMock(ContainerInterface::class); + $container->expects($this->once())->method('get')->willReturn([ + 'db' => 'sqlite:' . $this->testDb + ]); + + $this->assertInstanceOf(PdoMapper::class, (new MapperFactory())($container)); + } +}