diff --git a/.env.dist b/.env.dist index a25e8c2c..d6271d57 100644 --- a/.env.dist +++ b/.env.dist @@ -8,3 +8,7 @@ SHORTCODE_CHARS= DB_USER= DB_PASSWORD= DB_NAME= + +# Rest authentication +REST_USER= +REST_PASSWORD= diff --git a/.travis.yml b/.travis.yml index 77f1332b..eb2ead28 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,6 @@ branches: - develop php: - - 5.5 - 5.6 - 7 - hhvm diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c08baeb7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +## CHANGELOG + +### 0.2.0 + +**Enhancements:** + +* [9: Use symfony/console to dispatch console requests, instead of trying to integrate the process with expressive](https://github.com/acelaya/url-shortener/issues/9) +* [8: Create a REST API](https://github.com/acelaya/url-shortener/issues/8) +* [10: Add more CLI functionality](https://github.com/acelaya/url-shortener/issues/10) + +**Tasks** + +* [5: Create CHANGELOG file](https://github.com/acelaya/url-shortener/issues/5) diff --git a/bin/cli b/bin/cli index 0aca7dd0..e400bef8 100755 --- a/bin/cli +++ b/bin/cli @@ -1,17 +1,10 @@ #!/usr/bin/env php get(Application::class); -$command = count($_SERVER['argv']) > 1 ? $_SERVER['argv'][1] : ''; -$request = ServerRequestFactory::fromGlobals() - ->withMethod('CLI') - ->withUri(new Uri(sprintf('/%s', $command))); -$app->run($request); +$app = $container->get(CliApp::class); +$app->run(); diff --git a/composer.json b/composer.json index d43d7fbb..374fed26 100644 --- a/composer.json +++ b/composer.json @@ -11,19 +11,21 @@ } ], "require": { - "php": "^5.5 || ^7.0", + "php": "^5.6 || ^7.0", "zendframework/zend-expressive": "^1.0", "zendframework/zend-expressive-helpers": "^2.0", "zendframework/zend-expressive-fastroute": "^1.1", "zendframework/zend-expressive-twigrenderer": "^1.0", "zendframework/zend-stdlib": "^2.7", "zendframework/zend-servicemanager": "^3.0", + "zendframework/zend-paginator": "^2.6", "doctrine/orm": "^2.5", "guzzlehttp/guzzle": "^6.2", - "acelaya/zsm-annotated-services": "^0.2.0" + "acelaya/zsm-annotated-services": "^0.2.0", + "symfony/console": "^3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8", + "phpunit/phpunit": "^5.0", "squizlabs/php_codesniffer": "^2.3", "roave/security-advisories": "dev-master", "filp/whoops": "^2.0", diff --git a/config/autoload/cli-routes.global.php b/config/autoload/cli-routes.global.php deleted file mode 100644 index c2aafdf1..00000000 --- a/config/autoload/cli-routes.global.php +++ /dev/null @@ -1,15 +0,0 @@ - [ - [ - 'name' => 'cli-generate-shortcode', - 'path' => '/generate-shortcode', - 'middleware' => CliRoutable\GenerateShortcodeMiddleware::class, - 'allowed_methods' => ['CLI'], - ], - ], - -]; diff --git a/config/autoload/cli.global.php b/config/autoload/cli.global.php new file mode 100644 index 00000000..1276cc9e --- /dev/null +++ b/config/autoload/cli.global.php @@ -0,0 +1,15 @@ + [ + 'commands' => [ + Command\GenerateShortcodeCommand::class, + Command\ResolveUrlCommand::class, + Command\ListShortcodesCommand::class, + Command\GetVisitsCommand::class, + ] + ], + +]; diff --git a/config/autoload/errorhandler.local.php.dist b/config/autoload/errorhandler.local.php.dist new file mode 100644 index 00000000..92a92497 --- /dev/null +++ b/config/autoload/errorhandler.local.php.dist @@ -0,0 +1,21 @@ + [ + 'invokables' => [ + 'Zend\Expressive\Whoops' => Whoops\Run::class, + 'Zend\Expressive\WhoopsPageHandler' => Whoops\Handler\PrettyPageHandler::class, + ], + 'factories' => [ + 'Zend\Expressive\FinalHandler' => Zend\Expressive\Container\WhoopsErrorHandlerFactory::class, + ], + ], + + 'whoops' => [ + 'json_exceptions' => [ + 'display' => true, + 'show_trace' => true, + 'ajax_only' => true, + ], + ], +]; diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index d033f9b2..fc6f85f0 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -1,5 +1,5 @@ [ 'middleware' => [ ApplicationFactory::ROUTING_MIDDLEWARE, - CliParamsMiddleware::class, + ], + 'priority' => 10, + ], + + 'rest' => [ + 'path' => '/rest', + 'middleware' => [ + Middleware\CheckAuthenticationMiddleware::class, + Middleware\CrossDomainMiddleware::class, + ], + 'priority' => 5, + ], + + 'post-routing' => [ + 'middleware' => [ Helper\UrlHelperMiddleware::class, ApplicationFactory::DISPATCH_MIDDLEWARE, ], diff --git a/config/autoload/rest.global.php b/config/autoload/rest.global.php new file mode 100644 index 00000000..6e3fc216 --- /dev/null +++ b/config/autoload/rest.global.php @@ -0,0 +1,9 @@ + [ + 'username' => getenv('REST_USER'), + 'password' => getenv('REST_PASSWORD'), + ], + +]; diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index 40a3d20b..87133f09 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -1,5 +1,6 @@ Routable\RedirectMiddleware::class, 'allowed_methods' => ['GET'], ], + + // Rest + [ + 'name' => 'rest-authenticate', + 'path' => '/rest/authenticate', + 'middleware' => Rest\AuthenticateMiddleware::class, + 'allowed_methods' => ['POST', 'OPTIONS'], + ], + [ + 'name' => 'rest-create-shortcode', + 'path' => '/rest/short-codes', + 'middleware' => Rest\CreateShortcodeMiddleware::class, + 'allowed_methods' => ['POST', 'OPTIONS'], + ], + [ + 'name' => 'rest-resolve-url', + 'path' => '/rest/short-codes/{shortCode}', + 'middleware' => Rest\ResolveUrlMiddleware::class, + 'allowed_methods' => ['GET', 'OPTIONS'], + ], + [ + 'name' => 'rest-list-shortened-url', + 'path' => '/rest/short-codes', + 'middleware' => Rest\ListShortcodesMiddleware::class, + 'allowed_methods' => ['GET'], + ], + [ + 'name' => 'rest-get-visits', + 'path' => '/rest/visits/{shortCode}', + 'middleware' => Rest\GetVisitsMiddleware::class, + 'allowed_methods' => ['GET', 'OPTIONS'], + ], ], ]; diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index 300f1bf3..4d6cc40e 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -1,4 +1,5 @@ [ 'factories' => [ - Application::class => Container\ApplicationFactory::class, + Expressive\Application::class => Container\ApplicationFactory::class, + Console\Application::class => CLI\Factory\ApplicationFactory::class, // Url helpers Helper\UrlHelper::class => Helper\UrlHelperFactory::class, @@ -36,17 +39,31 @@ return [ GuzzleHttp\Client::class => InvokableFactory::class, Service\UrlShortener::class => AnnotatedFactory::class, Service\VisitsTracker::class => AnnotatedFactory::class, + Service\ShortUrlService::class => AnnotatedFactory::class, + Service\RestTokenService::class => AnnotatedFactory::class, Cache::class => CacheFactory::class, + // Cli commands + CLI\Command\GenerateShortcodeCommand::class => AnnotatedFactory::class, + CLI\Command\ResolveUrlCommand::class => AnnotatedFactory::class, + CLI\Command\ListShortcodesCommand::class => AnnotatedFactory::class, + CLI\Command\GetVisitsCommand::class => AnnotatedFactory::class, + // Middleware - Middleware\CliRoutable\GenerateShortcodeMiddleware::class => AnnotatedFactory::class, Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::class, - Middleware\CliParamsMiddleware::class => Middleware\Factory\CliParamsMiddlewareFactory::class, + Middleware\Rest\AuthenticateMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\CreateShortcodeMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\ResolveUrlMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\GetVisitsMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\ListShortcodesMiddleware::class => AnnotatedFactory::class, + Middleware\CrossDomainMiddleware::class => InvokableFactory::class, + Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class, ], 'aliases' => [ 'em' => EntityManager::class, 'httpClient' => GuzzleHttp\Client::class, Router\RouterInterface::class => Router\FastRouteRouter::class, + AnnotatedFactory::CACHE_SERVICE => Cache::class, ] ], diff --git a/data/docs/rest.md b/data/docs/rest.md new file mode 100644 index 00000000..f93f6c48 --- /dev/null +++ b/data/docs/rest.md @@ -0,0 +1,278 @@ + +# REST API documentation + +## Error management + +Statuses: + +* 400 -> controlled error +* 401 -> authentication error +* 500 -> unexpected error + +[TODO] + +## Authentication + +[TODO] + +## Endpoints + +#### Authenticate + +**REQUEST** + +* `POST` -> `/rest/authenticate` +* Params: + * username: `string` + * password: `string` + +**SUCCESS RESPONSE** + +```json +{ + "token": "9f741eb0-33d7-4c56-b8f7-3719e9929946" +} +``` + +**ERROR RESPONSE** + +```json +{ + "error": "INVALID_ARGUMENT", + "message": "You have to provide both \"username\" and \"password\"" +} +``` + +Posible errors: + +* **INVALID_ARGUMENT**: Username or password were not provided. +* **INVALID_CREDENTIALS**: Username or password are incorrect. + + +#### Create shortcode + +**REQUEST** + +* `POST` -> `/rest/short-codes` +* Params: + * longUrl: `string` -> The URL to shorten +* Headers: + * X-Auth-Token: `string` -> The token provided in the authentication request + +**SUCCESS RESPONSE** + +```json +{ + "longUrl": "https://www.facebook.com/something/something", + "shortUrl": "https://doma.in/rY9Kr", + "shortCode": "rY9Kr" +} +``` + +**ERROR RESPONSE** + +```json +{ + "error": "INVALID_URL", + "message": "Provided URL \"wfwef\" is invalid. Try with a different one." +} +``` + +Posible errors: + +* **INVALID_ARGUMENT**: The longUrl was not provided. +* **INVALID_URL**: Provided longUrl has an invalid format or does not resolve. +* **UNKNOWN_ERROR**: Something unexpected happened. + + +#### Resolve URL + +**REQUEST** + +* `GET` -> `/rest/short-codes/{shortCode}` +* Route params: + * shortCode: `string` -> The short code we want to resolve +* Headers: + * X-Auth-Token: `string` -> The token provided in the authentication request + +**SUCCESS RESPONSE** + +```json +{ + "longUrl": "https://www.facebook.com/something/something" +} +``` + +**ERROR RESPONSE** + +```json +{ + "error": "INVALID_SHORTCODE", + "message": "Provided short code \"abc123\" has an invalid format" +} +``` + +Posible errors: + +* **INVALID_ARGUMENT**: No longUrl was found for provided shortCode. +* **INVALID_SHORTCODE**: Provided shortCode does not match the character set used by the app to generate short codes. +* **UNKNOWN_ERROR**: Something unexpected happened. + + +#### List shortened URLs + +**REQUEST** + +* `GET` -> `/rest/short-codes` +* Query params: + * page: `integer` -> The page to list. Defaults to 1 if not provided. +* Headers: + * X-Auth-Token: `string` -> The token provided in the authentication request + +**SUCCESS RESPONSE** + +```json +{ + "shortUrls": { + "data": [ + { + "shortCode": "abc123", + "originalUrl": "http://www.alejandrocelaya.com", + "dateCreated": "2016-04-30T18:01:47+0200", + "visitsCount": 4 + }, + { + "shortCode": "def456", + "originalUrl": "http://www.alejandrocelaya.com/en", + "dateCreated": "2016-04-30T18:03:43+0200", + "visitsCount": 0 + }, + { + "shortCode": "ghi789", + "originalUrl": "http://www.alejandrocelaya.com/es", + "dateCreated": "2016-04-30T18:10:38+0200", + "visitsCount": 0 + }, + { + "shortCode": "jkl987", + "originalUrl": "http://www.alejandrocelaya.com/es/", + "dateCreated": "2016-04-30T18:10:57+0200", + "visitsCount": 0 + }, + { + "shortCode": "mno654", + "originalUrl": "http://blog.alejandrocelaya.com/2016/04/09/improving-zend-service-manager-workflow-with-annotations/", + "dateCreated": "2016-04-30T19:21:05+0200", + "visitsCount": 1 + }, + { + "shortCode": "pqr321", + "originalUrl": "http://www.google.com", + "dateCreated": "2016-05-01T11:19:53+0200", + "visitsCount": 0 + }, + { + "shortCode": "stv159", + "originalUrl": "http://www.acelaya.com", + "dateCreated": "2016-06-12T17:49:21+0200", + "visitsCount": 0 + }, + { + "shortCode": "wxy753", + "originalUrl": "http://www.atomic-reader.com", + "dateCreated": "2016-06-12T17:50:27+0200", + "visitsCount": 0 + }, + { + "shortCode": "zab852", + "originalUrl": "http://foo.com", + "dateCreated": "2016-07-03T09:07:36+0200", + "visitsCount": 0 + }, + { + "shortCode": "cde963", + "originalUrl": "https://www.facebook.com.com", + "dateCreated": "2016-07-03T09:12:35+0200", + "visitsCount": 0 + } + ], + "pagination": { + "currentPage": 4, + "pagesCount": 15 + } + } +} +``` + +**ERROR RESPONSE** + +```json +{ + "error": "UNKNOWN_ERROR", + "message": "Unexpected error occured" +} +``` + +Posible errors: + +* **UNKNOWN_ERROR**: Something unexpected happened. + + +#### Get visits + +**REQUEST** + +* `GET` -> `/rest/visits/{shortCode}` +* Route params: + * shortCode: `string` -> The shortCode from which we eant to get the visits. +* Headers: + * X-Auth-Token: `string` -> The token provided in the authentication request + +**SUCCESS RESPONSE** + +```json +{ + "shortUrls": { + "data": [ + { + "referer": null, + "date": "2016-06-18T09:32:22+0200", + "remoteAddr": "127.0.0.1", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36" + }, + { + "referer": null, + "date": "2016-04-30T19:20:06+0200", + "remoteAddr": "127.0.0.1", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36" + }, + { + "referer": "google.com", + "date": "2016-04-30T19:19:57+0200", + "remoteAddr": "1.2.3.4", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36" + }, + { + "referer": null, + "date": "2016-04-30T19:17:35+0200", + "remoteAddr": "127.0.0.1", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36" + } + ], + } +} +``` + +**ERROR RESPONSE** + +```json +{ + "error": "INVALID_ARGUMENT", + "message": "Provided short code \"abc123\" is invalid" +} +``` + +Posible errors: + +* **INVALID_ARGUMENT**: The shortcode does not belong to any short URL +* **UNKNOWN_ERROR**: Something unexpected happened. diff --git a/src/CLI/Command/GenerateShortcodeCommand.php b/src/CLI/Command/GenerateShortcodeCommand.php new file mode 100644 index 00000000..ffc355db --- /dev/null +++ b/src/CLI/Command/GenerateShortcodeCommand.php @@ -0,0 +1,93 @@ +urlShortener = $urlShortener; + $this->domainConfig = $domainConfig; + } + + public function configure() + { + $this->setName('shortcode:generate') + ->setDescription('Generates a shortcode for provided URL and returns the short URL') + ->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse'); + } + + public function interact(InputInterface $input, OutputInterface $output) + { + $longUrl = $input->getArgument('longUrl'); + if (! empty($longUrl)) { + return; + } + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new Question( + 'A long URL was not provided. Which URL do you want to shorten?: ' + ); + + $longUrl = $helper->ask($input, $output, $question); + if (! empty($longUrl)) { + $input->setArgument('longUrl', $longUrl); + } + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $longUrl = $input->getArgument('longUrl'); + + try { + if (! isset($longUrl)) { + $output->writeln('A URL was not provided!'); + return; + } + + $shortcode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); + $shortUrl = (new Uri())->withPath($shortcode) + ->withScheme($this->domainConfig['schema']) + ->withHost($this->domainConfig['hostname']); + + $output->writeln([ + sprintf('Processed URL %s', $longUrl), + sprintf('Generated URL %s', $shortUrl), + ]); + } catch (InvalidUrlException $e) { + $output->writeln( + sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl) + ); + } + } +} diff --git a/src/CLI/Command/GetVisitsCommand.php b/src/CLI/Command/GetVisitsCommand.php new file mode 100644 index 00000000..3c4e796d --- /dev/null +++ b/src/CLI/Command/GetVisitsCommand.php @@ -0,0 +1,77 @@ +visitsTracker = $visitsTracker; + } + + public function configure() + { + $this->setName('shortcode:visits') + ->setDescription('Returns the detailed visits information for provided short code') + ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get'); + } + + public function interact(InputInterface $input, OutputInterface $output) + { + $shortCode = $input->getArgument('shortCode'); + if (! empty($shortCode)) { + return; + } + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new Question( + 'A short code was not provided. Which short code do you want to use?: ' + ); + + $shortCode = $helper->ask($input, $output, $question); + if (! empty($shortCode)) { + $input->setArgument('shortCode', $shortCode); + } + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $shortCode = $input->getArgument('shortCode'); + $visits = $this->visitsTracker->info($shortCode); + $table = new Table($output); + $table->setHeaders([ + 'Referer', + 'Date', + 'Temote Address', + 'User agent', + ]); + + foreach ($visits as $row) { + $table->addRow(array_values($row->jsonSerialize())); + } + $table->render(); + } +} diff --git a/src/CLI/Command/ListShortcodesCommand.php b/src/CLI/Command/ListShortcodesCommand.php new file mode 100644 index 00000000..ac85d50f --- /dev/null +++ b/src/CLI/Command/ListShortcodesCommand.php @@ -0,0 +1,84 @@ +shortUrlService = $shortUrlService; + } + + public function configure() + { + $this->setName('shortcode:list') + ->setDescription('List all short URLs') + ->addOption( + 'page', + 'p', + InputOption::VALUE_OPTIONAL, + sprintf('The first page to list (%s items per page)', PaginableRepositoryAdapter::ITEMS_PER_PAGE), + 1 + ); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $page = intval($input->getOption('page')); + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + do { + $result = $this->shortUrlService->listShortUrls($page); + $page++; + $table = new Table($output); + $table->setHeaders([ + 'Short code', + 'Original URL', + 'Date created', + 'Visits count', + ]); + + foreach ($result as $row) { + $table->addRow(array_values($row->jsonSerialize())); + } + $table->render(); + + if ($this->isLastPage($result)) { + $continue = false; + $output->writeln('You have reached last page'); + } else { + $continue = $helper->ask($input, $output, new ConfirmationQuestion( + sprintf('Continue with page %s? (y/N) ', $page), + false + )); + } + } while ($continue); + } +} diff --git a/src/CLI/Command/ResolveUrlCommand.php b/src/CLI/Command/ResolveUrlCommand.php new file mode 100644 index 00000000..4eb5ff41 --- /dev/null +++ b/src/CLI/Command/ResolveUrlCommand.php @@ -0,0 +1,78 @@ +urlShortener = $urlShortener; + } + + public function configure() + { + $this->setName('shortcode:parse') + ->setDescription('Returns the long URL behind a short code') + ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse'); + } + + public function interact(InputInterface $input, OutputInterface $output) + { + $shortCode = $input->getArgument('shortCode'); + if (! empty($shortCode)) { + return; + } + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new Question( + 'A short code was not provided. Which short code do you want to parse?: ' + ); + + $shortCode = $helper->ask($input, $output, $question); + if (! empty($shortCode)) { + $input->setArgument('shortCode', $shortCode); + } + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $shortCode = $input->getArgument('shortCode'); + + try { + $longUrl = $this->urlShortener->shortCodeToUrl($shortCode); + if (! isset($longUrl)) { + $output->writeln(sprintf('No URL found for short code "%s"', $shortCode)); + return; + } + + $output->writeln(sprintf('Long URL %s', $longUrl)); + } catch (InvalidShortCodeException $e) { + $output->writeln( + sprintf('Provided short code "%s" has an invalid format.', $shortCode) + ); + } + } +} diff --git a/src/Middleware/Factory/CliParamsMiddlewareFactory.php b/src/CLI/Factory/ApplicationFactory.php similarity index 61% rename from src/Middleware/Factory/CliParamsMiddlewareFactory.php rename to src/CLI/Factory/ApplicationFactory.php index 9be7f52e..d44150d1 100644 --- a/src/Middleware/Factory/CliParamsMiddlewareFactory.php +++ b/src/CLI/Factory/ApplicationFactory.php @@ -1,14 +1,14 @@ get('config')['cli']; + $app = new CliApp(); + + $commands = isset($config['commands']) ? $config['commands'] : []; + foreach ($commands as $command) { + if (! $container->has($command)) { + continue; + } + + $app->add($container->get($command)); + } + + return $app; } } diff --git a/src/Entity/RestToken.php b/src/Entity/RestToken.php new file mode 100644 index 00000000..90a70f0e --- /dev/null +++ b/src/Entity/RestToken.php @@ -0,0 +1,102 @@ +updateExpiration(); + $this->setRandomTokenKey(); + } + + /** + * @return \DateTime + */ + public function getExpirationDate() + { + return $this->expirationDate; + } + + /** + * @param \DateTime $expirationDate + * @return $this + */ + public function setExpirationDate($expirationDate) + { + $this->expirationDate = $expirationDate; + return $this; + } + + /** + * @return string + */ + public function getToken() + { + return $this->token; + } + + /** + * @param string $token + * @return $this + */ + public function setToken($token) + { + $this->token = $token; + return $this; + } + + /** + * @return bool + */ + public function isExpired() + { + return new \DateTime() > $this->expirationDate; + } + + /** + * Updates the expiration of the token, setting it to the default interval in the future + * @return $this + */ + public function updateExpiration() + { + return $this->setExpirationDate((new \DateTime())->add(new \DateInterval(self::DEFAULT_INTERVAL))); + } + + /** + * Sets a random unique token key for this RestToken + * @return RestToken + */ + public function setRandomTokenKey() + { + return $this->setToken($this->generateV4Uuid()); + } +} diff --git a/src/Entity/ShortUrl.php b/src/Entity/ShortUrl.php index 35c7ed1b..9f63af68 100644 --- a/src/Entity/ShortUrl.php +++ b/src/Entity/ShortUrl.php @@ -10,14 +10,14 @@ use Doctrine\ORM\Mapping as ORM; * @author * @link * - * @ORM\Entity + * @ORM\Entity(repositoryClass="Acelaya\UrlShortener\Repository\ShortUrlRepository") * @ORM\Table(name="short_urls") */ -class ShortUrl extends AbstractEntity +class ShortUrl extends AbstractEntity implements \JsonSerializable { /** * @var string - * @ORM\Column(name="original_url", type="string", nullable=false, length=1024, unique=true) + * @ORM\Column(name="original_url", type="string", nullable=false, length=1024) */ protected $originalUrl; /** @@ -117,4 +117,21 @@ class ShortUrl extends AbstractEntity $this->visits = $visits; return $this; } + + /** + * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize() + { + return [ + 'shortCode' => $this->shortCode, + 'originalUrl' => $this->originalUrl, + 'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ISO8601) : null, + 'visitsCount' => count($this->visits), + ]; + } } diff --git a/src/Entity/Visit.php b/src/Entity/Visit.php index 2e8fad6f..42aebec5 100644 --- a/src/Entity/Visit.php +++ b/src/Entity/Visit.php @@ -11,7 +11,7 @@ use Doctrine\ORM\Mapping as ORM; * @ORM\Entity * @ORM\Table(name="visits") */ -class Visit extends AbstractEntity +class Visit extends AbstractEntity implements \JsonSerializable { /** * @var string @@ -134,4 +134,21 @@ class Visit extends AbstractEntity $this->userAgent = $userAgent; return $this; } + + /** + * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize() + { + return [ + 'referer' => $this->referer, + 'date' => isset($this->date) ? $this->date->format(\DateTime::ISO8601) : null, + 'remoteAddr' => $this->remoteAddr, + 'userAgent' => $this->userAgent, + ]; + } } diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php new file mode 100644 index 00000000..0876be75 --- /dev/null +++ b/src/Exception/AuthenticationException.php @@ -0,0 +1,10 @@ + "%s". Password -> "%s"', $username, $password)); + } +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 00000000..4b851930 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,6 @@ +restTokenService = $restTokenService; + } + + /** + * 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) + { + // If current route is the authenticate route or an OPTIONS request, continue to the next middleware + /** @var RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class); + if ((isset($routeResult) && $routeResult->getMatchedRouteName() === 'rest-authenticate') + || strtolower($request->getMethod()) === 'options' + ) { + return $out($request, $response); + } + + // Check that the auth header was provided, and that it belongs to a non-expired token + if (! $request->hasHeader(self::AUTH_TOKEN_HEADER)) { + return $this->createTokenErrorResponse(); + } + + $authToken = $request->getHeaderLine(self::AUTH_TOKEN_HEADER); + try { + $restToken = $this->restTokenService->getByToken($authToken); + if ($restToken->isExpired()) { + return $this->createTokenErrorResponse(); + } + + // Update the token expiration and continue to next middleware + $this->restTokenService->updateExpiration($restToken); + return $out($request, $response); + } catch (InvalidArgumentException $e) { + return $this->createTokenErrorResponse(); + } + } + + protected function createTokenErrorResponse() + { + return new JsonResponse([ + 'error' => RestUtils::INVALID_AUTH_TOKEN_ERROR, + 'message' => sprintf( + 'Missing or invalid auth token provided. Perform a new authentication request and send provided token ' + . 'on every new request on the "%s" header', + self::AUTH_TOKEN_HEADER + ), + ], 401); + } +} diff --git a/src/Middleware/CliRoutable/GenerateShortcodeMiddleware.php b/src/Middleware/CliRoutable/GenerateShortcodeMiddleware.php deleted file mode 100644 index 6573ed60..00000000 --- a/src/Middleware/CliRoutable/GenerateShortcodeMiddleware.php +++ /dev/null @@ -1,91 +0,0 @@ -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/CliParamsMiddleware.php b/src/Middleware/CrossDomainMiddleware.php similarity index 56% rename from src/Middleware/CliParamsMiddleware.php rename to src/Middleware/CrossDomainMiddleware.php index 683a8e86..c76d4d73 100644 --- a/src/Middleware/CliParamsMiddleware.php +++ b/src/Middleware/CrossDomainMiddleware.php @@ -3,26 +3,10 @@ namespace Acelaya\UrlShortener\Middleware; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Zend\Expressive\Router\RouteResult; use Zend\Stratigility\MiddlewareInterface; -class CliParamsMiddleware implements MiddlewareInterface +class CrossDomainMiddleware implements MiddlewareInterface { - /** - * @var array - */ - private $argv; - /** - * @var - */ - private $currentSapi; - - public function __construct(array $argv, $currentSapi) - { - $this->argv = $argv; - $this->currentSapi = $currentSapi; - } - /** * Process an incoming request and/or response. * @@ -50,22 +34,19 @@ class CliParamsMiddleware implements MiddlewareInterface */ 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 Response $response */ + $response = $out($request, $response); + + if (strtolower($request->getMethod()) === 'options') { + $response = $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + ->withHeader('Access-Control-Max-Age', '1000') + ->withHeader( + // Allow all requested headers + 'Access-Control-Allow-Headers', + $request->getHeaderLine('Access-Control-Request-Headers') + ); } - /** @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); + return $response->withHeader('Access-Control-Allow-Origin', '*'); } } diff --git a/src/Middleware/Rest/AbstractRestMiddleware.php b/src/Middleware/Rest/AbstractRestMiddleware.php new file mode 100644 index 00000000..1168ff60 --- /dev/null +++ b/src/Middleware/Rest/AbstractRestMiddleware.php @@ -0,0 +1,51 @@ +getMethod()) === 'options') { + return $response; + } + + return $this->dispatch($request, $response, $out); + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + abstract protected function dispatch(Request $request, Response $response, callable $out = null); +} diff --git a/src/Middleware/Rest/AuthenticateMiddleware.php b/src/Middleware/Rest/AuthenticateMiddleware.php new file mode 100644 index 00000000..88c9df60 --- /dev/null +++ b/src/Middleware/Rest/AuthenticateMiddleware.php @@ -0,0 +1,57 @@ +restTokenService = $restTokenService; + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + public function dispatch(Request $request, Response $response, callable $out = null) + { + $authData = $request->getParsedBody(); + if (! isset($authData['username'], $authData['password'])) { + return new JsonResponse([ + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'message' => 'You have to provide both "username" and "password"' + ], 400); + } + + try { + $token = $this->restTokenService->createToken($authData['username'], $authData['password']); + return new JsonResponse(['token' => $token->getToken()]); + } catch (AuthenticationException $e) { + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => 'Invalid username and/or password', + ], 401); + } + } +} diff --git a/src/Middleware/Rest/CreateShortcodeMiddleware.php b/src/Middleware/Rest/CreateShortcodeMiddleware.php new file mode 100644 index 00000000..f5ee3228 --- /dev/null +++ b/src/Middleware/Rest/CreateShortcodeMiddleware.php @@ -0,0 +1,79 @@ +urlShortener = $urlShortener; + $this->domainConfig = $domainConfig; + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + public function dispatch(Request $request, Response $response, callable $out = null) + { + $postData = $request->getParsedBody(); + if (! isset($postData['longUrl'])) { + return new JsonResponse([ + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'message' => 'A URL was not provided', + ], 400); + } + $longUrl = $postData['longUrl']; + + try { + $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); + $shortUrl = (new Uri())->withPath($shortCode) + ->withScheme($this->domainConfig['schema']) + ->withHost($this->domainConfig['hostname']); + + return new JsonResponse([ + 'longUrl' => $longUrl, + 'shortUrl' => $shortUrl->__toString(), + 'shortCode' => $shortCode, + ]); + } catch (InvalidUrlException $e) { + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl), + ], 400); + } catch (\Exception $e) { + return new JsonResponse([ + 'error' => RestUtils::UNKNOWN_ERROR, + 'message' => 'Unexpected error occured', + ], 500); + } + } +} diff --git a/src/Middleware/Rest/GetVisitsMiddleware.php b/src/Middleware/Rest/GetVisitsMiddleware.php new file mode 100644 index 00000000..a6ca954e --- /dev/null +++ b/src/Middleware/Rest/GetVisitsMiddleware.php @@ -0,0 +1,62 @@ +visitsTracker = $visitsTracker; + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + public function dispatch(Request $request, Response $response, callable $out = null) + { + $shortCode = $request->getAttribute('shortCode'); + + try { + $visits = $this->visitsTracker->info($shortCode); + + return new JsonResponse([ + 'visits' => [ + 'data' => $visits, +// 'pagination' => [], + ] + ]); + } catch (InvalidArgumentException $e) { + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => sprintf('Provided short code "%s" is invalid', $shortCode), + ], 400); + } catch (\Exception $e) { + return new JsonResponse([ + 'error' => RestUtils::UNKNOWN_ERROR, + 'message' => 'Unexpected error occured', + ], 500); + } + } +} diff --git a/src/Middleware/Rest/ListShortcodesMiddleware.php b/src/Middleware/Rest/ListShortcodesMiddleware.php new file mode 100644 index 00000000..99d82454 --- /dev/null +++ b/src/Middleware/Rest/ListShortcodesMiddleware.php @@ -0,0 +1,53 @@ +shortUrlService = $shortUrlService; + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + public function dispatch(Request $request, Response $response, callable $out = null) + { + try { + $query = $request->getQueryParams(); + $shortUrls = $this->shortUrlService->listShortUrls(isset($query['page']) ? $query['page'] : 1); + return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls)]); + } catch (\Exception $e) { + return new JsonResponse([ + 'error' => RestUtils::UNKNOWN_ERROR, + 'message' => 'Unexpected error occured', + ], 500); + } + } +} diff --git a/src/Middleware/Rest/ResolveUrlMiddleware.php b/src/Middleware/Rest/ResolveUrlMiddleware.php new file mode 100644 index 00000000..4529e973 --- /dev/null +++ b/src/Middleware/Rest/ResolveUrlMiddleware.php @@ -0,0 +1,65 @@ +urlShortener = $urlShortener; + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + public function dispatch(Request $request, Response $response, callable $out = null) + { + $shortCode = $request->getAttribute('shortCode'); + + try { + $longUrl = $this->urlShortener->shortCodeToUrl($shortCode); + if (! isset($longUrl)) { + return new JsonResponse([ + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'message' => sprintf('No URL found for shortcode "%s"', $shortCode), + ], 400); + } + + return new JsonResponse([ + 'longUrl' => $longUrl, + ]); + } catch (InvalidShortCodeException $e) { + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => sprintf('Provided short code "%s" has an invalid format', $shortCode), + ], 400); + } catch (\Exception $e) { + return new JsonResponse([ + 'error' => RestUtils::UNKNOWN_ERROR, + 'message' => 'Unexpected error occured', + ], 500); + } + } +} diff --git a/src/Paginator/Adapter/PaginableRepositoryAdapter.php b/src/Paginator/Adapter/PaginableRepositoryAdapter.php new file mode 100644 index 00000000..243e4a1b --- /dev/null +++ b/src/Paginator/Adapter/PaginableRepositoryAdapter.php @@ -0,0 +1,56 @@ +paginableRepository = $paginableRepository; + $this->searchTerm = $searchTerm; + $this->orderBy = $orderBy; + } + + /** + * Returns a collection of items for a page. + * + * @param int $offset Page offset + * @param int $itemCountPerPage Number of items per page + * @return array + */ + public function getItems($offset, $itemCountPerPage) + { + return $this->paginableRepository->findList($itemCountPerPage, $offset, $this->searchTerm, $this->orderBy); + } + + /** + * Count elements of an object + * @link http://php.net/manual/en/countable.count.php + * @return int The custom count as an integer. + *

