Merge pull request #16 from acelaya/feature/8

Feature/8
This commit is contained in:
Alejandro Celaya 2016-07-05 20:05:02 +02:00 committed by GitHub
commit 86d877b565
36 changed files with 1523 additions and 38 deletions

View file

@ -8,3 +8,7 @@ SHORTCODE_CHARS=
DB_USER=
DB_PASSWORD=
DB_NAME=
# Rest authentication
REST_USER=
REST_PASSWORD=

View file

@ -6,7 +6,6 @@ branches:
- develop
php:
- 5.5
- 5.6
- 7
- hhvm

View file

@ -11,20 +11,21 @@
}
],
"require": {
"php": "^5.5 || ^7.0",
"php": "^5.6 || ^7.0",
"zendframework/zend-expressive": "^1.0",
"zendframework/zend-expressive-helpers": "^2.0",
"zendframework/zend-expressive-fastroute": "^1.1",
"zendframework/zend-expressive-twigrenderer": "^1.0",
"zendframework/zend-stdlib": "^2.7",
"zendframework/zend-servicemanager": "^3.0",
"zendframework/zend-paginator": "^2.6",
"doctrine/orm": "^2.5",
"guzzlehttp/guzzle": "^6.2",
"acelaya/zsm-annotated-services": "^0.2.0",
"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",

View file

@ -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,
],
],
];

View file

@ -1,4 +1,5 @@
<?php
use Acelaya\UrlShortener\Middleware;
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive\Helper;
@ -15,6 +16,21 @@ return [
'routing' => [
'middleware' => [
ApplicationFactory::ROUTING_MIDDLEWARE,
],
'priority' => 10,
],
'rest' => [
'path' => '/rest',
'middleware' => [
Middleware\CheckAuthenticationMiddleware::class,
Middleware\CrossDomainMiddleware::class,
],
'priority' => 5,
],
'post-routing' => [
'middleware' => [
Helper\UrlHelperMiddleware::class,
ApplicationFactory::DISPATCH_MIDDLEWARE,
],

View file

@ -0,0 +1,9 @@
<?php
return [
'rest' => [
'username' => getenv('REST_USER'),
'password' => getenv('REST_PASSWORD'),
],
];

View file

@ -1,5 +1,6 @@
<?php
use Acelaya\UrlShortener\Middleware\Routable;
use Acelaya\UrlShortener\Middleware\Rest;
return [
@ -10,6 +11,38 @@ return [
'middleware' => Routable\RedirectMiddleware::class,
'allowed_methods' => ['GET'],
],
// Rest
[
'name' => 'rest-authenticate',
'path' => '/rest/authenticate',
'middleware' => Rest\AuthenticateMiddleware::class,
'allowed_methods' => ['POST', 'OPTIONS'],
],
[
'name' => 'rest-create-shortcode',
'path' => '/rest/short-codes',
'middleware' => Rest\CreateShortcodeMiddleware::class,
'allowed_methods' => ['POST', 'OPTIONS'],
],
[
'name' => 'rest-resolve-url',
'path' => '/rest/short-codes/{shortCode}',
'middleware' => Rest\ResolveUrlMiddleware::class,
'allowed_methods' => ['GET', 'OPTIONS'],
],
[
'name' => 'rest-list-shortened-url',
'path' => '/rest/short-codes',
'middleware' => Rest\ListShortcodesMiddleware::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'rest-get-visits',
'path' => '/rest/visits/{shortCode}',
'middleware' => Rest\GetVisitsMiddleware::class,
'allowed_methods' => ['GET', 'OPTIONS'],
],
],
];

View file

@ -37,6 +37,8 @@ return [
GuzzleHttp\Client::class => InvokableFactory::class,
Service\UrlShortener::class => AnnotatedFactory::class,
Service\VisitsTracker::class => AnnotatedFactory::class,
Service\ShortUrlService::class => AnnotatedFactory::class,
Service\RestTokenService::class => AnnotatedFactory::class,
Cache::class => CacheFactory::class,
// Cli commands
@ -44,11 +46,19 @@ 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,
Middleware\Rest\ListShortcodesMiddleware::class => AnnotatedFactory::class,
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
'httpClient' => GuzzleHttp\Client::class,
Router\RouterInterface::class => Router\FastRouteRouter::class,
AnnotatedFactory::CACHE_SERVICE => Cache::class,
]
],

278
data/docs/rest.md Normal file
View file

@ -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.

102
src/Entity/RestToken.php Normal file
View file

