From 8a7d5a499ee2ca137cf06dafe5298619a0cfbe3f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 08:33:22 +0200 Subject: [PATCH 01/18] Dropped unused middleware --- src/Middleware/CliParamsMiddleware.php | 71 -------------------------- 1 file changed, 71 deletions(-) delete mode 100644 src/Middleware/CliParamsMiddleware.php diff --git a/src/Middleware/CliParamsMiddleware.php b/src/Middleware/CliParamsMiddleware.php deleted file mode 100644 index 683a8e86..00000000 --- a/src/Middleware/CliParamsMiddleware.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php -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 -{ - /** - * @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. - * - * 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); - } -} From 1fbefbbd15bf365416fe522b53ccdd5493d7b07d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Sun, 12 Jun 2016 17:51:30 +0200 Subject: [PATCH 02/18] Created shortcode creation rest endpoint --- composer.json | 4 +- config/autoload/routes.global.php | 9 ++ .../Rest/CreateShortcodeMiddleware.php | 98 +++++++++++++++++++ src/Util/RestUtils.php | 26 +++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 src/Middleware/Rest/CreateShortcodeMiddleware.php create mode 100644 src/Util/RestUtils.php diff --git a/composer.json b/composer.json index 922e9067..15f92bee 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ } ], "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", @@ -24,7 +24,7 @@ "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/routes.global.php b/config/autoload/routes.global.php index 40a3d20b..7f16c641 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -1,5 +1,6 @@ <?php use Acelaya\UrlShortener\Middleware\Routable; +use Acelaya\UrlShortener\Middleware\Rest; return [ @@ -10,6 +11,14 @@ return [ 'middleware' => Routable\RedirectMiddleware::class, 'allowed_methods' => ['GET'], ], + + // Rest + [ + 'name' => 'rest-create-shortcode', + 'path' => '/rest/short-code', + 'middleware' => Rest\CreateShortcodeMiddleware::class, + 'allowed_methods' => ['POST'], + ], ], ]; diff --git a/src/Middleware/Rest/CreateShortcodeMiddleware.php b/src/Middleware/Rest/CreateShortcodeMiddleware.php new file mode 100644 index 00000000..21ab4379 --- /dev/null +++ b/src/Middleware/Rest/CreateShortcodeMiddleware.php @@ -0,0 +1,98 @@ +<?php +namespace Acelaya\UrlShortener\Middleware\Rest; + +use Acelaya\UrlShortener\Exception\InvalidUrlException; +use Acelaya\UrlShortener\Service\UrlShortener; +use Acelaya\UrlShortener\Service\UrlShortenerInterface; +use Acelaya\UrlShortener\Util\RestUtils; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Uri; +use Zend\Stratigility\MiddlewareInterface; + +class CreateShortcodeMiddleware implements MiddlewareInterface +{ + /** + * @var UrlShortener|UrlShortenerInterface + */ + private $urlShortener; + /** + * @var array + */ + private $domainConfig; + + /** + * GenerateShortcodeMiddleware constructor. + * + * @param UrlShortenerInterface|UrlShortener $urlShortener + * @param array $domainConfig + * + * @Inject({UrlShortener::class, "config.url_shortener.domain"}) + */ + public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig) + { + $this->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) + { + $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(), + ]); + } 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' => sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl), + ], 500); + } + } +} diff --git a/src/Util/RestUtils.php b/src/Util/RestUtils.php new file mode 100644 index 00000000..d4c7179d --- /dev/null +++ b/src/Util/RestUtils.php @@ -0,0 +1,26 @@ +<?php +namespace Acelaya\UrlShortener\Util; + +use Acelaya\UrlShortener\Exception\ExceptionInterface; +use Acelaya\UrlShortener\Exception\InvalidShortCodeException; +use Acelaya\UrlShortener\Exception\InvalidUrlException; + +class RestUtils +{ + const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE'; + const INVALID_URL_ERROR = 'INVALID_URL'; + const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMEN'; + const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; + + public static function getRestErrorCodeFromException(ExceptionInterface $e) + { + switch (true) { + case $e instanceof InvalidShortCodeException: + return self::INVALID_SHORTCODE_ERROR; + case $e instanceof InvalidUrlException: + return self::INVALID_URL_ERROR; + default: + return self::UNKNOWN_ERROR; + } + } +} From ab8ccd7df11c64a5abe4f103d0677243bba6f233 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Sun, 12 Jun 2016 21:31:28 +0200 Subject: [PATCH 03/18] Added get URL rest endpoint --- config/autoload/routes.global.php | 6 ++ config/autoload/services.global.php | 2 + .../Rest/CreateShortcodeMiddleware.php | 2 +- src/Middleware/Rest/ResolveUrlMiddleware.php | 85 +++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/Middleware/Rest/ResolveUrlMiddleware.php diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index 7f16c641..60da6d2a 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -19,6 +19,12 @@ return [ 'middleware' => Rest\CreateShortcodeMiddleware::class, 'allowed_methods' => ['POST'], ], + [ + 'name' => 'rest-resolve-url', + 'path' => '/rest/short-code/{shortCode}', + 'middleware' => Rest\ResolveUrlMiddleware::class, + 'allowed_methods' => ['GET'], + ], ], ]; diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index 617242b5..46890750 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -44,6 +44,8 @@ return [ // Middleware Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\CreateShortcodeMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\ResolveUrlMiddleware::class => AnnotatedFactory::class, ], 'aliases' => [ 'em' => EntityManager::class, diff --git a/src/Middleware/Rest/CreateShortcodeMiddleware.php b/src/Middleware/Rest/CreateShortcodeMiddleware.php index 21ab4379..1e723d48 100644 --- a/src/Middleware/Rest/CreateShortcodeMiddleware.php +++ b/src/Middleware/Rest/CreateShortcodeMiddleware.php @@ -91,7 +91,7 @@ class CreateShortcodeMiddleware implements MiddlewareInterface } catch (\Exception $e) { return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, - 'message' => sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl), + '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..1beee164 --- /dev/null +++ b/src/Middleware/Rest/ResolveUrlMiddleware.php @@ -0,0 +1,85 @@ +<?php +namespace Acelaya\UrlShortener\Middleware\Rest; + +use Acelaya\UrlShortener\Exception\InvalidShortCodeException; +use Acelaya\UrlShortener\Service\UrlShortener; +use Acelaya\UrlShortener\Service\UrlShortenerInterface; +use Acelaya\UrlShortener\Util\RestUtils; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Diactoros\Response\JsonResponse; +use Zend\Stratigility\MiddlewareInterface; + +class ResolveUrlMiddleware implements MiddlewareInterface +{ + /** + * @var UrlShortenerInterface + */ + private $urlShortener; + + /** + * ResolveUrlMiddleware constructor. + * @param UrlShortenerInterface|UrlShortener $urlShortener + * + * @Inject({UrlShortener::class}) + */ + public function __construct(UrlShortenerInterface $urlShortener) + { + $this->urlShortener = $urlShortener; + } + + /** + * 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 (! 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); + } + } +} From 305df3a95ba0e6386cad17cfa39edc40dc644da8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Sun, 12 Jun 2016 21:51:06 +0200 Subject: [PATCH 04/18] Implemented rest endpoint to return shortcode visits --- config/autoload/routes.global.php | 10 ++- config/autoload/services.global.php | 2 + src/Entity/Visit.php | 19 ++++- src/Exception/InvalidArgumentException.php | 6 ++ src/Middleware/Rest/GetVisitsMiddleware.php | 82 +++++++++++++++++++++ src/Service/VisitsTracker.php | 25 +++++++ src/Service/VisitsTrackerInterface.php | 10 +++ src/Util/RestUtils.php | 14 ++-- 8 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 src/Exception/InvalidArgumentException.php create mode 100644 src/Middleware/Rest/GetVisitsMiddleware.php diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index 60da6d2a..680a195d 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -15,16 +15,22 @@ return [ // Rest [ 'name' => 'rest-create-shortcode', - 'path' => '/rest/short-code', + 'path' => '/rest/short-codes', 'middleware' => Rest\CreateShortcodeMiddleware::class, 'allowed_methods' => ['POST'], ], [ 'name' => 'rest-resolve-url', - 'path' => '/rest/short-code/{shortCode}', + 'path' => '/rest/short-codes/{shortCode}', 'middleware' => Rest\ResolveUrlMiddleware::class, 'allowed_methods' => ['GET'], ], + [ + 'name' => 'rest-get-visits', + 'path' => '/rest/visits/{shortCode}', + 'middleware' => Rest\GetVisitsMiddleware::class, + 'allowed_methods' => ['GET'], + ], ], ]; diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index 46890750..8dada5e8 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -46,11 +46,13 @@ return [ Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::class, Middleware\Rest\CreateShortcodeMiddleware::class => AnnotatedFactory::class, Middleware\Rest\ResolveUrlMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\GetVisitsMiddleware::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/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 <b>json_encode</b>, + * 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/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 @@ +<?php +namespace Acelaya\UrlShortener\Exception; + +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Middleware/Rest/GetVisitsMiddleware.php b/src/Middleware/Rest/GetVisitsMiddleware.php new file mode 100644 index 00000000..b932622d --- /dev/null +++ b/src/Middleware/Rest/GetVisitsMiddleware.php @@ -0,0 +1,82 @@ +<?php +namespace Acelaya\UrlShortener\Middleware\Rest; + +use Acelaya\UrlShortener\Exception\InvalidArgumentException; +use Acelaya\UrlShortener\Service\VisitsTracker; +use Acelaya\UrlShortener\Service\VisitsTrackerInterface; +use Acelaya\UrlShortener\Util\RestUtils; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Diactoros\Response\JsonResponse; +use Zend\Stratigility\MiddlewareInterface; + +class GetVisitsMiddleware implements MiddlewareInterface +{ + /** + * @var VisitsTrackerInterface + */ + private $visitsTracker; + + /** + * GetVisitsMiddleware constructor. + * @param VisitsTrackerInterface|VisitsTracker $visitsTracker + * + * @Inject({VisitsTracker::class}) + */ + public function __construct(VisitsTrackerInterface $visitsTracker) + { + $this->visitsTracker = $visitsTracker; + } + + /** + * 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 { + $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/Service/VisitsTracker.php b/src/Service/VisitsTracker.php index 453627d9..4d091288 100644 --- a/src/Service/VisitsTracker.php +++ b/src/Service/VisitsTracker.php @@ -3,6 +3,8 @@ namespace Acelaya\UrlShortener\Service; use Acelaya\UrlShortener\Entity\ShortUrl; use Acelaya\UrlShortener\Entity\Visit; +use Acelaya\UrlShortener\Exception\InvalidArgumentException; +use Acelaya\UrlShortener\Exception\InvalidShortCodeException; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Doctrine\ORM\EntityManagerInterface; @@ -58,4 +60,27 @@ class VisitsTracker implements VisitsTrackerInterface { return isset($array[$key]) ? $array[$key] : $default; } + + /** + * Returns the visits on certain shortcode + * + * @param $shortCode + * @return Visit[] + */ + public function info($shortCode) + { + /** @var ShortUrl $shortUrl */ + $shortUrl = $this->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..0d524223 100644 --- a/src/Service/VisitsTrackerInterface.php +++ b/src/Service/VisitsTrackerInterface.php @@ -1,6 +1,8 @@ <?php namespace Acelaya\UrlShortener\Service; +use Acelaya\UrlShortener\Entity\Visit; + interface VisitsTrackerInterface { /** @@ -10,4 +12,12 @@ interface VisitsTrackerInterface * @param array $visitorData Defaults to global $_SERVER */ public function track($shortCode, array $visitorData = null); + + /** + * Returns the visits on certain shortcode + * + * @param $shortCode + * @return Visit[] + */ + public function info($shortCode); } diff --git a/src/Util/RestUtils.php b/src/Util/RestUtils.php index d4c7179d..301c2e57 100644 --- a/src/Util/RestUtils.php +++ b/src/Util/RestUtils.php @@ -1,24 +1,24 @@ <?php namespace Acelaya\UrlShortener\Util; -use Acelaya\UrlShortener\Exception\ExceptionInterface; -use Acelaya\UrlShortener\Exception\InvalidShortCodeException; -use Acelaya\UrlShortener\Exception\InvalidUrlException; +use Acelaya\UrlShortener\Exception; class RestUtils { const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE'; const INVALID_URL_ERROR = 'INVALID_URL'; - const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMEN'; + const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT'; const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; - public static function getRestErrorCodeFromException(ExceptionInterface $e) + public static function getRestErrorCodeFromException(Exception\ExceptionInterface $e) { switch (true) { - case $e instanceof InvalidShortCodeException: + case $e instanceof Exception\InvalidShortCodeException: return self::INVALID_SHORTCODE_ERROR; - case $e instanceof InvalidUrlException: + case $e instanceof Exception\InvalidUrlException: return self::INVALID_URL_ERROR; + case $e instanceof Exception\InvalidArgumentException: + return self::INVALID_ARGUMENT_ERROR; default: return self::UNKNOWN_ERROR; } From 4129d35447ee650810c4fc1a3112ab7ac94016d8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Sat, 18 Jun 2016 09:43:29 +0200 Subject: [PATCH 05/18] Added list short URLs endpoint to rest api --- config/autoload/routes.global.php | 6 ++ config/autoload/services.global.php | 2 + src/Entity/ShortUrl.php | 19 ++++- .../Rest/ListShortcodesMiddleware.php | 74 +++++++++++++++++++ src/Service/ShortUrlService.php | 33 +++++++++ src/Service/ShortUrlServiceInterface.php | 12 +++ tests/Service/ShortUrlServiceTest.php | 45 +++++++++++ 7 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/Middleware/Rest/ListShortcodesMiddleware.php create mode 100644 src/Service/ShortUrlService.php create mode 100644 src/Service/ShortUrlServiceInterface.php create mode 100644 tests/Service/ShortUrlServiceTest.php diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index 680a195d..b2976df5 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -25,6 +25,12 @@ return [ 'middleware' => Rest\ResolveUrlMiddleware::class, 'allowed_methods' => ['GET'], ], + [ + 'name' => 'rest-list-shortened-url', + 'path' => '/rest/short-codes', + 'middleware' => Rest\ListShortcodesMiddleware::class, + 'allowed_methods' => ['GET'], + ], [ 'name' => 'rest-get-visits', 'path' => '/rest/visits/{shortCode}', diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index 8dada5e8..92a4adce 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -37,6 +37,7 @@ return [ GuzzleHttp\Client::class => InvokableFactory::class, Service\UrlShortener::class => AnnotatedFactory::class, Service\VisitsTracker::class => AnnotatedFactory::class, + Service\ShortUrlService::class => AnnotatedFactory::class, Cache::class => CacheFactory::class, // Cli commands @@ -47,6 +48,7 @@ return [ Middleware\Rest\CreateShortcodeMiddleware::class => AnnotatedFactory::class, Middleware\Rest\ResolveUrlMiddleware::class => AnnotatedFactory::class, Middleware\Rest\GetVisitsMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\ListShortcodesMiddleware::class => AnnotatedFactory::class, ], 'aliases' => [ 'em' => EntityManager::class, diff --git a/src/Entity/ShortUrl.php b/src/Entity/ShortUrl.php index d1d5c4f7..fe96d651 100644 --- a/src/Entity/ShortUrl.php +++ b/src/Entity/ShortUrl.php @@ -13,7 +13,7 @@ use Doctrine\ORM\Mapping as ORM; * @ORM\Entity * @ORM\Table(name="short_urls") */ -class ShortUrl extends AbstractEntity +class ShortUrl extends AbstractEntity implements \JsonSerializable { /** * @var string @@ -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 <b>json_encode</b>, + * 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/Middleware/Rest/ListShortcodesMiddleware.php b/src/Middleware/Rest/ListShortcodesMiddleware.php new file mode 100644 index 00000000..d437b742 --- /dev/null +++ b/src/Middleware/Rest/ListShortcodesMiddleware.php @@ -0,0 +1,74 @@ +<?php +namespace Acelaya\UrlShortener\Middleware\Rest; + +use Acelaya\UrlShortener\Service\ShortUrlService; +use Acelaya\UrlShortener\Service\ShortUrlServiceInterface; +use Acelaya\UrlShortener\Util\RestUtils; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Diactoros\Response\JsonResponse; +use Zend\Stratigility\MiddlewareInterface; + +class ListShortcodesMiddleware implements MiddlewareInterface +{ + /** + * @var ShortUrlServiceInterface + */ + private $shortUrlService; + + /** + * ListShortcodesMiddleware constructor. + * @param ShortUrlServiceInterface|ShortUrlService $shortUrlService + * + * @Inject({ShortUrlService::class}) + */ + public function __construct(ShortUrlServiceInterface $shortUrlService) + { + $this->shortUrlService = $shortUrlService; + } + + /** + * 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) + { + try { + $shortUrls = $this->shortUrlService->listShortUrls(); + + return new JsonResponse([ + 'shortUrls' => [ + 'data' => $shortUrls, +// 'pagination' => [], + ] + ]); + } catch (\Exception $e) { + return new JsonResponse([ + 'error' => RestUtils::UNKNOWN_ERROR, + 'message' => 'Unexpected error occured', + ], 500); + } + } +} diff --git a/src/Service/ShortUrlService.php b/src/Service/ShortUrlService.php new file mode 100644 index 00000000..f6dc57fa --- /dev/null +++ b/src/Service/ShortUrlService.php @@ -0,0 +1,33 @@ +<?php +namespace Acelaya\UrlShortener\Service; + +use Acelaya\UrlShortener\Entity\ShortUrl; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Doctrine\ORM\EntityManagerInterface; + +class ShortUrlService implements ShortUrlServiceInterface +{ + /** + * @var EntityManagerInterface + */ + private $em; + + /** + * ShortUrlService constructor. + * @param EntityManagerInterface $em + * + * @Inject({"em"}) + */ + public function __construct(EntityManagerInterface $em) + { + $this->em = $em; + } + + /** + * @return ShortUrl[] + */ + public function listShortUrls() + { + return $this->em->getRepository(ShortUrl::class)->findAll(); + } +} diff --git a/src/Service/ShortUrlServiceInterface.php b/src/Service/ShortUrlServiceInterface.php new file mode 100644 index 00000000..5a943ba0 --- /dev/null +++ b/src/Service/ShortUrlServiceInterface.php @@ -0,0 +1,12 @@ +<?php +namespace Acelaya\UrlShortener\Service; + +use Acelaya\UrlShortener\Entity\ShortUrl; + +interface ShortUrlServiceInterface +{ + /** + * @return ShortUrl[] + */ + public function listShortUrls(); +} diff --git a/tests/Service/ShortUrlServiceTest.php b/tests/Service/ShortUrlServiceTest.php new file mode 100644 index 00000000..6d325d17 --- /dev/null +++ b/tests/Service/ShortUrlServiceTest.php @@ -0,0 +1,45 @@ +<?php +namespace AcelayaTest\UrlShortener\Service; + +use Acelaya\UrlShortener\Entity\ShortUrl; +use Acelaya\UrlShortener\Service\ShortUrlService; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityRepository; +use PHPUnit_Framework_TestCase as TestCase; +use Prophecy\Prophecy\ObjectProphecy; + +class ShortUrlServiceTest extends TestCase +{ + /** + * @var ShortUrlService + */ + protected $service; + /** + * @var ObjectProphecy|EntityManagerInterface + */ + protected $em; + + public function setUp() + { + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->service = new ShortUrlService($this->em->reveal()); + } + + /** + * @test + */ + public function listedUrlsAreReturnedFromEntityManager() + { + $repo = $this->prophesize(EntityRepository::class); + $repo->findAll()->willReturn([ + new ShortUrl(), + new ShortUrl(), + new ShortUrl(), + new ShortUrl(), + ])->shouldBeCalledTimes(1); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $list = $this->service->listShortUrls(); + $this->assertCount(4, $list); + } +} From 67ef171262197762cb6513c3ee4de60a2fa1f1e5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Sun, 3 Jul 2016 08:40:39 +0200 Subject: [PATCH 06/18] Improved middleware pipeline and added cross-domain headers for ajax requests --- .../autoload/middleware-pipeline.global.php | 15 ++++++ config/autoload/services.global.php | 1 + src/Middleware/CrossDomainMiddleware.php | 51 +++++++++++++++++++ src/Middleware/Rest/GetVisitsMiddleware.php | 2 +- 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/Middleware/CrossDomainMiddleware.php diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 8a393ce1..ab903ac9 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -1,4 +1,5 @@ <?php +use Acelaya\UrlShortener\Middleware; use Zend\Expressive\Container\ApplicationFactory; use Zend\Expressive\Helper; @@ -15,6 +16,20 @@ return [ 'routing' => [ 'middleware' => [ ApplicationFactory::ROUTING_MIDDLEWARE, + ], + 'priority' => 10, + ], + + 'rest' => [ + 'path' => '/rest', + 'middleware' => [ + Middleware\CrossDomainMiddleware::class, + ], + 'priority' => 5, + ], + + 'post-routing' => [ + 'middleware' => [ Helper\UrlHelperMiddleware::class, ApplicationFactory::DISPATCH_MIDDLEWARE, ], diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index 92a4adce..08fedf55 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -49,6 +49,7 @@ return [ Middleware\Rest\ResolveUrlMiddleware::class => AnnotatedFactory::class, Middleware\Rest\GetVisitsMiddleware::class => AnnotatedFactory::class, Middleware\Rest\ListShortcodesMiddleware::class => AnnotatedFactory::class, + Middleware\CrossDomainMiddleware::class => InvokableFactory::class, ], 'aliases' => [ 'em' => EntityManager::class, diff --git a/src/Middleware/CrossDomainMiddleware.php b/src/Middleware/CrossDomainMiddleware.php new file mode 100644 index 00000000..c762ed83 --- /dev/null +++ b/src/Middleware/CrossDomainMiddleware.php @@ -0,0 +1,51 @@ +<?php +namespace Acelaya\UrlShortener\Middleware; + +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Stratigility\MiddlewareInterface; + +class CrossDomainMiddleware implements MiddlewareInterface +{ + /** + * 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) + { + /** @var Response $response */ + $response = $out($request, $response); + + if ($request->hasHeader('X-Requested-With') + && strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest' + ) { + $response = $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + ->withHeader('Access-Control-Max-Age', '1000') + ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader('Access-Control-Allow-Headers', '*'); + } + + return $response; + } +} diff --git a/src/Middleware/Rest/GetVisitsMiddleware.php b/src/Middleware/Rest/GetVisitsMiddleware.php index b932622d..1a1b973b 100644 --- a/src/Middleware/Rest/GetVisitsMiddleware.php +++ b/src/Middleware/Rest/GetVisitsMiddleware.php @@ -57,7 +57,7 @@ class GetVisitsMiddleware implements MiddlewareInterface public function __invoke(Request $request, Response $response, callable $out = null) { $shortCode = $request->getAttribute('shortCode'); - + try { $visits = $this->visitsTracker->info($shortCode); From 35f1a4b6722f076f7d60d4a30caf6890a0230da4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 08:57:37 +0200 Subject: [PATCH 07/18] Created stuff to handle pagination on list results --- composer.json | 1 + src/Repository/PaginableRepository.php | 14 +++++++ src/Repository/ShortUrlRepository.php | 42 +++++++++++++++++++ .../ShortUrlRepositoryInterface.php | 8 ++++ src/Service/ShortUrlService.php | 3 +- src/Service/ShortUrlServiceInterface.php | 3 +- src/Service/VisitsTracker.php | 3 +- src/Service/VisitsTrackerInterface.php | 3 +- 8 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 src/Repository/PaginableRepository.php create mode 100644 src/Repository/ShortUrlRepository.php create mode 100644 src/Repository/ShortUrlRepositoryInterface.php diff --git a/composer.json b/composer.json index 15f92bee..374fed26 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "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", diff --git a/src/Repository/PaginableRepository.php b/src/Repository/PaginableRepository.php new file mode 100644 index 00000000..ad993961 --- /dev/null +++ b/src/Repository/PaginableRepository.php @@ -0,0 +1,14 @@ +<?php +namespace Acelaya\UrlShortener\Repository; + +interface PaginableRepository +{ + /** + * @param int|null $limit + * @param int|null $offset + * @param string|null $searchTerm + * @param string|array|null $orderBy + * @return array + */ + public function findList($limit = null, $offset = null, $searchTerm = null, $orderBy = null); +} diff --git a/src/Repository/ShortUrlRepository.php b/src/Repository/ShortUrlRepository.php new file mode 100644 index 00000000..cfc23d58 --- /dev/null +++ b/src/Repository/ShortUrlRepository.php @@ -0,0 +1,42 @@ +<?php +namespace Acelaya\UrlShortener\Repository; + +use Acelaya\UrlShortener\Entity\ShortUrl; +use Doctrine\ORM\EntityRepository; + +class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface +{ + /** + * @param int|null $limit + * @param int|null $offset + * @param string|null $searchTerm + * @param string|array|null $orderBy + * @return ShortUrl[] + */ + public function findList($limit = null, $offset = null, $searchTerm = null, $orderBy = null) + { + $qb = $this->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(); + } +} diff --git a/src/Repository/ShortUrlRepositoryInterface.php b/src/Repository/ShortUrlRepositoryInterface.php new file mode 100644 index 00000000..37b013d9 --- /dev/null +++ b/src/Repository/ShortUrlRepositoryInterface.php @@ -0,0 +1,8 @@ +<?php +namespace Acelaya\UrlShortener\Repository; + +use Doctrine\Common\Persistence\ObjectRepository; + +interface ShortUrlRepositoryInterface extends ObjectRepository, PaginableRepository +{ +} diff --git a/src/Service/ShortUrlService.php b/src/Service/ShortUrlService.php index f6dc57fa..2ffc640c 100644 --- a/src/Service/ShortUrlService.php +++ b/src/Service/ShortUrlService.php @@ -4,6 +4,7 @@ namespace Acelaya\UrlShortener\Service; use Acelaya\UrlShortener\Entity\ShortUrl; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Doctrine\ORM\EntityManagerInterface; +use Zend\Paginator\Paginator; class ShortUrlService implements ShortUrlServiceInterface { @@ -24,7 +25,7 @@ class ShortUrlService implements ShortUrlServiceInterface } /** - * @return ShortUrl[] + * @return Paginator|ShortUrl[] */ public function listShortUrls() { diff --git a/src/Service/ShortUrlServiceInterface.php b/src/Service/ShortUrlServiceInterface.php index 5a943ba0..9f0a219c 100644 --- a/src/Service/ShortUrlServiceInterface.php +++ b/src/Service/ShortUrlServiceInterface.php @@ -2,11 +2,12 @@ namespace Acelaya\UrlShortener\Service; use Acelaya\UrlShortener\Entity\ShortUrl; +use Zend\Paginator\Paginator; interface ShortUrlServiceInterface { /** - * @return ShortUrl[] + * @return Paginator|ShortUrl[] */ public function listShortUrls(); } diff --git a/src/Service/VisitsTracker.php b/src/Service/VisitsTracker.php index 4d091288..7fcdf5e6 100644 --- a/src/Service/VisitsTracker.php +++ b/src/Service/VisitsTracker.php @@ -7,6 +7,7 @@ use Acelaya\UrlShortener\Exception\InvalidArgumentException; use Acelaya\UrlShortener\Exception\InvalidShortCodeException; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Doctrine\ORM\EntityManagerInterface; +use Zend\Paginator\Paginator; class VisitsTracker implements VisitsTrackerInterface { @@ -65,7 +66,7 @@ class VisitsTracker implements VisitsTrackerInterface * Returns the visits on certain shortcode * * @param $shortCode - * @return Visit[] + * @return Paginator|Visit[] */ public function info($shortCode) { diff --git a/src/Service/VisitsTrackerInterface.php b/src/Service/VisitsTrackerInterface.php index 0d524223..ce0a61cf 100644 --- a/src/Service/VisitsTrackerInterface.php +++ b/src/Service/VisitsTrackerInterface.php @@ -2,6 +2,7 @@ namespace Acelaya\UrlShortener\Service; use Acelaya\UrlShortener\Entity\Visit; +use Zend\Paginator\Paginator; interface VisitsTrackerInterface { @@ -17,7 +18,7 @@ interface VisitsTrackerInterface * Returns the visits on certain shortcode * * @param $shortCode - * @return Visit[] + * @return Paginator|Visit[] */ public function info($shortCode); } From cc1829f9ed45d38c563f965d05c4ce95239c01ac Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 09:15:50 +0200 Subject: [PATCH 08/18] Added pagination to ShortUrls list --- src/Entity/ShortUrl.php | 2 +- .../Rest/ListShortcodesMiddleware.php | 8 ++- .../Adapter/PaginableRepositoryAdapter.php | 56 +++++++++++++++++++ ...y.php => PaginableRepositoryInterface.php} | 12 +++- src/Repository/ShortUrlRepository.php | 20 +++++++ .../ShortUrlRepositoryInterface.php | 2 +- src/Service/ShortUrlService.php | 13 ++++- src/Service/ShortUrlServiceInterface.php | 5 +- 8 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 src/Paginator/Adapter/PaginableRepositoryAdapter.php rename src/Repository/{PaginableRepository.php => PaginableRepositoryInterface.php} (51%) diff --git a/src/Entity/ShortUrl.php b/src/Entity/ShortUrl.php index fe96d651..9f63af68 100644 --- a/src/Entity/ShortUrl.php +++ b/src/Entity/ShortUrl.php @@ -10,7 +10,7 @@ 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 implements \JsonSerializable diff --git a/src/Middleware/Rest/ListShortcodesMiddleware.php b/src/Middleware/Rest/ListShortcodesMiddleware.php index d437b742..5f3b92a0 100644 --- a/src/Middleware/Rest/ListShortcodesMiddleware.php +++ b/src/Middleware/Rest/ListShortcodesMiddleware.php @@ -8,6 +8,7 @@ use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\JsonResponse; +use Zend\Stdlib\ArrayUtils; use Zend\Stratigility\MiddlewareInterface; class ListShortcodesMiddleware implements MiddlewareInterface @@ -60,8 +61,11 @@ class ListShortcodesMiddleware implements MiddlewareInterface return new JsonResponse([ 'shortUrls' => [ - 'data' => $shortUrls, -// 'pagination' => [], + 'data' => ArrayUtils::iteratorToArray($shortUrls->getCurrentItems()), + 'pagination' => [ + 'currentPage' => $shortUrls->getCurrentPageNumber(), + 'pagesCount' => $shortUrls->count(), + ], ] ]); } catch (\Exception $e) { 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 @@ +<?php +namespace Acelaya\UrlShortener\Paginator\Adapter; + +use Acelaya\UrlShortener\Repository\PaginableRepositoryInterface; +use Zend\Paginator\Adapter\AdapterInterface; + +class PaginableRepositoryAdapter implements AdapterInterface +{ + const ITEMS_PER_PAGE = 10; + + /** + * @var PaginableRepositoryInterface + */ + private $paginableRepository; + /** + * @var null + */ + private $searchTerm; + /** + * @var null + */ + private $orderBy; + + public function __construct(PaginableRepositoryInterface $paginableRepository, $searchTerm = null, $orderBy = null) + { + $this->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. + * </p> + * <p> + * 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/Repository/PaginableRepository.php b/src/Repository/PaginableRepositoryInterface.php similarity index 51% rename from src/Repository/PaginableRepository.php rename to src/Repository/PaginableRepositoryInterface.php index ad993961..99c8696e 100644 --- a/src/Repository/PaginableRepository.php +++ b/src/Repository/PaginableRepositoryInterface.php @@ -1,9 +1,11 @@ <?php namespace Acelaya\UrlShortener\Repository; -interface PaginableRepository +interface PaginableRepositoryInterface { /** + * Gets a list of elements using provided filtering data + * * @param int|null $limit * @param int|null $offset * @param string|null $searchTerm @@ -11,4 +13,12 @@ interface PaginableRepository * @return array */ public function findList($limit = null, $offset = null, $searchTerm = null, $orderBy = null); + + /** + * Counts the number of elements in a list using provided filtering data + * + * @param null $searchTerm + * @return int + */ + public function countList($searchTerm = null); } diff --git a/src/Repository/ShortUrlRepository.php b/src/Repository/ShortUrlRepository.php index cfc23d58..7ba68d82 100644 --- a/src/Repository/ShortUrlRepository.php +++ b/src/Repository/ShortUrlRepository.php @@ -3,6 +3,7 @@ namespace Acelaya\UrlShortener\Repository; use Acelaya\UrlShortener\Entity\ShortUrl; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\QueryBuilder; class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface { @@ -39,4 +40,23 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI 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 index 37b013d9..be7f3fff 100644 --- a/src/Repository/ShortUrlRepositoryInterface.php +++ b/src/Repository/ShortUrlRepositoryInterface.php @@ -3,6 +3,6 @@ namespace Acelaya\UrlShortener\Repository; use Doctrine\Common\Persistence\ObjectRepository; -interface ShortUrlRepositoryInterface extends ObjectRepository, PaginableRepository +interface ShortUrlRepositoryInterface extends ObjectRepository, PaginableRepositoryInterface { } diff --git a/src/Service/ShortUrlService.php b/src/Service/ShortUrlService.php index 2ffc640c..9dbc176b 100644 --- a/src/Service/ShortUrlService.php +++ b/src/Service/ShortUrlService.php @@ -2,6 +2,8 @@ namespace Acelaya\UrlShortener\Service; use Acelaya\UrlShortener\Entity\ShortUrl; +use Acelaya\UrlShortener\Paginator\Adapter\PaginableRepositoryAdapter; +use Acelaya\UrlShortener\Repository\ShortUrlRepository; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Doctrine\ORM\EntityManagerInterface; use Zend\Paginator\Paginator; @@ -25,10 +27,17 @@ class ShortUrlService implements ShortUrlServiceInterface } /** + * @param int $page * @return Paginator|ShortUrl[] */ - public function listShortUrls() + public function listShortUrls($page = 1) { - return $this->em->getRepository(ShortUrl::class)->findAll(); + /** @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 index 9f0a219c..a9d182d2 100644 --- a/src/Service/ShortUrlServiceInterface.php +++ b/src/Service/ShortUrlServiceInterface.php @@ -7,7 +7,8 @@ use Zend\Paginator\Paginator; interface ShortUrlServiceInterface { /** - * @return Paginator|ShortUrl[] + * @param int $page + * @return ShortUrl[]|Paginator */ - public function listShortUrls(); + public function listShortUrls($page = 1); } From 30773c66d05716a9c64c0a895158d9efd6f9f7ad Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 09:18:10 +0200 Subject: [PATCH 09/18] Fixed ShortUrlServiceTest --- tests/Service/ShortUrlServiceTest.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/Service/ShortUrlServiceTest.php b/tests/Service/ShortUrlServiceTest.php index 6d325d17..3b77ee6a 100644 --- a/tests/Service/ShortUrlServiceTest.php +++ b/tests/Service/ShortUrlServiceTest.php @@ -2,10 +2,11 @@ namespace AcelayaTest\UrlShortener\Service; use Acelaya\UrlShortener\Entity\ShortUrl; +use Acelaya\UrlShortener\Repository\ShortUrlRepository; use Acelaya\UrlShortener\Service\ShortUrlService; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; use PHPUnit_Framework_TestCase as TestCase; +use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; class ShortUrlServiceTest extends TestCase @@ -30,16 +31,19 @@ class ShortUrlServiceTest extends TestCase */ public function listedUrlsAreReturnedFromEntityManager() { - $repo = $this->prophesize(EntityRepository::class); - $repo->findAll()->willReturn([ + $list = [ new ShortUrl(), new ShortUrl(), new ShortUrl(), new ShortUrl(), - ])->shouldBeCalledTimes(1); + ]; + + $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->assertCount(4, $list); + $this->assertEquals(4, $list->getCurrentItemCount()); } } From b4e6fe7135b488a3e530e707855eb51bcdee5cce Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 12:50:06 +0200 Subject: [PATCH 10/18] Created trait to serialize paginators --- .../Rest/ListShortcodesMiddleware.php | 17 ++++++----------- .../Util/PaginatorSerializerTrait.php | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 src/Paginator/Util/PaginatorSerializerTrait.php diff --git a/src/Middleware/Rest/ListShortcodesMiddleware.php b/src/Middleware/Rest/ListShortcodesMiddleware.php index 5f3b92a0..6a4a627f 100644 --- a/src/Middleware/Rest/ListShortcodesMiddleware.php +++ b/src/Middleware/Rest/ListShortcodesMiddleware.php @@ -1,6 +1,7 @@ <?php namespace Acelaya\UrlShortener\Middleware\Rest; +use Acelaya\UrlShortener\Paginator\Util\PaginatorSerializerTrait; use Acelaya\UrlShortener\Service\ShortUrlService; use Acelaya\UrlShortener\Service\ShortUrlServiceInterface; use Acelaya\UrlShortener\Util\RestUtils; @@ -13,6 +14,8 @@ use Zend\Stratigility\MiddlewareInterface; class ListShortcodesMiddleware implements MiddlewareInterface { + use PaginatorSerializerTrait; + /** * @var ShortUrlServiceInterface */ @@ -57,17 +60,9 @@ class ListShortcodesMiddleware implements MiddlewareInterface public function __invoke(Request $request, Response $response, callable $out = null) { try { - $shortUrls = $this->shortUrlService->listShortUrls(); - - return new JsonResponse([ - 'shortUrls' => [ - 'data' => ArrayUtils::iteratorToArray($shortUrls->getCurrentItems()), - 'pagination' => [ - 'currentPage' => $shortUrls->getCurrentPageNumber(), - 'pagesCount' => $shortUrls->count(), - ], - ] - ]); + $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, diff --git a/src/Paginator/Util/PaginatorSerializerTrait.php b/src/Paginator/Util/PaginatorSerializerTrait.php new file mode 100644 index 00000000..573832ca --- /dev/null +++ b/src/Paginator/Util/PaginatorSerializerTrait.php @@ -0,0 +1,19 @@ +<?php +namespace Acelaya\UrlShortener\Paginator\Util; + +use Zend\Paginator\Paginator; +use Zend\Stdlib\ArrayUtils; + +trait PaginatorSerializerTrait +{ + protected function serializePaginator(Paginator $paginator) + { + return [ + 'data' => ArrayUtils::iteratorToArray($paginator->getCurrentItems()), + 'pagination' => [ + 'currentPage' => $paginator->getCurrentPageNumber(), + 'pagesCount' => $paginator->count(), + ], + ]; + } +} From bbef3444c214eeb29964d97a7dd9ebef6f8d31bd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 13:14:01 +0200 Subject: [PATCH 11/18] Added errorhanler local config distributable file --- config/autoload/errorhandler.local.php.dist | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 config/autoload/errorhandler.local.php.dist 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 @@ +<?php + +return [ + 'services' => [ + '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, + ], + ], +]; From 56b2bd3d56073ec3ba4f61d14e824cb21c9cc496 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 14:04:10 +0200 Subject: [PATCH 12/18] Created entity to persist rest tokens --- .env.dist | 4 ++ config/autoload/rest.global.php | 9 ++++ src/Entity/RestToken.php | 89 +++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 config/autoload/rest.global.php create mode 100644 src/Entity/RestToken.php 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/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 @@ +<?php +return [ + + 'rest' => [ + 'username' => getenv('REST_USER'), + 'password' => getenv('REST_PASSWORD'), + ], + +]; diff --git a/src/Entity/RestToken.php b/src/Entity/RestToken.php new file mode 100644 index 00000000..d23dc10f --- /dev/null +++ b/src/Entity/RestToken.php @@ -0,0 +1,89 @@ +<?php +namespace Acelaya\UrlShortener\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * Class RestToken + * @author + * @link + * + * @ORM\Entity() + * @ORM\Table(name="rest_tokens") + */ +class RestToken extends AbstractEntity +{ + /** + * The default interval is 20 minutes + */ + const DEFAULT_INTERVAL = 'PT20M'; + + /** + * @var \DateTime + * @ORM\Column(type="datetime", name="expiration_date", nullable=false) + */ + protected $expirationDate; + /** + * @var string + * @ORM\Column(nullable=false) + */ + protected $token; + + public function __construct() + { + $this->updateExpiration(); + } + + /** + * @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))); + } +} From dfc5bfd0f2b9576aa88aefdedb943d2296ad96de Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 14:45:18 +0200 Subject: [PATCH 13/18] Created rest route to perform authentication --- config/autoload/routes.global.php | 6 ++ config/autoload/services.global.php | 2 + src/Entity/RestToken.php | 13 +++ src/Exception/AuthenticationException.php | 10 +++ .../Rest/AuthenticateMiddleware.php | 77 ++++++++++++++++ src/Service/RestTokenService.php | 87 +++++++++++++++++++ src/Service/RestTokenServiceInterface.php | 25 ++++++ src/Util/RestUtils.php | 3 + src/Util/StringUtilsTrait.php | 40 +++++++++ 9 files changed, 263 insertions(+) create mode 100644 src/Exception/AuthenticationException.php create mode 100644 src/Middleware/Rest/AuthenticateMiddleware.php create mode 100644 src/Service/RestTokenService.php create mode 100644 src/Service/RestTokenServiceInterface.php create mode 100644 src/Util/StringUtilsTrait.php diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index b2976df5..7ffdbc74 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -13,6 +13,12 @@ return [ ], // Rest + [ + 'name' => 'rest-authenticate', + 'path' => '/rest/authenticate', + 'middleware' => Rest\AuthenticateMiddleware::class, + 'allowed_methods' => ['POST'], + ], [ 'name' => 'rest-create-shortcode', 'path' => '/rest/short-codes', diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index 08fedf55..d5de3d4a 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -38,6 +38,7 @@ return [ 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 @@ -45,6 +46,7 @@ return [ // Middleware Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::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, diff --git a/src/Entity/RestToken.php b/src/Entity/RestToken.php index d23dc10f..90a70f0e 100644 --- a/src/Entity/RestToken.php +++ b/src/Entity/RestToken.php @@ -1,6 +1,7 @@ <?php namespace Acelaya\UrlShortener\Entity; +use Acelaya\UrlShortener\Util\StringUtilsTrait; use Doctrine\ORM\Mapping as ORM; /** @@ -13,6 +14,8 @@ use Doctrine\ORM\Mapping as ORM; */ class RestToken extends AbstractEntity { + use StringUtilsTrait; + /** * The default interval is 20 minutes */ @@ -32,6 +35,7 @@ class RestToken extends AbstractEntity public function __construct() { $this->updateExpiration(); + $this->setRandomTokenKey(); } /** @@ -86,4 +90,13 @@ class RestToken extends AbstractEntity { 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/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 @@ +<?php +namespace Acelaya\UrlShortener\Exception; + +class AuthenticationException extends \RuntimeException implements ExceptionInterface +{ + public static function fromCredentials($username, $password) + { + return new self(sprintf('Invalid credentials. Username -> "%s". Password -> "%s"', $username, $password)); + } +} diff --git a/src/Middleware/Rest/AuthenticateMiddleware.php b/src/Middleware/Rest/AuthenticateMiddleware.php new file mode 100644 index 00000000..0189b249 --- /dev/null +++ b/src/Middleware/Rest/AuthenticateMiddleware.php @@ -0,0 +1,77 @@ +<?php +namespace Acelaya\UrlShortener\Middleware\Rest; + +use Acelaya\UrlShortener\Exception\AuthenticationException; +use Acelaya\UrlShortener\Service\RestTokenService; +use Acelaya\UrlShortener\Service\RestTokenServiceInterface; +use Acelaya\UrlShortener\Util\RestUtils; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Diactoros\Response\JsonResponse; +use Zend\Stratigility\MiddlewareInterface; + +class AuthenticateMiddleware implements MiddlewareInterface +{ + /** + * @var RestTokenServiceInterface + */ + private $restTokenService; + + /** + * AuthenticateMiddleware constructor. + * @param RestTokenServiceInterface|RestTokenService $restTokenService + * + * @Inject({RestTokenService::class}) + */ + public function __construct(RestTokenServiceInterface $restTokenService) + { + $this->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) + { + $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/Service/RestTokenService.php b/src/Service/RestTokenService.php new file mode 100644 index 00000000..aa9ea0b8 --- /dev/null +++ b/src/Service/RestTokenService.php @@ -0,0 +1,87 @@ +<?php +namespace Acelaya\UrlShortener\Service; + +use Acelaya\UrlShortener\Entity\RestToken; +use Acelaya\UrlShortener\Exception\AuthenticationException; +use Acelaya\UrlShortener\Exception\InvalidArgumentException; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Doctrine\ORM\EntityManagerInterface; + +class RestTokenService implements RestTokenServiceInterface +{ + /** + * @var EntityManagerInterface + */ + private $em; + /** + * @var array + */ + private $restConfig; + + /** + * ShortUrlService constructor. + * @param EntityManagerInterface $em + * + * @param array $restConfig + * @Inject({"em", "config.rest"}) + */ + public function __construct(EntityManagerInterface $em, array $restConfig) + { + $this->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); + } +} diff --git a/src/Service/RestTokenServiceInterface.php b/src/Service/RestTokenServiceInterface.php new file mode 100644 index 00000000..fb45483d --- /dev/null +++ b/src/Service/RestTokenServiceInterface.php @@ -0,0 +1,25 @@ +<?php +namespace Acelaya\UrlShortener\Service; + +use Acelaya\UrlShortener\Entity\RestToken; +use Acelaya\UrlShortener\Exception\AuthenticationException; +use Acelaya\UrlShortener\Exception\InvalidArgumentException; + +interface RestTokenServiceInterface +{ + /** + * @param string $token + * @return RestToken + * @throws InvalidArgumentException + */ + public function getByToken($token); + + /** + * 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); +} diff --git a/src/Util/RestUtils.php b/src/Util/RestUtils.php index 301c2e57..94ab47ec 100644 --- a/src/Util/RestUtils.php +++ b/src/Util/RestUtils.php @@ -8,6 +8,7 @@ class RestUtils const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE'; const INVALID_URL_ERROR = 'INVALID_URL'; const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT'; + const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'; const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; public static function getRestErrorCodeFromException(Exception\ExceptionInterface $e) @@ -19,6 +20,8 @@ class RestUtils return self::INVALID_URL_ERROR; case $e instanceof Exception\InvalidArgumentException: return self::INVALID_ARGUMENT_ERROR; + case $e instanceof Exception\AuthenticationException: + return self::INVALID_CREDENTIALS; default: return self::UNKNOWN_ERROR; } diff --git a/src/Util/StringUtilsTrait.php b/src/Util/StringUtilsTrait.php new file mode 100644 index 00000000..2b4bf625 --- /dev/null +++ b/src/Util/StringUtilsTrait.php @@ -0,0 +1,40 @@ +<?php +namespace Acelaya\UrlShortener\Util; + +trait StringUtilsTrait +{ + protected function generateRandomString($length = 10) + { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $randomString = ''; + for ($i = 0; $i < $length; $i++) { + $randomString .= $characters[rand(0, $charactersLength - 1)]; + } + + return $randomString; + } + + protected function generateV4Uuid() + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + // 32 bits for "time_low" + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + // 16 bits for "time_mid" + mt_rand(0, 0xffff), + // 16 bits for "time_hi_and_version", + // four most significant bits holds version number 4 + mt_rand(0, 0x0fff) | 0x4000, + // 16 bits, 8 bits for "clk_seq_hi_res", + // 8 bits for "clk_seq_low", + // two most significant bits holds zero and one for variant DCE1.1 + mt_rand(0, 0x3fff) | 0x8000, + // 48 bits for "node" + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff) + ); + } +} From 431169eb8c0788b929cfc28e32da32267bcbbd26 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 17:54:24 +0200 Subject: [PATCH 14/18] Created middleware that checks authentication --- .../autoload/middleware-pipeline.global.php | 1 + config/autoload/services.global.php | 1 + .../CheckAuthenticationMiddleware.php | 100 ++++++++++++++++++ src/Service/RestTokenService.php | 11 ++ src/Service/RestTokenServiceInterface.php | 7 ++ src/Util/RestUtils.php | 5 +- 6 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/Middleware/CheckAuthenticationMiddleware.php diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index ab903ac9..fc6f85f0 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -23,6 +23,7 @@ return [ 'rest' => [ 'path' => '/rest', 'middleware' => [ + Middleware\CheckAuthenticationMiddleware::class, Middleware\CrossDomainMiddleware::class, ], 'priority' => 5, diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index d5de3d4a..b08229e7 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -52,6 +52,7 @@ return [ 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, diff --git a/src/Middleware/CheckAuthenticationMiddleware.php b/src/Middleware/CheckAuthenticationMiddleware.php new file mode 100644 index 00000000..92081ad8 --- /dev/null +++ b/src/Middleware/CheckAuthenticationMiddleware.php @@ -0,0 +1,100 @@ +<?php +namespace Acelaya\UrlShortener\Middleware; + +use Acelaya\UrlShortener\Exception\InvalidArgumentException; +use Acelaya\UrlShortener\Service\RestTokenService; +use Acelaya\UrlShortener\Service\RestTokenServiceInterface; +use Acelaya\UrlShortener\Util\RestUtils; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Diactoros\Response\JsonResponse; +use Zend\Expressive\Router\RouteResult; +use Zend\Stratigility\MiddlewareInterface; + +class CheckAuthenticationMiddleware implements MiddlewareInterface +{ + const AUTH_TOKEN_HEADER = 'X-Auth-Token'; + + /** + * @var RestTokenServiceInterface + */ + private $restTokenService; + + /** + * CheckAuthenticationMiddleware constructor. + * @param RestTokenServiceInterface|RestTokenService $restTokenService + * + * @Inject({RestTokenService::class}) + */ + public function __construct(RestTokenServiceInterface $restTokenService) + { + $this->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, continue to the next middleware + /** @var RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class); + if (isset($routeResult) && $routeResult->getMatchedRouteName() === 'rest-authenticate') { + 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/Service/RestTokenService.php b/src/Service/RestTokenService.php index aa9ea0b8..26d7f34c 100644 --- a/src/Service/RestTokenService.php +++ b/src/Service/RestTokenService.php @@ -84,4 +84,15 @@ class RestTokenService implements RestTokenServiceInterface // 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 index fb45483d..0cdec822 100644 --- a/src/Service/RestTokenServiceInterface.php +++ b/src/Service/RestTokenServiceInterface.php @@ -22,4 +22,11 @@ interface RestTokenServiceInterface * @throws AuthenticationException */ public function createToken($username, $password); + + /** + * Updates the expiration of provided token, extending its life + * + * @param RestToken $token + */ + public function updateExpiration(RestToken $token); } diff --git a/src/Util/RestUtils.php b/src/Util/RestUtils.php index 94ab47ec..f0c37a00 100644 --- a/src/Util/RestUtils.php +++ b/src/Util/RestUtils.php @@ -8,7 +8,8 @@ class RestUtils const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE'; const INVALID_URL_ERROR = 'INVALID_URL'; const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT'; - const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'; + const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; + const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN_ERROR'; const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; public static function getRestErrorCodeFromException(Exception\ExceptionInterface $e) @@ -21,7 +22,7 @@ class RestUtils case $e instanceof Exception\InvalidArgumentException: return self::INVALID_ARGUMENT_ERROR; case $e instanceof Exception\AuthenticationException: - return self::INVALID_CREDENTIALS; + return self::INVALID_CREDENTIALS_ERROR; default: return self::UNKNOWN_ERROR; } From bd36c65a7347fbea387f2a6c65c4dd59dc76c34b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Tue, 5 Jul 2016 19:08:34 +0200 Subject: [PATCH 15/18] Fixed some cross-origin issues --- config/autoload/routes.global.php | 2 +- src/Middleware/CrossDomainMiddleware.php | 13 +++++++------ src/Middleware/Rest/AuthenticateMiddleware.php | 4 ++++ src/Middleware/Rest/CreateShortcodeMiddleware.php | 5 +++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index 7ffdbc74..06e5f733 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -17,7 +17,7 @@ return [ 'name' => 'rest-authenticate', 'path' => '/rest/authenticate', 'middleware' => Rest\AuthenticateMiddleware::class, - 'allowed_methods' => ['POST'], + 'allowed_methods' => ['POST', 'OPTIONS'], ], [ 'name' => 'rest-create-shortcode', diff --git a/src/Middleware/CrossDomainMiddleware.php b/src/Middleware/CrossDomainMiddleware.php index c762ed83..c76d4d73 100644 --- a/src/Middleware/CrossDomainMiddleware.php +++ b/src/Middleware/CrossDomainMiddleware.php @@ -37,15 +37,16 @@ class CrossDomainMiddleware implements MiddlewareInterface /** @var Response $response */ $response = $out($request, $response); - if ($request->hasHeader('X-Requested-With') - && strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest' - ) { + if (strtolower($request->getMethod()) === 'options') { $response = $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') ->withHeader('Access-Control-Max-Age', '1000') - ->withHeader('Access-Control-Allow-Origin', '*') - ->withHeader('Access-Control-Allow-Headers', '*'); + ->withHeader( + // Allow all requested headers + 'Access-Control-Allow-Headers', + $request->getHeaderLine('Access-Control-Request-Headers') + ); } - return $response; + return $response->withHeader('Access-Control-Allow-Origin', '*'); } } diff --git a/src/Middleware/Rest/AuthenticateMiddleware.php b/src/Middleware/Rest/AuthenticateMiddleware.php index 0189b249..85d12330 100644 --- a/src/Middleware/Rest/AuthenticateMiddleware.php +++ b/src/Middleware/Rest/AuthenticateMiddleware.php @@ -56,6 +56,10 @@ class AuthenticateMiddleware implements MiddlewareInterface */ public function __invoke(Request $request, Response $response, callable $out = null) { + if (strtolower($request->getMethod()) === 'options') { + return $response; + } + $authData = $request->getParsedBody(); if (! isset($authData['username'], $authData['password'])) { return new JsonResponse([ diff --git a/src/Middleware/Rest/CreateShortcodeMiddleware.php b/src/Middleware/Rest/CreateShortcodeMiddleware.php index 1e723d48..b68c551c 100644 --- a/src/Middleware/Rest/CreateShortcodeMiddleware.php +++ b/src/Middleware/Rest/CreateShortcodeMiddleware.php @@ -74,14 +74,15 @@ class CreateShortcodeMiddleware implements MiddlewareInterface $longUrl = $postData['longUrl']; try { - $shortcode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); - $shortUrl = (new Uri())->withPath($shortcode) + $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([ From baf5936cf18d778e2e5a9d7b64c52836d5f700bd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Tue, 5 Jul 2016 19:19:23 +0200 Subject: [PATCH 16/18] More cross-domain improvements --- config/autoload/routes.global.php | 6 +-- .../CheckAuthenticationMiddleware.php | 6 ++- .../Rest/AbstractRestMiddleware.php | 51 +++++++++++++++++++ .../Rest/AuthenticateMiddleware.php | 30 ++--------- .../Rest/CreateShortcodeMiddleware.php | 26 ++-------- src/Middleware/Rest/GetVisitsMiddleware.php | 26 ++-------- .../Rest/ListShortcodesMiddleware.php | 26 ++-------- src/Middleware/Rest/ResolveUrlMiddleware.php | 26 ++-------- 8 files changed, 73 insertions(+), 124 deletions(-) create mode 100644 src/Middleware/Rest/AbstractRestMiddleware.php diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index 06e5f733..87133f09 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -23,13 +23,13 @@ return [ 'name' => 'rest-create-shortcode', 'path' => '/rest/short-codes', 'middleware' => Rest\CreateShortcodeMiddleware::class, - 'allowed_methods' => ['POST'], + 'allowed_methods' => ['POST', 'OPTIONS'], ], [ 'name' => 'rest-resolve-url', 'path' => '/rest/short-codes/{shortCode}', 'middleware' => Rest\ResolveUrlMiddleware::class, - 'allowed_methods' => ['GET'], + 'allowed_methods' => ['GET', 'OPTIONS'], ], [ 'name' => 'rest-list-shortened-url', @@ -41,7 +41,7 @@ return [ 'name' => 'rest-get-visits', 'path' => '/rest/visits/{shortCode}', 'middleware' => Rest\GetVisitsMiddleware::class, - 'allowed_methods' => ['GET'], + 'allowed_methods' => ['GET', 'OPTIONS'], ], ], diff --git a/src/Middleware/CheckAuthenticationMiddleware.php b/src/Middleware/CheckAuthenticationMiddleware.php index 92081ad8..8f7327e7 100644 --- a/src/Middleware/CheckAuthenticationMiddleware.php +++ b/src/Middleware/CheckAuthenticationMiddleware.php @@ -59,10 +59,12 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface */ public function __invoke(Request $request, Response $response, callable $out = null) { - // If current route is the authenticate route, continue to the next middleware + // 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') { + if ((isset($routeResult) && $routeResult->getMatchedRouteName() === 'rest-authenticate') + || strtolower($request->getMethod()) === 'options' + ) { return $out($request, $response); } 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 @@ +<?php +namespace Acelaya\UrlShortener\Middleware\Rest; + +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Stratigility\MiddlewareInterface; + +abstract class AbstractRestMiddleware implements MiddlewareInterface +{ + /** + * 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 (strtolower($request->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 index 85d12330..88c9df60 100644 --- a/src/Middleware/Rest/AuthenticateMiddleware.php +++ b/src/Middleware/Rest/AuthenticateMiddleware.php @@ -9,9 +9,8 @@ use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\JsonResponse; -use Zend\Stratigility\MiddlewareInterface; -class AuthenticateMiddleware implements MiddlewareInterface +class AuthenticateMiddleware extends AbstractRestMiddleware { /** * @var RestTokenServiceInterface @@ -30,36 +29,13 @@ class AuthenticateMiddleware implements MiddlewareInterface } /** - * 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 + * @param callable|null $out * @return null|Response */ - public function __invoke(Request $request, Response $response, callable $out = null) + public function dispatch(Request $request, Response $response, callable $out = null) { - if (strtolower($request->getMethod()) === 'options') { - return $response; - } - $authData = $request->getParsedBody(); if (! isset($authData['username'], $authData['password'])) { return new JsonResponse([ diff --git a/src/Middleware/Rest/CreateShortcodeMiddleware.php b/src/Middleware/Rest/CreateShortcodeMiddleware.php index b68c551c..f5ee3228 100644 --- a/src/Middleware/Rest/CreateShortcodeMiddleware.php +++ b/src/Middleware/Rest/CreateShortcodeMiddleware.php @@ -10,9 +10,8 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Uri; -use Zend\Stratigility\MiddlewareInterface; -class CreateShortcodeMiddleware implements MiddlewareInterface +class CreateShortcodeMiddleware extends AbstractRestMiddleware { /** * @var UrlShortener|UrlShortenerInterface @@ -38,31 +37,12 @@ class CreateShortcodeMiddleware implements MiddlewareInterface } /** - * 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 + * @param callable|null $out * @return null|Response */ - public function __invoke(Request $request, Response $response, callable $out = null) + public function dispatch(Request $request, Response $response, callable $out = null) { $postData = $request->getParsedBody(); if (! isset($postData['longUrl'])) { diff --git a/src/Middleware/Rest/GetVisitsMiddleware.php b/src/Middleware/Rest/GetVisitsMiddleware.php index 1a1b973b..a6ca954e 100644 --- a/src/Middleware/Rest/GetVisitsMiddleware.php +++ b/src/Middleware/Rest/GetVisitsMiddleware.php @@ -9,9 +9,8 @@ use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\JsonResponse; -use Zend\Stratigility\MiddlewareInterface; -class GetVisitsMiddleware implements MiddlewareInterface +class GetVisitsMiddleware extends AbstractRestMiddleware { /** * @var VisitsTrackerInterface @@ -30,31 +29,12 @@ class GetVisitsMiddleware implements MiddlewareInterface } /** - * 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 + * @param callable|null $out * @return null|Response */ - public function __invoke(Request $request, Response $response, callable $out = null) + public function dispatch(Request $request, Response $response, callable $out = null) { $shortCode = $request->getAttribute('shortCode'); diff --git a/src/Middleware/Rest/ListShortcodesMiddleware.php b/src/Middleware/Rest/ListShortcodesMiddleware.php index 6a4a627f..6b74241c 100644 --- a/src/Middleware/Rest/ListShortcodesMiddleware.php +++ b/src/Middleware/Rest/ListShortcodesMiddleware.php @@ -10,9 +10,8 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\JsonResponse; use Zend\Stdlib\ArrayUtils; -use Zend\Stratigility\MiddlewareInterface; -class ListShortcodesMiddleware implements MiddlewareInterface +class ListShortcodesMiddleware extends AbstractRestMiddleware { use PaginatorSerializerTrait; @@ -33,31 +32,12 @@ class ListShortcodesMiddleware implements MiddlewareInterface } /** - * 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 + * @param callable|null $out * @return null|Response */ - public function __invoke(Request $request, Response $response, callable $out = null) + public function dispatch(Request $request, Response $response, callable $out = null) { try { $query = $request->getQueryParams(); diff --git a/src/Middleware/Rest/ResolveUrlMiddleware.php b/src/Middleware/Rest/ResolveUrlMiddleware.php index 1beee164..4529e973 100644 --- a/src/Middleware/Rest/ResolveUrlMiddleware.php +++ b/src/Middleware/Rest/ResolveUrlMiddleware.php @@ -9,9 +9,8 @@ use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\JsonResponse; -use Zend\Stratigility\MiddlewareInterface; -class ResolveUrlMiddleware implements MiddlewareInterface +class ResolveUrlMiddleware extends AbstractRestMiddleware { /** * @var UrlShortenerInterface @@ -30,31 +29,12 @@ class ResolveUrlMiddleware implements MiddlewareInterface } /** - * 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 + * @param callable|null $out * @return null|Response */ - public function __invoke(Request $request, Response $response, callable $out = null) + public function dispatch(Request $request, Response $response, callable $out = null) { $shortCode = $request->getAttribute('shortCode'); From f691bb00d13d965046dbecc323a1f68f3a592ae0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Tue, 5 Jul 2016 19:28:47 +0200 Subject: [PATCH 17/18] Created rest documentation --- data/docs/rest.md | 278 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 data/docs/rest.md 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. From 371e264ebeac10ab9c817d857362697cc72b9bde Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Tue, 5 Jul 2016 19:54:16 +0200 Subject: [PATCH 18/18] Removed PHP5.5 from travis environments --- .travis.yml | 1 - 1 file changed, 1 deletion(-) 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