diff --git a/.env.dist b/.env.dist new file mode 100644 index 00000000..a25e8c2c --- /dev/null +++ b/.env.dist @@ -0,0 +1,10 @@ +# Application +APP_ENV= +SHORTENED_URL_SCHEMA= +SHORTENED_URL_HOSTNAME= +SHORTCODE_CHARS= + +# Database +DB_USER= +DB_PASSWORD= +DB_NAME= diff --git a/.gitignore b/.gitignore index 397e34a5..5aedee0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ build composer.lock vendor/ +.env diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 00000000..692b0397 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,6 @@ +tools: + external_code_coverage: true +checks: + php: + code_rating: true + duplication: true diff --git a/.travis.yml b/.travis.yml index 4d90fc81..77f1332b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,28 +1,26 @@ -sudo: false - language: php -matrix: - fast_finish: true - include: - - php: 5.5 - - php: 5.6 - env: - - EXECUTE_CS_CHECK=true - - php: 7 - - php: hhvm - allow_failures: - - php: hhvm +branches: + only: + - master + - develop -before_install: +php: + - 5.5 + - 5.6 + - 7 + - hhvm + +before_script: - composer self-update - -install: - - travis_retry composer install --no-interaction --ignore-platform-reqs --prefer-source --no-scripts + - composer install --no-interaction script: - - composer test - - if [[ $EXECUTE_CS_CHECK == 'true' ]]; then composer cs ; fi + - mkdir build + - composer check -notifications: - email: true +after_script: + - wget https://scrutinizer-ci.com/ocular.phar + - php ocular.phar code-coverage:upload --format=php-clover build/clover.xml + +sudo: false diff --git a/bin/cli b/bin/cli old mode 100644 new mode 100755 index 1aa55f63..0aca7dd0 --- a/bin/cli +++ b/bin/cli @@ -1,11 +1,14 @@ #!/usr/bin/env php get(Application::class); $command = count($_SERVER['argv']) > 1 ? $_SERVER['argv'][1] : ''; $request = ServerRequestFactory::fromGlobals() diff --git a/cli-config.php b/cli-config.php new file mode 100644 index 00000000..be9b8b82 --- /dev/null +++ b/cli-config.php @@ -0,0 +1,11 @@ +get(EntityManager::class); + +return ConsoleRunner::createHelperSet($em); diff --git a/composer.json b/composer.json index a69b5905..d43d7fbb 100644 --- a/composer.json +++ b/composer.json @@ -14,18 +14,21 @@ "php": "^5.5 || ^7.0", "zendframework/zend-expressive": "^1.0", "zendframework/zend-expressive-helpers": "^2.0", - "zendframework/zend-stdlib": "^2.7", - "zendframework/zend-expressive-aurarouter": "^1.0", - "zendframework/zend-servicemanager": "^3.0", + "zendframework/zend-expressive-fastroute": "^1.1", "zendframework/zend-expressive-twigrenderer": "^1.0", - "acelaya/expressive-slim-router": "^2.0" + "zendframework/zend-stdlib": "^2.7", + "zendframework/zend-servicemanager": "^3.0", + "doctrine/orm": "^2.5", + "guzzlehttp/guzzle": "^6.2", + "acelaya/zsm-annotated-services": "^0.2.0" }, "require-dev": { "phpunit/phpunit": "^4.8", "squizlabs/php_codesniffer": "^2.3", "roave/security-advisories": "dev-master", "filp/whoops": "^2.0", - "symfony/var-dumper": "^3.0" + "symfony/var-dumper": "^3.0", + "vlucas/phpdotenv": "^2.2" }, "autoload": { "psr-4": { @@ -45,7 +48,7 @@ "cs": "phpcs", "cs-fix": "phpcbf", "serve": "php -S 0.0.0.0:8000 -t public/", - "test": "phpunit", - "pretty-test": "phpunit -c tests/phpunit.xml --coverage-html build/coverage" + "test": "phpunit --coverage-clover build/clover.xml", + "pretty-test": "phpunit --coverage-html build/coverage" } } diff --git a/config/app.php b/config/app.php deleted file mode 100644 index 08b73799..00000000 --- a/config/app.php +++ /dev/null @@ -1,14 +0,0 @@ -setService('config', $config); - -return $container->get(Application::class); diff --git a/config/autoload/cli-routes.global.php b/config/autoload/cli-routes.global.php new file mode 100644 index 00000000..c2aafdf1 --- /dev/null +++ b/config/autoload/cli-routes.global.php @@ -0,0 +1,15 @@ + [ + [ + 'name' => 'cli-generate-shortcode', + 'path' => '/generate-shortcode', + 'middleware' => CliRoutable\GenerateShortcodeMiddleware::class, + 'allowed_methods' => ['CLI'], + ], + ], + +]; diff --git a/config/autoload/database.global.php b/config/autoload/database.global.php new file mode 100644 index 00000000..4e3b00e3 --- /dev/null +++ b/config/autoload/database.global.php @@ -0,0 +1,15 @@ + [ + 'driver' => 'pdo_mysql', + 'user' => getenv('DB_USER'), + 'password' => getenv('DB_PASSWORD'), + 'dbname' => getenv('DB_NAME') ?: 'acelaya_url_shortener', + 'charset' => 'utf8', + 'driverOptions' => [ + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8' + ], + ], + +]; diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 9f5f2d63..d033f9b2 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -1,9 +1,10 @@ [ 'always' => [ 'middleware' => [ @@ -15,6 +16,7 @@ return [ 'routing' => [ 'middleware' => [ ApplicationFactory::ROUTING_MIDDLEWARE, + CliParamsMiddleware::class, Helper\UrlHelperMiddleware::class, ApplicationFactory::DISPATCH_MIDDLEWARE, ], diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index 992f5656..40a3d20b 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -1,26 +1,15 @@ [ [ - 'name' => 'home', - 'path' => '/', - 'middleware' => function ($req, $resp) { - $resp->getBody()->write('Hello world'); - return $resp; - }, + 'name' => 'long-url-redirect', + 'path' => '/{shortCode}', + 'middleware' => Routable\RedirectMiddleware::class, 'allowed_methods' => ['GET'], ], - [ - 'name' => 'cli', - 'path' => '/command-name', - 'middleware' => function ($req, $resp) { - $resp->getBody()->write('Hello world from cli'); - return $resp; - }, - 'allowed_methods' => ['CLI'], - ], ], - + ]; diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index 8f2a8816..300f1bf3 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -1,17 +1,22 @@ [ - 'invokables' => [ - Helper\ServerUrlHelper::class => Helper\ServerUrlHelper::class, - Router\RouterInterface::class => Router\AuraRouter::class, - ], 'factories' => [ Application::class => Container\ApplicationFactory::class, @@ -19,11 +24,30 @@ return [ Helper\UrlHelper::class => Helper\UrlHelperFactory::class, Helper\ServerUrlMiddleware::class => Helper\ServerUrlMiddlewareFactory::class, Helper\UrlHelperMiddleware::class => Helper\UrlHelperMiddlewareFactory::class, + Helper\ServerUrlHelper::class => InvokableFactory::class, + Router\FastRouteRouter::class => InvokableFactory::class, // View 'Zend\Expressive\FinalHandler' => Container\TemplatedErrorHandlerFactory::class, - Template\TemplateRendererInterface::class => Zend\Expressive\Twig\TwigRendererFactory::class, + Template\TemplateRendererInterface::class => Twig\TwigRendererFactory::class, + + // Services + EntityManager::class => EntityManagerFactory::class, + GuzzleHttp\Client::class => InvokableFactory::class, + Service\UrlShortener::class => AnnotatedFactory::class, + Service\VisitsTracker::class => AnnotatedFactory::class, + Cache::class => CacheFactory::class, + + // Middleware + Middleware\CliRoutable\GenerateShortcodeMiddleware::class => AnnotatedFactory::class, + Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::class, + Middleware\CliParamsMiddleware::class => Middleware\Factory\CliParamsMiddlewareFactory::class, ], + 'aliases' => [ + 'em' => EntityManager::class, + 'httpClient' => GuzzleHttp\Client::class, + Router\RouterInterface::class => Router\FastRouteRouter::class, + ] ], ]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php new file mode 100644 index 00000000..718ed3f0 --- /dev/null +++ b/config/autoload/url-shortener.global.php @@ -0,0 +1,12 @@ + [ + 'domain' => [ + 'schema' => getenv('SHORTENED_URL_SCHEMA') ?: 'http', + 'hostname' => getenv('SHORTENED_URL_HOSTNAME'), + ], + 'shortcode_chars' => getenv('SHORTCODE_CHARS'), + ], + +]; diff --git a/config/config.php b/config/config.php index 4838f371..a3d0e7ac 100644 --- a/config/config.php +++ b/config/config.php @@ -29,6 +29,4 @@ if (is_file($cachedConfigFile)) { } } -// Return an ArrayObject so we can inject the config as a service in Aura.Di -// and still use array checks like ``is_array``. -return new ArrayObject($config, ArrayObject::ARRAY_AS_PROPS); +return $config; diff --git a/config/container.php b/config/container.php new file mode 100644 index 00000000..5678edcb --- /dev/null +++ b/config/container.php @@ -0,0 +1,21 @@ +load(); +} + +// Build container +$config = require __DIR__ . '/config.php'; +$container = new ServiceManager($config['services']); +$container->setService('config', $config); +return $container; diff --git a/data/.gitignore b/data/cache/.gitignore similarity index 100% rename from data/.gitignore rename to data/cache/.gitignore diff --git a/data/proxies/.gitignore b/data/proxies/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/data/proxies/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bb69885f..0721b191 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - - ./test + + ./tests diff --git a/public/index.php b/public/index.php index 16af56e5..6b310943 100644 --- a/public/index.php +++ b/public/index.php @@ -1,6 +1,9 @@ get(Application::class); $app->run(); diff --git a/src/Entity/AbstractEntity.php b/src/Entity/AbstractEntity.php new file mode 100644 index 00000000..0fc810ef --- /dev/null +++ b/src/Entity/AbstractEntity.php @@ -0,0 +1,33 @@ +id; + } + + /** + * @param int $id + * @return $this + */ + public function setId($id) + { + $this->id = $id; + return $this; + } +} diff --git a/src/Entity/ShortUrl.php b/src/Entity/ShortUrl.php new file mode 100644 index 00000000..35c7ed1b --- /dev/null +++ b/src/Entity/ShortUrl.php @@ -0,0 +1,120 @@ +setDateCreated(new \DateTime()); + $this->setVisits(new ArrayCollection()); + $this->setShortCode(''); + } + + /** + * @return string + */ + public function getOriginalUrl() + { + return $this->originalUrl; + } + + /** + * @param string $originalUrl + * @return $this + */ + public function setOriginalUrl($originalUrl) + { + $this->originalUrl = (string) $originalUrl; + return $this; + } + + /** + * @return string + */ + public function getShortCode() + { + return $this->shortCode; + } + + /** + * @param string $shortCode + * @return $this + */ + public function setShortCode($shortCode) + { + $this->shortCode = $shortCode; + return $this; + } + + /** + * @return \DateTime + */ + public function getDateCreated() + { + return $this->dateCreated; + } + + /** + * @param \DateTime $dateCreated + * @return $this + */ + public function setDateCreated($dateCreated) + { + $this->dateCreated = $dateCreated; + return $this; + } + + /** + * @return Visit[]|Collection + */ + public function getVisits() + { + return $this->visits; + } + + /** + * @param Visit[]|Collection $visits + * @return $this + */ + public function setVisits($visits) + { + $this->visits = $visits; + return $this; + } +} diff --git a/src/Entity/Visit.php b/src/Entity/Visit.php new file mode 100644 index 00000000..2e8fad6f --- /dev/null +++ b/src/Entity/Visit.php @@ -0,0 +1,137 @@ +date = new \DateTime(); + } + + /** + * @return string + */ + public function getReferer() + { + return $this->referer; + } + + /** + * @param string $referer + * @return $this + */ + public function setReferer($referer) + { + $this->referer = $referer; + return $this; + } + + /** + * @return \DateTime + */ + public function getDate() + { + return $this->date; + } + + /** + * @param \DateTime $date + * @return $this + */ + public function setDate($date) + { + $this->date = $date; + return $this; + } + + /** + * @return ShortUrl + */ + public function getShortUrl() + { + return $this->shortUrl; + } + + /** + * @param ShortUrl $shortUrl + * @return $this + */ + public function setShortUrl($shortUrl) + { + $this->shortUrl = $shortUrl; + return $this; + } + + /** + * @return string + */ + public function getRemoteAddr() + { + return $this->remoteAddr; + } + + /** + * @param string $remoteAddr + * @return $this + */ + public function setRemoteAddr($remoteAddr) + { + $this->remoteAddr = $remoteAddr; + return $this; + } + + /** + * @return string + */ + public function getUserAgent() + { + return $this->userAgent; + } + + /** + * @param string $userAgent + * @return $this + */ + public function setUserAgent($userAgent) + { + $this->userAgent = $userAgent; + return $this; + } +} diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php new file mode 100644 index 00000000..d6bdc788 --- /dev/null +++ b/src/Exception/ExceptionInterface.php @@ -0,0 +1,6 @@ +getCode() : -1; + return new static( + sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet), + $code, + $previous + ); + } +} diff --git a/src/Exception/InvalidUrlException.php b/src/Exception/InvalidUrlException.php new file mode 100644 index 00000000..a19eedab --- /dev/null +++ b/src/Exception/InvalidUrlException.php @@ -0,0 +1,11 @@ +getCode() : -1; + return new static(sprintf('Provided URL "%s" is not an exisitng and valid URL', $url), $code, $previous); + } +} diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 00000000..32e61356 --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,6 @@ +get('config'); + $isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false; + $cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache(); + $dbConfig = isset($globalConfig['database']) ? $globalConfig['database'] : []; + + return EntityManager::create($dbConfig, Setup::createAnnotationMetadataConfiguration( + ['src/Entity'], + $isDevMode, + 'data/proxies', + $cache, + false + )); + } +} diff --git a/src/Middleware/CliParamsMiddleware.php b/src/Middleware/CliParamsMiddleware.php new file mode 100644 index 00000000..683a8e86 --- /dev/null +++ b/src/Middleware/CliParamsMiddleware.php @@ -0,0 +1,71 @@ +argv = $argv; + $this->currentSapi = $currentSapi; + } + + /** + * Process an incoming request and/or response. + * + * Accepts a server-side request and a response instance, and does + * something with them. + * + * If the response is not complete and/or further processing would not + * interfere with the work done in the middleware, or if the middleware + * wants to delegate to another process, it can use the `$out` callable + * if present. + * + * If the middleware does not return a value, execution of the current + * request is considered complete, and the response instance provided will + * be considered the response to return. + * + * Alternately, the middleware may return a response instance. + * + * Often, middleware will `return $out();`, with the assumption that a + * later middleware will return a response. + * + * @param Request $request + * @param Response $response + * @param null|callable $out + * @return null|Response + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + // When not in CLI, just call next middleware + if ($this->currentSapi !== 'cli') { + return $out($request, $response); + } + + /** @var RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class); + if (! $routeResult->isSuccess()) { + return $out($request, $response); + } + + // Inject ARGV params as request attributes + if ($routeResult->getMatchedRouteName() === 'cli-generate-shortcode') { + $request = $request->withAttribute('longUrl', isset($this->argv[2]) ? $this->argv[2] : null); + } + + return $out($request, $response); + } +} diff --git a/src/Middleware/CliRoutable/GenerateShortcodeMiddleware.php b/src/Middleware/CliRoutable/GenerateShortcodeMiddleware.php new file mode 100644 index 00000000..6573ed60 --- /dev/null +++ b/src/Middleware/CliRoutable/GenerateShortcodeMiddleware.php @@ -0,0 +1,91 @@ +urlShortener = $urlShortener; + $this->domainConfig = $domainConfig; + } + + /** + * Process an incoming request and/or response. + * + * Accepts a server-side request and a response instance, and does + * something with them. + * + * If the response is not complete and/or further processing would not + * interfere with the work done in the middleware, or if the middleware + * wants to delegate to another process, it can use the `$out` callable + * if present. + * + * If the middleware does not return a value, execution of the current + * request is considered complete, and the response instance provided will + * be considered the response to return. + * + * Alternately, the middleware may return a response instance. + * + * Often, middleware will `return $out();`, with the assumption that a + * later middleware will return a response. + * + * @param Request $request + * @param Response $response + * @param null|callable $out + * @return null|Response + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + $longUrl = $request->getAttribute('longUrl'); + + try { + if (! isset($longUrl)) { + $response->getBody()->write('A URL was not provided!' . PHP_EOL); + return; + } + + $shortcode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); + $shortUrl = (new Uri())->withPath($shortcode) + ->withScheme($this->domainConfig['schema']) + ->withHost($this->domainConfig['hostname']); + + $response->getBody()->write( + sprintf('Processed URL "%s".%sGenerated short URL "%s"', $longUrl, PHP_EOL, $shortUrl) . PHP_EOL + ); + } catch (InvalidUrlException $e) { + $response->getBody()->write( + sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl) . PHP_EOL + ); + } catch (\Exception $e) { + $response->getBody()->write($e); + } finally { + return $response; + } + } +} diff --git a/src/Middleware/Factory/CliParamsMiddlewareFactory.php b/src/Middleware/Factory/CliParamsMiddlewareFactory.php new file mode 100644 index 00000000..9be7f52e --- /dev/null +++ b/src/Middleware/Factory/CliParamsMiddlewareFactory.php @@ -0,0 +1,32 @@ +urlShortener = $urlShortener; + $this->visitTracker = $visitTracker; + } + + /** + * Process an incoming request and/or response. + * + * Accepts a server-side request and a response instance, and does + * something with them. + * + * If the response is not complete and/or further processing would not + * interfere with the work done in the middleware, or if the middleware + * wants to delegate to another process, it can use the `$out` callable + * if present. + * + * If the middleware does not return a value, execution of the current + * request is considered complete, and the response instance provided will + * be considered the response to return. + * + * Alternately, the middleware may return a response instance. + * + * Often, middleware will `return $out();`, with the assumption that a + * later middleware will return a response. + * + * @param Request $request + * @param Response $response + * @param null|callable $out + * @return null|Response + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + $shortCode = $request->getAttribute('shortCode', ''); + + try { + $longUrl = $this->urlShortener->shortCodeToUrl($shortCode); + + // If provided shortCode does not belong to a valid long URL, dispatch next middleware, which is 404 + // middleware + if (! isset($longUrl)) { + return $out($request, $response); + } + + // Track visit to this shortcode + $this->visitTracker->track($shortCode); + + // Return a redirect response to the long URL. + // Use a temporary redirect to make sure browsers always hit the server for analytics purposes + return new RedirectResponse($longUrl); + } catch (\Exception $e) { + // In case of error, dispatch 404 error + return $out($request, $response); + } + } +} diff --git a/src/Service/UrlShortener.php b/src/Service/UrlShortener.php new file mode 100644 index 00000000..3c15a0f6 --- /dev/null +++ b/src/Service/UrlShortener.php @@ -0,0 +1,154 @@ +httpClient = $httpClient; + $this->em = $em; + $this->chars = $chars; + } + + /** + * Creates and persists a unique shortcode generated for provided url + * + * @param UriInterface $url + * @return string + * @throws InvalidUrlException + * @throws RuntimeException + */ + public function urlToShortCode(UriInterface $url) + { + // If the url already exists in the database, just return its short code + $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ + 'originalUrl' => $url + ]); + if (isset($shortUrl)) { + return $shortUrl->getShortCode(); + } + + // Check that the URL exists + $this->checkUrlExists($url); + + // Transactionally insert the short url, then generate the short code and finally update the short code + try { + $this->em->beginTransaction(); + + // First, create the short URL with an empty short code + $shortUrl = new ShortUrl(); + $shortUrl->setOriginalUrl($url); + $this->em->persist($shortUrl); + $this->em->flush(); + + // Generate the short code and persist it + $shortCode = $this->convertAutoincrementIdToShortCode($shortUrl->getId()); + $shortUrl->setShortCode($shortCode); + $this->em->flush(); + + $this->em->commit(); + return $shortCode; + } catch (ORMException $e) { + if ($this->em->getConnection()->isTransactionActive()) { + $this->em->rollback(); + $this->em->close(); + } + + throw new RuntimeException('An error occured while persisting the short URL', -1, $e); + } + } + + /** + * Tries to perform a GET request to provided url, returning true on success and false on failure + * + * @param UriInterface $url + * @return bool + */ + protected function checkUrlExists(UriInterface $url) + { + try { + $this->httpClient->request('GET', $url); + } catch (GuzzleException $e) { + throw InvalidUrlException::fromUrl($url, $e); + } + } + + /** + * Generates the unique shortcode for an autoincrement ID + * + * @param int $id + * @return string + */ + protected function convertAutoincrementIdToShortCode($id) + { + $id = intval($id) + 200000; // Increment the Id so that the generated shortcode is not too short + $length = strlen($this->chars); + $code = ''; + + while ($id > 0) { + // Determine the value of the next higher character in the short code and prepend it + $code = $this->chars[intval(fmod($id, $length))] . $code; + $id = floor($id / $length); + } + + return $this->chars[intval($id)] . $code; + } + + /** + * Tries to find the mapped URL for provided short code. Returns null if not found + * + * @param string $shortCode + * @return string|null + * @throws InvalidShortCodeException + */ + public function shortCodeToUrl($shortCode) + { + // Validate short code format + if (! preg_match('|[' . $this->chars . "]+|", $shortCode)) { + throw InvalidShortCodeException::fromShortCode($shortCode, $this->chars); + } + + /** @var ShortUrl $shortUrl */ + $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ + 'shortCode' => $shortCode, + ]); + return isset($shortUrl) ? $shortUrl->getOriginalUrl() : null; + } +} diff --git a/src/Service/UrlShortenerInterface.php b/src/Service/UrlShortenerInterface.php new file mode 100644 index 00000000..6623f392 --- /dev/null +++ b/src/Service/UrlShortenerInterface.php @@ -0,0 +1,29 @@ +em = $em; + } + + /** + * Tracks a new visit to provided short code, using an array of data to look up information + * + * @param string $shortCode + * @param array $visitorData Defaults to global $_SERVER + */ + public function track($shortCode, array $visitorData = null) + { + $visitorData = $visitorData ?: $_SERVER; + + /** @var ShortUrl $shortUrl */ + $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ + 'shortCode' => $shortCode, + ]); + + $visit = new Visit(); + $visit->setShortUrl($shortUrl) + ->setUserAgent($this->getArrayValue($visitorData, 'HTTP_USER_AGENT')) + ->setReferer($this->getArrayValue($visitorData, 'HTTP_REFERER')) + ->setRemoteAddr($this->getArrayValue($visitorData, 'REMOTE_ADDR')); + $this->em->persist($visit); + $this->em->flush(); + } + + /** + * @param array $array + * @param $key + * @param null $default + * @return mixed|null + */ + protected function getArrayValue(array $array, $key, $default = null) + { + return isset($array[$key]) ? $array[$key] : $default; + } +} diff --git a/src/Service/VisitsTrackerInterface.php b/src/Service/VisitsTrackerInterface.php new file mode 100644 index 00000000..3b2fc874 --- /dev/null +++ b/src/Service/VisitsTrackerInterface.php @@ -0,0 +1,13 @@ + -