+ *

+ * The return value is cast to an integer. + * @since 5.1.0 + */ + public function count() + { + return $this->paginableRepository->countList($this->searchTerm); + } +} diff --git a/src/Paginator/Util/PaginatorUtilsTrait.php b/src/Paginator/Util/PaginatorUtilsTrait.php new file mode 100644 index 00000000..270aaea4 --- /dev/null +++ b/src/Paginator/Util/PaginatorUtilsTrait.php @@ -0,0 +1,30 @@ + ArrayUtils::iteratorToArray($paginator->getCurrentItems()), + 'pagination' => [ + 'currentPage' => $paginator->getCurrentPageNumber(), + 'pagesCount' => $paginator->count(), + ], + ]; + } + + /** + * Checks if provided paginator is in last page + * + * @param Paginator $paginator + * @return bool + */ + protected function isLastPage(Paginator $paginator) + { + return $paginator->getCurrentPageNumber() >= $paginator->count(); + } +} diff --git a/src/Repository/PaginableRepositoryInterface.php b/src/Repository/PaginableRepositoryInterface.php new file mode 100644 index 00000000..99c8696e --- /dev/null +++ b/src/Repository/PaginableRepositoryInterface.php @@ -0,0 +1,24 @@ +createQueryBuilder('s'); + + if (isset($limit)) { + $qb->setMaxResults($limit); + } + if (isset($offset)) { + $qb->setFirstResult($offset); + } + if (isset($searchTerm)) { + // TODO + } + if (isset($orderBy)) { + if (is_string($orderBy)) { + $qb->orderBy($orderBy); + } elseif (is_array($orderBy)) { + $key = key($orderBy); + $qb->orderBy($key, $orderBy[$key]); + } + } else { + $qb->orderBy('s.dateCreated'); + } + + return $qb->getQuery()->getResult(); + } + + /** + * Counts the number of elements in a list using provided filtering data + * + * @param null $searchTerm + * @return int + */ + public function countList($searchTerm = null) + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('COUNT(s)') + ->from(ShortUrl::class, 's'); + + if (isset($searchTerm)) { + // TODO + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/src/Repository/ShortUrlRepositoryInterface.php b/src/Repository/ShortUrlRepositoryInterface.php new file mode 100644 index 00000000..be7f3fff --- /dev/null +++ b/src/Repository/ShortUrlRepositoryInterface.php @@ -0,0 +1,8 @@ +em = $em; + $this->restConfig = $restConfig; + } + + /** + * @param string $token + * @return RestToken + * @throws InvalidArgumentException + */ + public function getByToken($token) + { + $restToken = $this->em->getRepository(RestToken::class)->findOneBy([ + 'token' => $token, + ]); + if (! isset($restToken)) { + throw new InvalidArgumentException(sprintf('RestToken not found for token "%s"', $token)); + } + + return $restToken; + } + + /** + * Creates and returns a new RestToken if username and password are correct + * @param $username + * @param $password + * @return RestToken + * @throws AuthenticationException + */ + public function createToken($username, $password) + { + $this->processCredentials($username, $password); + + $restToken = new RestToken(); + $this->em->persist($restToken); + $this->em->flush(); + + return $restToken; + } + + /** + * @param string $username + * @param string $password + */ + protected function processCredentials($username, $password) + { + $configUsername = strtolower(trim($this->restConfig['username'])); + $providedUsername = strtolower(trim($username)); + $configPassword = trim($this->restConfig['password']); + $providedPassword = trim($password); + + if ($configUsername === $providedUsername && $configPassword === $providedPassword) { + return; + } + + // If credentials are not correct, throw exception + throw AuthenticationException::fromCredentials($providedUsername, $providedPassword); + } + + /** + * Updates the expiration of provided token, extending its life + * + * @param RestToken $token + */ + public function updateExpiration(RestToken $token) + { + $token->updateExpiration(); + $this->em->flush(); + } +} diff --git a/src/Service/RestTokenServiceInterface.php b/src/Service/RestTokenServiceInterface.php new file mode 100644 index 00000000..0cdec822 --- /dev/null +++ b/src/Service/RestTokenServiceInterface.php @@ -0,0 +1,32 @@ +em = $em; + } + + /** + * @param int $page + * @return Paginator|ShortUrl[] + */ + public function listShortUrls($page = 1) + { + /** @var ShortUrlRepository $repo */ + $repo = $this->em->getRepository(ShortUrl::class); + $paginator = new Paginator(new PaginableRepositoryAdapter($repo)); + $paginator->setItemCountPerPage(PaginableRepositoryAdapter::ITEMS_PER_PAGE) + ->setCurrentPageNumber($page); + + return $paginator; + } +} diff --git a/src/Service/ShortUrlServiceInterface.php b/src/Service/ShortUrlServiceInterface.php new file mode 100644 index 00000000..a9d182d2 --- /dev/null +++ b/src/Service/ShortUrlServiceInterface.php @@ -0,0 +1,14 @@ +em->getRepository(ShortUrl::class)->findOneBy([ + 'shortCode' => $shortCode, + ]); + if (! isset($shortUrl)) { + throw new InvalidArgumentException(sprintf('Short code "%s" not found', $shortCode)); + } + + return $this->em->getRepository(Visit::class)->findBy([ + 'shortUrl' => $shortUrl, + ], [ + 'date' => 'DESC' + ]); + } } diff --git a/src/Service/VisitsTrackerInterface.php b/src/Service/VisitsTrackerInterface.php index 3b2fc874..ce0a61cf 100644 --- a/src/Service/VisitsTrackerInterface.php +++ b/src/Service/VisitsTrackerInterface.php @@ -1,6 +1,9 @@ __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 deleted file mode 100644 index 3fad4bab..00000000 --- a/tests/Middleware/Factory/CliParamsMiddlewareFactoryTest.php +++ /dev/null @@ -1,29 +0,0 @@ -factory = new CliParamsMiddlewareFactory(); - } - - /** - * @test - */ - public function serviceIsCreated() - { - $instance = $this->factory->__invoke(new ServiceManager(), ''); - $this->assertInstanceOf(CliParamsMiddleware::class, $instance); - } -} diff --git a/tests/Service/ShortUrlServiceTest.php b/tests/Service/ShortUrlServiceTest.php new file mode 100644 index 00000000..3b77ee6a --- /dev/null +++ b/tests/Service/ShortUrlServiceTest.php @@ -0,0 +1,49 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->service = new ShortUrlService($this->em->reveal()); + } + + /** + * @test + */ + public function listedUrlsAreReturnedFromEntityManager() + { + $list = [ + new ShortUrl(), + new ShortUrl(), + new ShortUrl(), + new ShortUrl(), + ]; + + $repo = $this->prophesize(ShortUrlRepository::class); + $repo->findList(Argument::cetera())->willReturn($list)->shouldBeCalledTimes(1); + $repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledTimes(1); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $list = $this->service->listShortUrls(); + $this->assertEquals(4, $list->getCurrentItemCount()); + } +}