@ -0,0 +1,102 @@
<?php
namespace Acelaya\UrlShortener\Entity;
use Acelaya\UrlShortener\Util\StringUtilsTrait;
use Doctrine\ORM\Mapping as ORM;
/**
* Class RestToken
* @author
* @link
*
* @ORM\Entity()
* @ORM\Table(name="rest_tokens")
*/
class RestToken extends AbstractEntity
{
use StringUtilsTrait;
/**
* 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();
$this->setRandomTokenKey();
}
/**
* @return \DateTime
*/
public function getExpirationDate()
{
return $this->expirationDate;
}
/**
* @param \DateTime $expirationDate
* @return $this
*/
public function setExpirationDate($expirationDate)
{
$this->expirationDate = $expirationDate;
return $this;
}
/**
* @return string
*/
public function getToken()
{
return $this->token;
}
/**
* @param string $token
* @return $this
*/
public function setToken($token)
{
$this->token = $token;
return $this;
}
/**
* @return bool
*/
public function isExpired()
{
return new \DateTime() > $this->expirationDate;
}
/**
* Updates the expiration of the token, setting it to the default interval in the future
* @return $this
*/
public function updateExpiration()
{
return $this->setExpirationDate((new \DateTime())->add(new \DateInterval(self::DEFAULT_INTERVAL)));
}
/**
* Sets a random unique token key for this RestToken
* @return RestToken
*/
public function setRandomTokenKey()
{
return $this->setToken($this->generateV4Uuid());
}
}

View file

@ -10,10 +10,10 @@ use Doctrine\ORM\Mapping as ORM;
* @author
* @link
*
* @ORM\Entity
* @ORM\Entity(repositoryClass="Acelaya\UrlShortener\Repository\ShortUrlRepository")
* @ORM\Table(name="short_urls")
*/
class ShortUrl extends AbstractEntity
class ShortUrl extends AbstractEntity implements \JsonSerializable
{
/**
* @var string
@ -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),
];
}
}

View file

@ -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,
];
}
}

View file

@ -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));
}
}

View file

@ -0,0 +1,6 @@
<?php
namespace Acelaya\UrlShortener\Exception;
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
}

View file

@ -0,0 +1,102 @@
<?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 or an OPTIONS request, continue to the next middleware
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class);
if ((isset($routeResult) && $routeResult->getMatchedRouteName() === 'rest-authenticate')
|| strtolower($request->getMethod()) === 'options'
) {
return $out($request, $response);
}
// Check that the auth header was provided, and that it belongs to a non-expired token
if (! $request->hasHeader(self::AUTH_TOKEN_HEADER)) {
return $this->createTokenErrorResponse();
}
$authToken = $request->getHeaderLine(self::AUTH_TOKEN_HEADER);
try {
$restToken = $this->restTokenService->getByToken($authToken);
if ($restToken->isExpired()) {
return $this->createTokenErrorResponse();
}
// Update the token expiration and continue to next middleware
$this->restTokenService->updateExpiration($restToken);
return $out($request, $response);
} catch (InvalidArgumentException $e) {
return $this->createTokenErrorResponse();
}
}
protected function createTokenErrorResponse()
{
return new JsonResponse([
'error' => RestUtils::INVALID_AUTH_TOKEN_ERROR,
'message' => sprintf(
'Missing or invalid auth token provided. Perform a new authentication request and send provided token '
. 'on every new request on the "%s" header',
self::AUTH_TOKEN_HEADER
),
], 401);
}
}

View file

@ -3,26 +3,10 @@ namespace Acelaya\UrlShortener\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Expressive\Router\RouteResult;
use Zend\Stratigility\MiddlewareInterface;
class CliParamsMiddleware implements MiddlewareInterface
class CrossDomainMiddleware implements MiddlewareInterface
{
/**
* @var array
*/
private $argv;
/**
* @var
*/
private $currentSapi;
public function __construct(array $argv, $currentSapi)
{
$this->argv = $argv;
$this->currentSapi = $currentSapi;
}
/**
* Process an incoming request and/or response.
*
@ -50,22 +34,19 @@ class CliParamsMiddleware implements MiddlewareInterface
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
// When not in CLI, just call next middleware
if ($this->currentSapi !== 'cli') {
return $out($request, $response);
/** @var Response $response */
$response = $out($request, $response);
if (strtolower($request->getMethod()) === 'options') {
$response = $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Max-Age', '1000')
->withHeader(
// Allow all requested headers
'Access-Control-Allow-Headers',
$request->getHeaderLine('Access-Control-Request-Headers')
);
}
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class);
if (! $routeResult->isSuccess()) {
return $out($request, $response);
}
// Inject ARGV params as request attributes
if ($routeResult->getMatchedRouteName() === 'cli-generate-shortcode') {
$request = $request->withAttribute('longUrl', isset($this->argv[2]) ? $this->argv[2] : null);
}
return $out($request, $response);
return $response->withHeader('Access-Control-Allow-Origin', '*');
}
}

View file

@ -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);
}

View file