Welcome to zend-expressive

-

- Congratulations! You have successfully installed the - zend-expressive skeleton application. - This skeleton can serve as a simple starting point for you to begin building your application. -

-

- Expressive builds on zend-stratigility to provide a minimalist PSR-7 middleware framework for PHP. -

- - -
-
-

- - Agile & Lean - -

-

- Expressive is fast, small and perfect for rapid application development, prototyping and api's. You decide how you - extend it and choose the best packages from major framework or standalone projects. -

-
- -
-

- - HTTP Messages - -

-

- HTTP messages are the foundation of web development. Web browsers and HTTP clients such as cURL create - HTTP request messages that are sent to a web server, which provides an HTTP response message. - Server-side code receives an HTTP request message, and returns an HTTP response message. -

-
- -
-

- - Middleware - -

-

- Middleware is code that exists between the request and response, and which can take the incoming - request, perform actions based on it, and either complete the response or pass delegation on to the - next middleware in the queue. Your application is easily extended with custom middleware created by - yourself or others. -

-
-
- -
-
-

- - Containers - -

-

- Expressive promotes and advocates the usage of Dependency Injection/Inversion of Control containers - when writing your applications. Expressive supports multiple containers which typehints against - container-interop. -

-
- -
-

- - Routers - -

-

- One fundamental feature of zend-expressive is that it provides mechanisms for implementing dynamic - routing, a feature required in most modern web applications. Expressive ships with multiple adapters. -

