From e4f24b04f7ef1d02a5364c41cd664565b0d0ae60 Mon Sep 17 00:00:00 2001 From: Anton Komarev <1849174+antonkomarev@users.noreply.github.com> Date: Sun, 12 Jul 2020 18:46:42 +0300 Subject: [PATCH] Add PDO repository (#2) * Add PDO repository * Make file storage path configurable * Name username query property required * Add special exception class --- .env.example | 8 + .gitignore | 1 + README.md | 2 +- composer.json | 3 +- composer.lock | 369 +++++++++++++++++- ...bProfileViewsCounterThrowableInterface.php | 20 + contracts/Exceptions/InvalidPathException.php | 21 + public/file-repository.php | 55 +++ public/index.php | 28 +- public/pdo-repository.php | 70 ++++ src/CounterFileRepository.php | 14 +- src/CounterImageRendererService.php | 4 +- src/CounterPdoRepository.php | 51 +++ 13 files changed, 604 insertions(+), 42 deletions(-) create mode 100644 .env.example create mode 100644 contracts/Exceptions/GitHubProfileViewsCounterThrowableInterface.php create mode 100644 contracts/Exceptions/InvalidPathException.php create mode 100644 public/file-repository.php create mode 100644 public/pdo-repository.php create mode 100644 src/CounterPdoRepository.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a09ccf0 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DB_DRIVER=pgsql +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD= +DB_NAME=github_profile_views_counter + +FILE_STORAGE_PATH= diff --git a/.gitignore b/.gitignore index 57872d0..3d5163b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /vendor/ +.env diff --git a/README.md b/README.md index a98ed78..de5b281 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Here is my profile repository: [https://github.com/antonkomarev/antonkomarev](ht You need to host this app on your server and display it via Markdown syntax: ```markdown -![](https://komarev.com/github-profile-views-counter) +![](https://komarev.com/github-profile-views-counter/?username=antonkomarev) ``` Only `public/index.php` should be exposed. diff --git a/composer.json b/composer.json index 6695cdd..ce1132f 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ } ], "require": { - "php": "^7.4" + "php": "^7.4", + "vlucas/phpdotenv": "^5.0" }, "require-dev": { }, diff --git a/composer.lock b/composer.lock index 1e5c42e..b2283c1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,373 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d6dfebb57666653dd233e3ce73e99b17", - "packages": [], + "content-hash": "26df08e2e3ecf73c0f0c84752718e156", + "packages": [ + { + "name": "graham-campbell/result-type", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "7e279d2cd5d7fbb156ce46daada972355cea27bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/7e279d2cd5d7fbb156ce46daada972355cea27bb", + "reference": "7e279d2cd5d7fbb156ce46daada972355cea27bb", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0", + "phpoption/phpoption": "^1.7.3" + }, + "require-dev": { + "phpunit/phpunit": "^6.5|^7.5|^8.5|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "graham@alt-three.com" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "time": "2020-04-13T13:17:36+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.7.4", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "b2ada2ad5d8a32b89088b8adc31ecd2e3a13baf3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/b2ada2ad5d8a32b89088b8adc31ecd2e3a13baf3", + "reference": "b2ada2ad5d8a32b89088b8adc31ecd2e3a13baf3", + "shasum": "" + }, + "require": { + "php": "^5.5.9 || ^7.0 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.3", + "phpunit/phpunit": "^4.8.35 || ^5.0 || ^6.0 || ^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.7-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "time": "2020-06-07T10:40:07+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.17.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "2edd75b8b35d62fd3eeabba73b26b8f1f60ce13d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/2edd75b8b35d62fd3eeabba73b26b8f1f60ce13d", + "reference": "2edd75b8b35d62fd3eeabba73b26b8f1f60ce13d", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "time": "2020-06-06T08:46:27+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.17.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "7110338d81ce1cbc3e273136e4574663627037a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7110338d81ce1cbc3e273136e4574663627037a7", + "reference": "7110338d81ce1cbc3e273136e4574663627037a7", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2020-06-06T08:46:27+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.17.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "4a5b6bba3259902e386eb80dd1956181ee90b5b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/4a5b6bba3259902e386eb80dd1956181ee90b5b2", + "reference": "4a5b6bba3259902e386eb80dd1956181ee90b5b2", + "shasum": "" + }, + "require": { + "php": ">=7.0.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2020-06-06T08:46:27+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "fbb6a5f65512f21d0db9e21bd49e67f70a9bbd5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/fbb6a5f65512f21d0db9e21bd49e67f70a9bbd5e", + "reference": "fbb6a5f65512f21d0db9e21bd49e67f70a9bbd5e", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.0.1", + "php": "^7.1.3 || ^8.0", + "phpoption/phpoption": "^1.7.4", + "symfony/polyfill-ctype": "^1.17", + "symfony/polyfill-mbstring": "^1.17", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "ext-filter": "*", + "phpunit/phpunit": "^7.5.20 || ^8.5.2 || ^9.0" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "homepage": "https://gjcampbell.co.uk/" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://vancelucas.com/" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "time": "2020-06-07T19:04:14+00:00" + } + ], "packages-dev": [], "aliases": [], "minimum-stability": "dev", diff --git a/contracts/Exceptions/GitHubProfileViewsCounterThrowableInterface.php b/contracts/Exceptions/GitHubProfileViewsCounterThrowableInterface.php new file mode 100644 index 0000000..2b71f90 --- /dev/null +++ b/contracts/Exceptions/GitHubProfileViewsCounterThrowableInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Contracts\Komarev\GitHubProfileViewsCounter; + +use Throwable; + +interface GitHubProfileViewsCounterThrowableInterface extends Throwable +{ +} diff --git a/contracts/Exceptions/InvalidPathException.php b/contracts/Exceptions/InvalidPathException.php new file mode 100644 index 0000000..6c26a30 --- /dev/null +++ b/contracts/Exceptions/InvalidPathException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Contracts\Komarev\GitHubProfileViewsCounter; + +use InvalidArgumentException; + +final class InvalidPathException extends InvalidArgumentException implements + GitHubProfileViewsCounterThrowableInterface +{ +} diff --git a/public/file-repository.php b/public/file-repository.php new file mode 100644 index 0000000..4029d91 --- /dev/null +++ b/public/file-repository.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use Dotenv\Dotenv; +use Komarev\GitHubProfileViewsCounter\CounterImageRendererService; +use Komarev\GitHubProfileViewsCounter\CounterFileRepository; + +$basePath = realpath(__DIR__ . '/..'); + +// Register The Auto Loader +require $basePath . '/vendor/autoload.php'; + +try { + $dotEnv = Dotenv::createImmutable($basePath); + $dotEnv->safeLoad(); + + $counterSourceImagePath = $basePath . '/resources/views-count.svg'; + + if ($_ENV['FILE_STORAGE_PATH'] === null) { + $storagePath = $basePath . '/storage'; + } else { + $storagePath = $_ENV['FILE_STORAGE_PATH']; + } + + $username = $_GET['username'] ?? ''; + $username = trim($username); + + if ($username === '') { + throw new InvalidArgumentException('Query property `username` is missing'); + } + + $counterRepository = new CounterFileRepository($storagePath); + $counterRepository->incrementCountByUsername($username); + $count = $counterRepository->getCountByUsername($username); + + $counterImageRenderer = new CounterImageRendererService($counterSourceImagePath); + $counterImage = $counterImageRenderer->getImageWithCount($count); + + header('Content-Type: image/svg+xml'); + header('Cache-Control: max-age=0, no-cache, no-store, must-revalidate'); + + echo $counterImage; +} catch (Exception $exception) { + echo $exception->getMessage(); +} diff --git a/public/index.php b/public/index.php index f7a3b9c..8dd4468 100644 --- a/public/index.php +++ b/public/index.php @@ -11,29 +11,5 @@ declare(strict_types=1); -use Komarev\GitHubProfileViewsCounter\CounterImageRendererService; -use Komarev\GitHubProfileViewsCounter\CounterFileRepository; - -$basePath = realpath(__DIR__ . '/..'); -$storagePath = $basePath . '/storage'; -$counterSourceImagePath = $basePath . '/resources/views-count.svg'; - -require $basePath . '/vendor/autoload.php'; - -$username = $_GET['username'] ?? ''; -$username = trim($username); - -try { - $counterRepository = new CounterFileRepository($storagePath); - $counterImageRenderer = new CounterImageRendererService($counterSourceImagePath); - - $counterRepository->incrementCountByUsername($username); - $count = $counterRepository->getCountByUsername($username); - - header('Content-Type: image/svg+xml'); - header('Cache-Control: max-age=0, no-cache, no-store, must-revalidate'); - - echo $counterImageRenderer->getImageWithCount($count); -} catch (Exception $exception) { - echo $exception->getMessage(); -} +require 'file-repository.php'; +//require 'pdo-repository.php'; diff --git a/public/pdo-repository.php b/public/pdo-repository.php new file mode 100644 index 0000000..50486a3 --- /dev/null +++ b/public/pdo-repository.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use Dotenv\Dotenv; +use Dotenv\Exception\InvalidPathException; +use Komarev\GitHubProfileViewsCounter\CounterImageRendererService; +use Komarev\GitHubProfileViewsCounter\CounterPdoRepository; + +$basePath = realpath(__DIR__ . '/..'); + +// Register The Auto Loader +require $basePath . '/vendor/autoload.php'; + +try { + $dotEnv = Dotenv::createImmutable($basePath); + $dotEnv->load(); + + $dotEnv->required([ + 'DB_DRIVER', + 'DB_HOST', + 'DB_PORT', + 'DB_USER', + 'DB_PASSWORD', + 'DB_NAME', + ]); + + $counterSourceImagePath = $basePath . '/resources/views-count.svg'; + + $username = $_GET['username'] ?? ''; + $username = trim($username); + + if ($username === '') { + throw new InvalidArgumentException('Query property `username` is missing'); + } + + $dsn = sprintf( + '%s:host=%s;port=%d;dbname=%s', + $_ENV['DB_DRIVER'], $_ENV['DB_HOST'], $_ENV['DB_PORT'], $_ENV['DB_NAME'] + ); + $dbConnectionOptions = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]; + $dbConnection = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], $dbConnectionOptions); + + $counterRepository = new CounterPdoRepository($dbConnection); + $counterRepository->incrementCountByUsername($username); + $count = $counterRepository->getCountByUsername($username); + + $counterImageRenderer = new CounterImageRendererService($counterSourceImagePath); + $counterImage = $counterImageRenderer->getImageWithCount($count); + + header('Content-Type: image/svg+xml'); + header('Cache-Control: max-age=0, no-cache, no-store, must-revalidate'); + + echo $counterImage; +} catch (InvalidPathException $exception) { + echo 'Application environment file is missing'; +} catch (Exception $exception) { + echo $exception->getMessage(); +} diff --git a/src/CounterFileRepository.php b/src/CounterFileRepository.php index e8aa640..8e24e3b 100644 --- a/src/CounterFileRepository.php +++ b/src/CounterFileRepository.php @@ -14,7 +14,7 @@ declare(strict_types=1); namespace Komarev\GitHubProfileViewsCounter; use Contracts\Komarev\GitHubProfileViewsCounter\CounterRepositoryInterface; -use InvalidArgumentException; +use Contracts\Komarev\GitHubProfileViewsCounter\InvalidPathException; final class CounterFileRepository implements CounterRepositoryInterface { @@ -23,11 +23,11 @@ final class CounterFileRepository implements CounterRepositoryInterface public function __construct(string $storagePath) { if (!is_dir($storagePath)) { - throw new InvalidArgumentException('Counter storage is not a directory'); + throw new InvalidPathException('Counter storage is not a directory'); } if (!is_writable($storagePath)) { - throw new InvalidArgumentException('Counter storage is not writable'); + throw new InvalidPathException('Counter storage is not writable'); } $this->storagePath = $storagePath; @@ -49,12 +49,6 @@ final class CounterFileRepository implements CounterRepositoryInterface private function getCounterFilePath(string $username): string { - if ($username === '') { - $counterFileName = 'views-count'; - } else { - $counterFileName = $username . '-views-count'; - } - - return $this->storagePath . '/' . $counterFileName; + return $this->storagePath . '/' . $username . '-views-count'; } } diff --git a/src/CounterImageRendererService.php b/src/CounterImageRendererService.php index b1d6b0c..bbf5ff5 100644 --- a/src/CounterImageRendererService.php +++ b/src/CounterImageRendererService.php @@ -13,7 +13,7 @@ declare(strict_types=1); namespace Komarev\GitHubProfileViewsCounter; -use InvalidArgumentException; +use Contracts\Komarev\GitHubProfileViewsCounter\InvalidPathException; final class CounterImageRendererService { @@ -22,7 +22,7 @@ final class CounterImageRendererService public function __construct(string $sourceImagePath) { if (!file_exists($sourceImagePath)) { - throw new InvalidArgumentException('Counter source image not found'); + throw new InvalidPathException('Counter source image not found'); } $this->sourceImagePath = $sourceImagePath; diff --git a/src/CounterPdoRepository.php b/src/CounterPdoRepository.php new file mode 100644 index 0000000..907af6f --- /dev/null +++ b/src/CounterPdoRepository.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Komarev\GitHubProfileViewsCounter; + +use Contracts\Komarev\GitHubProfileViewsCounter\CounterRepositoryInterface; +use PDO; + +final class CounterPdoRepository implements CounterRepositoryInterface +{ + private PDO $connection; + + public function __construct(PDO $connection) + { + $this->connection = $connection; + } + + public function getCountByUsername(string $username): int + { + $statement = $this->connection->prepare( + 'SELECT COUNT(*) + FROM page_views + WHERE username = :username;' + ); + $statement->bindParam('username', $username); + $statement->execute(); + + return (int) $statement->fetchColumn(0); + } + + public function incrementCountByUsername(string $username): void + { + $statement = $this->connection->prepare( + 'INSERT INTO page_views + (username, created_at) + VALUES (:username, NOW());' + ); + $statement->bindParam('username', $username); + $statement->execute(); + } +}