@ -0,0 +1,57 @@
<?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;
class AuthenticateMiddleware extends AbstractRestMiddleware
{
/**
* @var RestTokenServiceInterface
*/
private $restTokenService;
/**
* AuthenticateMiddleware constructor.
* @param RestTokenServiceInterface|RestTokenService $restTokenService
*
* @Inject({RestTokenService::class})
*/
public function __construct(RestTokenServiceInterface $restTokenService)
{
$this->restTokenService = $restTokenService;
}
/**
* @param Request $request
* @param Response $response
* @param callable|null $out
* @return null|Response
*/
public function dispatch(Request $request, Response $response, callable $out = null)
{
$authData = $request->getParsedBody();
if (! isset($authData['username'], $authData['password'])) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => 'You have to provide both "username" and "password"'
], 400);
}
try {
$token = $this->restTokenService->createToken($authData['username'], $authData['password']);
return new JsonResponse(['token' => $token->getToken()]);
} catch (AuthenticationException $e) {
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => 'Invalid username and/or password',
], 401);
}
}
}

View file

@ -0,0 +1,79 @@
<?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;
class CreateShortcodeMiddleware extends AbstractRestMiddleware
{
/**
* @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;
}
/**
* @param Request $request
* @param Response $response
* @param callable|null $out
* @return null|Response
*/
public function dispatch(Request $request, Response $response, callable $out = null)
{
$postData = $request->getParsedBody();
if (! isset($postData['longUrl'])) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => 'A URL was not provided',
], 400);
}
$longUrl = $postData['longUrl'];
try {
$shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl));
$shortUrl = (new Uri())->withPath($shortCode)
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
return new JsonResponse([
'longUrl' => $longUrl,
'shortUrl' => $shortUrl->__toString(),
'shortCode' => $shortCode,
]);
} catch (InvalidUrlException $e) {
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl),
], 400);
} catch (\Exception $e) {
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => 'Unexpected error occured',
], 500);
}
}
}

View file

@ -0,0 +1,62 @@
<?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;
class GetVisitsMiddleware extends AbstractRestMiddleware
{
/**
* @var VisitsTrackerInterface
*/
private $visitsTracker;
/**
* GetVisitsMiddleware constructor.
* @param VisitsTrackerInterface|VisitsTracker $visitsTracker
*
* @Inject({VisitsTracker::class})
*/
public function __construct(VisitsTrackerInterface $visitsTracker)
{
$this->visitsTracker = $visitsTracker;
}
/**
* @param Request $request
* @param Response $response
* @param callable|null $out
* @return null|Response
*/
public function dispatch(Request $request, Response $response, callable $out = null)
{
$shortCode = $request->getAttribute('shortCode');
try {
$visits = $this->visitsTracker->info($shortCode);
return new JsonResponse([
'visits' => [
'data' => $visits,
// 'pagination' => [],
]
]);
} catch (InvalidArgumentException $e) {
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('Provided short code "%s" is invalid', $shortCode),
], 400);
} catch (\Exception $e) {
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => 'Unexpected error occured',
], 500);
}
}
}

View file

@ -0,0 +1,53 @@
<?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;
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;
class ListShortcodesMiddleware extends AbstractRestMiddleware
{
use PaginatorSerializerTrait;
/**
* @var ShortUrlServiceInterface
*/
private $shortUrlService;
/**
* ListShortcodesMiddleware constructor.
* @param ShortUrlServiceInterface|ShortUrlService $shortUrlService
*
* @Inject({ShortUrlService::class})
*/
public function __construct(ShortUrlServiceInterface $shortUrlService)
{
$this->shortUrlService = $shortUrlService;
}
/**
* @param Request $request
* @param Response $response
* @param callable|null $out
* @return null|Response
*/
public function dispatch(Request $request, Response $response, callable $out = null)
{
try {
$query = $request->getQueryParams();
$shortUrls = $this->shortUrlService->listShortUrls(isset($query['page']) ? $query['page'] : 1);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls)]);
} catch (\Exception $e) {
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => 'Unexpected error occured',
], 500);
}
}
}

View file

@ -0,0 +1,65 @@
<?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;
class ResolveUrlMiddleware extends AbstractRestMiddleware
{
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* ResolveUrlMiddleware constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
*
* @Inject({UrlShortener::class})
*/
public function __construct(UrlShortenerInterface $urlShortener)
{
$this->urlShortener = $urlShortener;
}
/**
* @param Request $request
* @param Response $response
* @param callable|null $out
* @return null|Response
*/
public function dispatch(Request $request, Response $response, callable $out = null)
{
$shortCode = $request->getAttribute('shortCode');
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
if (! isset($longUrl)) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => sprintf('No URL found for shortcode "%s"', $shortCode),
], 400);
}
return new JsonResponse([
'longUrl' => $longUrl,
]);
} catch (InvalidShortCodeException $e) {
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('Provided short code "%s" has an invalid format', $shortCode),
], 400);
} catch (\Exception $e) {
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => 'Unexpected error occured',
], 500);
}
}
}