- {% if routerName is defined %} -

- - Get started with {{ routerName }}. - -

- {% endif %} -
- -
-

- - Templating - -

-

- By default, no middleware in Expressive is templated. We do not even provide a default templating - engine, as the choice of templating engine is often very specific to the project and/or organization. - However, Expressive does provide abstraction for templating, which allows you to write middleware that - is engine-agnostic. -

- {% if templateName is defined %} -

- - Get started with {{ templateName }}. - -

- {% endif %} -
-
-{% endblock %} diff --git a/templates/error/404.html.twig b/templates/error/404.html.twig index 7b4e363e..0e591e2a 100644 --- a/templates/error/404.html.twig +++ b/templates/error/404.html.twig @@ -1,13 +1,17 @@ {% extends 'layout/default.html.twig' %} -{% block title %}404 Not Found{% endblock %} +{% block title %}URL Not Found{% endblock %} + +{% block stylesheets %} + +{% endblock %} {% block content %}

Oops!

-

This is awkward.

-

We encountered a 404 Not Found error.

-

- You are looking for something that doesn't exist or may have moved. Check out one of the links on this page - or head back to Home. -

+
+

This short URL doesn't seem to be valid.

+

Make sure you included all the characters, with no extra punctuation.

{% endblock %} diff --git a/templates/error/error.html.twig b/templates/error/error.html.twig index c9b66893..cd54354e 100644 --- a/templates/error/error.html.twig +++ b/templates/error/error.html.twig @@ -2,14 +2,19 @@ {% block title %}{{ status }} {{ reason }}{% endblock %} +{% block stylesheets %} + +{% endblock %} + {% block content %}

Oops!

-

This is awkward.

+

We encountered a {{ status }} {{ reason }} error.

{% if status == 404 %} -

- You are looking for something that doesn't exist or may have moved. Check out one of the links on this page - or head back to Home. -

+

This short URL doesn't seem to be valid.

+

Make sure you included all the characters, with no extra punctuation.

{% endif %} {% endblock %} diff --git a/templates/layout/default.html.twig b/templates/layout/default.html.twig index b3664d0e..aaec0aab 100644 --- a/templates/layout/default.html.twig +++ b/templates/layout/default.html.twig @@ -3,54 +3,19 @@ - {% block title %}{% endblock %} - zend-expressive + {% block title %}{% endblock %} | URL shortener {% block stylesheets %}{% endblock %} -
- -
-
{% block content %}{% endblock %} @@ -62,7 +27,7 @@
{% block footer %}

- © 2005 - {{ "now"|date("Y") }} by Zend Technologies Ltd. All rights reserved. + © {{ "now" | date("Y") }} by Alejandro Celaya.

{% endblock %}
diff --git a/tests/Factory/CacheFactoryTest.php b/tests/Factory/CacheFactoryTest.php new file mode 100644 index 00000000..a6920d56 --- /dev/null +++ b/tests/Factory/CacheFactoryTest.php @@ -0,0 +1,46 @@ +factory = new CacheFactory(); + } + + public static function tearDownAfterClass() + { + putenv('APP_ENV'); + } + + /** + * @test + */ + public function productionReturnsApcAdapter() + { + putenv('APP_ENV=pro'); + $instance = $this->factory->__invoke(new ServiceManager(), ''); + $this->assertInstanceOf(ApcuCache::class, $instance); + } + + /** + * @test + */ + public function developmentReturnsArrayAdapter() + { + putenv('APP_ENV=dev'); + $instance = $this->factory->__invoke(new ServiceManager(), ''); + $this->assertInstanceOf(ArrayCache::class, $instance); + } +} diff --git a/tests/Factory/EntityManagerFactoryTest.php b/tests/Factory/EntityManagerFactoryTest.php new file mode 100644 index 00000000..4b9bd82f --- /dev/null +++ b/tests/Factory/EntityManagerFactoryTest.php @@ -0,0 +1,38 @@ +factory = new EntityManagerFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $sm = new ServiceManager(['services' => [ + 'config' => [ + 'debug' => true, + 'database' => [ + 'driver' => 'pdo_sqlite', + ], + ], + ]]); + + $em = $this->factory->__invoke($sm, EntityManager::class); + $this->assertInstanceOf(EntityManager::class, $em); + } +} diff --git a/tests/Middleware/CliParamsMiddlewareTest.php b/tests/Middleware/CliParamsMiddlewareTest.php new file mode 100644 index 00000000..e07d05c0 --- /dev/null +++ b/tests/Middleware/CliParamsMiddlewareTest.php @@ -0,0 +1,91 @@ +__invoke( + ServerRequestFactory::fromGlobals(), + $originalResponse, + function ($req, $resp) use (&$invoked) { + $invoked = true; + return $resp; + } + ); + + $this->assertSame($originalResponse, $response); + $this->assertTrue($invoked); + } + + /** + * @test + */ + public function nonSuccessRouteResultJustInvokesNextMiddleware() + { + $middleware = new CliParamsMiddleware([], 'cli'); + + $invoked = false; + $originalResponse = new Response(); + $routeResult = $this->prophesize(RouteResult::class); + $routeResult->isSuccess()->willReturn(false)->shouldBeCalledTimes(1); + + $response = $middleware->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute(RouteResult::class, $routeResult->reveal()), + $originalResponse, + function ($req, $resp) use (&$invoked) { + $invoked = true; + return $resp; + } + ); + + $this->assertSame($originalResponse, $response); + $this->assertTrue($invoked); + } + + /** + * @test + */ + public function properRouteWillInjectAttributeInResponse() + { + $expectedLongUrl = 'http://www.google.com'; + $middleware = new CliParamsMiddleware(['foo', 'bar', $expectedLongUrl], 'cli'); + + $invoked = false; + $originalResponse = new Response(); + $routeResult = $this->prophesize(RouteResult::class); + $routeResult->isSuccess()->willReturn(true)->shouldBeCalledTimes(1); + $routeResult->getMatchedRouteName()->willReturn('cli-generate-shortcode')->shouldBeCalledTimes(1); + /** @var ServerRequestInterface $request */ + $request = null; + + $response = $middleware->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute(RouteResult::class, $routeResult->reveal()), + $originalResponse, + function ($req, $resp) use (&$invoked, &$request) { + $invoked = true; + $request = $req; + return $resp; + } + ); + + $this->assertSame($originalResponse, $response); + $this->assertEquals($expectedLongUrl, $request->getAttribute('longUrl')); + $this->assertTrue($invoked); + } +} diff --git a/tests/Middleware/Factory/CliParamsMiddlewareFactoryTest.php b/tests/Middleware/Factory/CliParamsMiddlewareFactoryTest.php new file mode 100644 index 00000000..3fad4bab --- /dev/null +++ b/tests/Middleware/Factory/CliParamsMiddlewareFactoryTest.php @@ -0,0 +1,29 @@ +factory = new CliParamsMiddlewareFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $instance = $this->factory->__invoke(new ServiceManager(), ''); + $this->assertInstanceOf(CliParamsMiddleware::class, $instance); + } +} diff --git a/tests/Service/UrlShortenerTest.php b/tests/Service/UrlShortenerTest.php new file mode 100644 index 00000000..61580e0e --- /dev/null +++ b/tests/Service/UrlShortenerTest.php @@ -0,0 +1,136 @@ +httpClient = $this->prophesize(ClientInterface::class); + + $this->em = $this->prophesize(EntityManagerInterface::class); + $conn = $this->prophesize(Connection::class); + $conn->isTransactionActive()->willReturn(false); + $this->em->getConnection()->willReturn($conn->reveal()); + $this->em->flush()->willReturn(null); + $this->em->commit()->willReturn(null); + $this->em->beginTransaction()->willReturn(null); + $this->em->persist(Argument::any())->will(function ($arguments) { + /** @var ShortUrl $shortUrl */ + $shortUrl = $arguments[0]; + $shortUrl->setId(10); + }); + $repo = $this->prophesize(ObjectRepository::class); + $repo->findOneBy(Argument::any())->willReturn(null); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $this->urlShortener = new UrlShortener($this->httpClient->reveal(), $this->em->reveal()); + } + + /** + * @test + */ + public function urlIsProperlyShortened() + { + // 10 -> 12C1c + $shortCode = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar')); + $this->assertEquals('12C1c', $shortCode); + } + + /** + * @test + * @expectedException \Acelaya\UrlShortener\Exception\RuntimeException + */ + public function exceptionIsThrownWhenOrmThrowsException() + { + $conn = $this->prophesize(Connection::class); + $conn->isTransactionActive()->willReturn(true); + $this->em->getConnection()->willReturn($conn->reveal()); + $this->em->rollback()->shouldBeCalledTimes(1); + $this->em->close()->shouldBeCalledTimes(1); + + $this->em->flush()->willThrow(new ORMException()); + $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar')); + } + + /** + * @test + * @expectedException \Acelaya\UrlShortener\Exception\InvalidUrlException + */ + public function exceptionIsThrownWhenUrlDoesNotExist() + { + $this->httpClient->request(Argument::cetera())->willThrow( + new ClientException('', $this->prophesize(Request::class)->reveal()) + ); + $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar')); + } + + /** + * @test + */ + public function whenShortUrlExistsItsShortcodeIsReturned() + { + $shortUrl = new ShortUrl(); + $shortUrl->setShortCode('expected_shortcode'); + $repo = $this->prophesize(ObjectRepository::class); + $repo->findOneBy(Argument::any())->willReturn($shortUrl); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $shortCode = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar')); + $this->assertEquals($shortUrl->getShortCode(), $shortCode); + } + + /** + * @test + */ + public function shortCodeIsProperlyParsed() + { + // 12C1c -> 10 + $shortUrl = new ShortUrl(); + $shortUrl->setShortCode('12C1c') + ->setOriginalUrl('expected_url'); + + $repo = $this->prophesize(ObjectRepository::class); + $repo->findOneBy(['shortCode' => '12C1c'])->willReturn($shortUrl); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $url = $this->urlShortener->shortCodeToUrl('12C1c'); + $this->assertEquals($shortUrl->getOriginalUrl(), $url); + } + + /** + * @test + * @expectedException \Acelaya\UrlShortener\Exception\InvalidShortCodeException + */ + public function invalidCharSetThrowsException() + { + $this->urlShortener->shortCodeToUrl('&/('); + } +} diff --git a/tests/Service/VisitsTrackerTest.php b/tests/Service/VisitsTrackerTest.php new file mode 100644 index 00000000..7e6707c7 --- /dev/null +++ b/tests/Service/VisitsTrackerTest.php @@ -0,0 +1,30 @@ +prophesize(EntityRepository::class); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl()); + + $em = $this->prophesize(EntityManager::class); + $em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); + $em->persist(Argument::any())->shouldBeCalledTimes(1); + $em->flush()->shouldBeCalledTimes(1); + + $visitsTracker = new VisitsTracker($em->reveal()); + $visitsTracker->track($shortCode); + } +}