View file

@ -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);
}
}

View file

@ -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(),
],
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace Acelaya\UrlShortener\Repository;
interface PaginableRepositoryInterface
{
/**
* Gets a list of elements using provided filtering data
*
* @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);
/**
* Counts the number of elements in a list using provided filtering data
*
* @param null $searchTerm
* @return int
*/
public function countList($searchTerm = null);
}

View file

@ -0,0 +1,62 @@
<?php
namespace Acelaya\UrlShortener\Repository;
use Acelaya\UrlShortener\Entity\ShortUrl;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
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();
}
/**
* 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();
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace Acelaya\UrlShortener\Repository;
use Doctrine\Common\Persistence\ObjectRepository;
interface ShortUrlRepositoryInterface extends ObjectRepository, PaginableRepositoryInterface
{
}

View file

@ -0,0 +1,98 @@
<?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);
}
/**
* Updates the expiration of provided token, extending its life
*
* @param RestToken $token
*/
public function updateExpiration(RestToken $token)
{
$token->updateExpiration();
$this->em->flush();
}
}

View file

@ -0,0 +1,32 @@
<?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);
/**
* Updates the expiration of provided token, extending its life
*
* @param RestToken $token
*/
public function updateExpiration(RestToken $token);
}

View file

@ -0,0 +1,43 @@
<?php
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;
class ShortUrlService implements ShortUrlServiceInterface
{
/**
* @var EntityManagerInterface
*/
private $em;
/**
* ShortUrlService constructor.
* @param EntityManagerInterface $em
*
* @Inject({"em"})
*/
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* @param int $page
* @return Paginator|ShortUrl[]
*/
public function listShortUrls($page = 1)
{
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$paginator = new Paginator(new PaginableRepositoryAdapter($repo));
$paginator->setItemCountPerPage(PaginableRepositoryAdapter::ITEMS_PER_PAGE)
->setCurrentPageNumber($page);
return $paginator;
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Acelaya\UrlShortener\Service;
use Acelaya\UrlShortener\Entity\ShortUrl;
use Zend\Paginator\Paginator;
interface ShortUrlServiceInterface
{
/**
* @param int $page
* @return ShortUrl[]|Paginator
*/
public function listShortUrls($page = 1);
}

View file

@ -3,8 +3,11 @@ 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;
use Zend\Paginator\Paginator;
class VisitsTracker implements VisitsTrackerInterface
{
@ -58,4 +61,27 @@ class VisitsTracker implements VisitsTrackerInterface
{
return isset($array[$key]) ? $array[$key] : $default;
}
/**
* Returns the visits on certain shortcode
*
* @param $shortCode
* @return Paginator|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'
]);
}
}

View file

@ -1,6 +1,9 @@
<?php
namespace Acelaya\UrlShortener\Service;
use Acelaya\UrlShortener\Entity\Visit;
use Zend\Paginator\Paginator;
interface VisitsTrackerInterface
{
/**
@ -10,4 +13,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 Paginator|Visit[]
*/
public function info($shortCode);
}

30
src/Util/RestUtils.php Normal file
View file

@ -0,0 +1,30 @@
<?php
namespace Acelaya\UrlShortener\Util;
use Acelaya\UrlShortener\Exception;
class RestUtils
{
const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE';
const INVALID_URL_ERROR = 'INVALID_URL';
const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT';
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)
{
switch (true) {
case $e instanceof Exception\InvalidShortCodeException:
return self::INVALID_SHORTCODE_ERROR;
case $e instanceof Exception\InvalidUrlException:
return self::INVALID_URL_ERROR;
case $e instanceof Exception\InvalidArgumentException:
return self::INVALID_ARGUMENT_ERROR;
case $e instanceof Exception\AuthenticationException:
return self::INVALID_CREDENTIALS_ERROR;
default:
return self::UNKNOWN_ERROR;
}
}
}

View file

@ -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)
);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace AcelayaTest\UrlShortener\Service;
use Acelaya\UrlShortener\Entity\ShortUrl;
use Acelaya\UrlShortener\Repository\ShortUrlRepository;
use Acelaya\UrlShortener\Service\ShortUrlService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
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()
{
$list = [
new ShortUrl(),
new ShortUrl(),
new ShortUrl(),
new ShortUrl(),
];
$repo = $this->prophesize(ShortUrlRepository::class);
$repo->findList(Argument::cetera())->willReturn($list)->shouldBeCalledTimes(1);
$repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledTimes(1);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$list = $this->service->listShortUrls();
$this->assertEquals(4, $list->getCurrentItemCount());
}
}