Merge branch 'develop'

This commit is contained in:
Alejandro Celaya 2016-05-02 17:29:32 +02:00
commit fb9d3268ad
47 changed files with 1506 additions and 237 deletions

10
.env.dist Normal file
View file

@ -0,0 +1,10 @@
# Application
APP_ENV=
SHORTENED_URL_SCHEMA=
SHORTENED_URL_HOSTNAME=
SHORTCODE_CHARS=
# Database
DB_USER=
DB_PASSWORD=
DB_NAME=

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
build
composer.lock
vendor/
.env

6
.scrutinizer.yml Normal file
View file

@ -0,0 +1,6 @@
tools:
external_code_coverage: true
checks:
php:
code_rating: true
duplication: true

View file

@ -1,28 +1,26 @@
sudo: false
language: php
matrix:
fast_finish: true
include:
- php: 5.5
- php: 5.6
env:
- EXECUTE_CS_CHECK=true
- php: 7
- php: hhvm
allow_failures:
- php: hhvm
branches:
only:
- master
- develop
before_install:
php:
- 5.5
- 5.6
- 7
- hhvm
before_script:
- composer self-update
install:
- travis_retry composer install --no-interaction --ignore-platform-reqs --prefer-source --no-scripts
- composer install --no-interaction
script:
- composer test
- if [[ $EXECUTE_CS_CHECK == 'true' ]]; then composer cs ; fi
- mkdir build
- composer check
notifications:
email: true
after_script:
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
sudo: false

5
bin/cli Normal file → Executable file
View file

@ -1,11 +1,14 @@
#!/usr/bin/env php
<?php
use Interop\Container\ContainerInterface;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Uri;
use Zend\Expressive\Application;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
/** @var Application $app */
$app = include __DIR__ . '/../config/app.php';
$app = $container->get(Application::class);
$command = count($_SERVER['argv']) > 1 ? $_SERVER['argv'][1] : '';
$request = ServerRequestFactory::fromGlobals()

11
cli-config.php Normal file
View file

@ -0,0 +1,11 @@
<?php
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Interop\Container\ContainerInterface;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/config/container.php';
/** @var EntityManager $em */
$em = $container->get(EntityManager::class);
return ConsoleRunner::createHelperSet($em);

View file

@ -14,18 +14,21 @@
"php": "^5.5 || ^7.0",
"zendframework/zend-expressive": "^1.0",
"zendframework/zend-expressive-helpers": "^2.0",
"zendframework/zend-stdlib": "^2.7",
"zendframework/zend-expressive-aurarouter": "^1.0",
"zendframework/zend-servicemanager": "^3.0",
"zendframework/zend-expressive-fastroute": "^1.1",
"zendframework/zend-expressive-twigrenderer": "^1.0",
"acelaya/expressive-slim-router": "^2.0"
"zendframework/zend-stdlib": "^2.7",
"zendframework/zend-servicemanager": "^3.0",
"doctrine/orm": "^2.5",
"guzzlehttp/guzzle": "^6.2",
"acelaya/zsm-annotated-services": "^0.2.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8",
"squizlabs/php_codesniffer": "^2.3",
"roave/security-advisories": "dev-master",
"filp/whoops": "^2.0",
"symfony/var-dumper": "^3.0"
"symfony/var-dumper": "^3.0",
"vlucas/phpdotenv": "^2.2"
},
"autoload": {
"psr-4": {
@ -45,7 +48,7 @@
"cs": "phpcs",
"cs-fix": "phpcbf",
"serve": "php -S 0.0.0.0:8000 -t public/",
"test": "phpunit",
"pretty-test": "phpunit -c tests/phpunit.xml --coverage-html build/coverage"
"test": "phpunit --coverage-clover build/clover.xml",
"pretty-test": "phpunit --coverage-html build/coverage"
}
}

View file

@ -1,14 +0,0 @@
<?php
use Zend\Expressive\Application;
use Zend\ServiceManager\ServiceManager;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
// Build container
$config = require __DIR__ . '/config.php';
$container = new ServiceManager($config['services']);
$container->setService('config', $config);
return $container->get(Application::class);

View file

@ -0,0 +1,15 @@
<?php
use Acelaya\UrlShortener\Middleware\CliRoutable;
return [
'routes' => [
[
'name' => 'cli-generate-shortcode',
'path' => '/generate-shortcode',
'middleware' => CliRoutable\GenerateShortcodeMiddleware::class,
'allowed_methods' => ['CLI'],
],
],
];

View file

@ -0,0 +1,15 @@
<?php
return [
'database' => [
'driver' => 'pdo_mysql',
'user' => getenv('DB_USER'),
'password' => getenv('DB_PASSWORD'),
'dbname' => getenv('DB_NAME') ?: 'acelaya_url_shortener',
'charset' => 'utf8',
'driverOptions' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8'
],
],
];

View file

@ -1,9 +1,10 @@
<?php
use Acelaya\UrlShortener\Middleware\CliParamsMiddleware;
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive\Helper;
return [
'middleware_pipeline' => [
'always' => [
'middleware' => [
@ -15,6 +16,7 @@ return [
'routing' => [
'middleware' => [
ApplicationFactory::ROUTING_MIDDLEWARE,
CliParamsMiddleware::class,
Helper\UrlHelperMiddleware::class,
ApplicationFactory::DISPATCH_MIDDLEWARE,
],

View file

@ -1,26 +1,15 @@
<?php
use Acelaya\UrlShortener\Middleware\Routable;
return [
'routes' => [
[
'name' => 'home',
'path' => '/',
'middleware' => function ($req, $resp) {
$resp->getBody()->write('Hello world');
return $resp;
},
'name' => 'long-url-redirect',
'path' => '/{shortCode}',
'middleware' => Routable\RedirectMiddleware::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'cli',
'path' => '/command-name',
'middleware' => function ($req, $resp) {
$resp->getBody()->write('Hello world from cli');
return $resp;
},
'allowed_methods' => ['CLI'],
],
],
];

View file

@ -1,17 +1,22 @@
<?php
use Acelaya\UrlShortener\Factory\CacheFactory;
use Acelaya\UrlShortener\Factory\EntityManagerFactory;
use Acelaya\UrlShortener\Middleware;
use Acelaya\UrlShortener\Service;
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use Zend\Expressive\Application;
use Zend\Expressive\Container;
use Zend\Expressive\Helper;
use Zend\Expressive\Router;
use Zend\Expressive\Template;
use Zend\Expressive\Twig;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'services' => [
'invokables' => [
Helper\ServerUrlHelper::class => Helper\ServerUrlHelper::class,
Router\RouterInterface::class => Router\AuraRouter::class,
],
'factories' => [
Application::class => Container\ApplicationFactory::class,
@ -19,11 +24,30 @@ return [
Helper\UrlHelper::class => Helper\UrlHelperFactory::class,
Helper\ServerUrlMiddleware::class => Helper\ServerUrlMiddlewareFactory::class,
Helper\UrlHelperMiddleware::class => Helper\UrlHelperMiddlewareFactory::class,
Helper\ServerUrlHelper::class => InvokableFactory::class,
Router\FastRouteRouter::class => InvokableFactory::class,
// View
'Zend\Expressive\FinalHandler' => Container\TemplatedErrorHandlerFactory::class,
Template\TemplateRendererInterface::class => Zend\Expressive\Twig\TwigRendererFactory::class,
Template\TemplateRendererInterface::class => Twig\TwigRendererFactory::class,
// Services
EntityManager::class => EntityManagerFactory::class,
GuzzleHttp\Client::class => InvokableFactory::class,
Service\UrlShortener::class => AnnotatedFactory::class,
Service\VisitsTracker::class => AnnotatedFactory::class,
Cache::class => CacheFactory::class,
// Middleware
Middleware\CliRoutable\GenerateShortcodeMiddleware::class => AnnotatedFactory::class,
Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::class,
Middleware\CliParamsMiddleware::class => Middleware\Factory\CliParamsMiddlewareFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
'httpClient' => GuzzleHttp\Client::class,
Router\RouterInterface::class => Router\FastRouteRouter::class,
]
],
];

View file

@ -0,0 +1,12 @@
<?php
return [
'url_shortener' => [
'domain' => [
'schema' => getenv('SHORTENED_URL_SCHEMA') ?: 'http',
'hostname' => getenv('SHORTENED_URL_HOSTNAME'),
],
'shortcode_chars' => getenv('SHORTCODE_CHARS'),
],
];

View file

@ -29,6 +29,4 @@ if (is_file($cachedConfigFile)) {
}
}
// Return an ArrayObject so we can inject the config as a service in Aura.Di
// and still use array checks like ``is_array``.
return new ArrayObject($config, ArrayObject::ARRAY_AS_PROPS);
return $config;

21
config/container.php Normal file
View file

@ -0,0 +1,21 @@
<?php
use Dotenv\Dotenv;
use Zend\ServiceManager\ServiceManager;
chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// If the Dotenv class exists, load env vars and enable errors
if (class_exists(Dotenv::class)) {
error_reporting(E_ALL);
ini_set('display_errors', 1);
$dotenv = new Dotenv(__DIR__ . '/..');
$dotenv->load();
}
// Build container
$config = require __DIR__ . '/config.php';
$container = new ServiceManager($config['services']);
$container->setService('config', $config);
return $container;

2
data/proxies/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -1,7 +1,7 @@
<phpunit bootstrap="./vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="App\\Tests">
<directory>./test</directory>
<testsuite name="AcelayaTest">
<directory>./tests</directory>
</testsuite>
</testsuites>

View file

@ -1,6 +1,9 @@
<?php
use Interop\Container\ContainerInterface;
use Zend\Expressive\Application;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
/** @var Application $app */
$app = include __DIR__ . '/../config/app.php';
$app = $container->get(Application::class);
$app->run();

View file

@ -0,0 +1,33 @@
<?php
namespace Acelaya\UrlShortener\Entity;
use Doctrine\ORM\Mapping as ORM;
abstract class AbstractEntity
{
/**
* @var int
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @ORM\Column(name="id", type="bigint", options={"unsigned"=true})
*/
protected $id;
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @param int $id
* @return $this
*/
public function setId($id)
{
$this->id = $id;
return $this;
}
}

120
src/Entity/ShortUrl.php Normal file
View file

@ -0,0 +1,120 @@
<?php
namespace Acelaya\UrlShortener\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* Class ShortUrl
* @author
* @link
*
* @ORM\Entity
* @ORM\Table(name="short_urls")
*/
class ShortUrl extends AbstractEntity
{
/**
* @var string
* @ORM\Column(name="original_url", type="string", nullable=false, length=1024, unique=true)
*/
protected $originalUrl;
/**
* @var string
* @ORM\Column(name="short_code", type="string", nullable=false, length=10, unique=true)
*/
protected $shortCode;
/**
* @var \DateTime
* @ORM\Column(name="date_created", type="datetime")
*/
protected $dateCreated;
/**
* @var Collection|Visit[]
* @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY")
*/
protected $visits;
/**
* ShortUrl constructor.
*/
public function __construct()
{
$this->setDateCreated(new \DateTime());
$this->setVisits(new ArrayCollection());
$this->setShortCode('');
}
/**
* @return string
*/
public function getOriginalUrl()
{
return $this->originalUrl;
}
/**
* @param string $originalUrl
* @return $this
*/
public function setOriginalUrl($originalUrl)
{
$this->originalUrl = (string) $originalUrl;
return $this;
}
/**
* @return string
*/
public function getShortCode()
{
return $this->shortCode;
}
/**
* @param string $shortCode
* @return $this
*/
public function setShortCode($shortCode)
{
$this->shortCode = $shortCode;
return $this;
}
/**
* @return \DateTime
*/
public function getDateCreated()
{
return $this->dateCreated;
}
/**
* @param \DateTime $dateCreated
* @return $this
*/
public function setDateCreated($dateCreated)
{
$this->dateCreated = $dateCreated;
return $this;
}
/**
* @return Visit[]|Collection
*/
public function getVisits()
{
return $this->visits;
}
/**
* @param Visit[]|Collection $visits
* @return $this
*/
public function setVisits($visits)
{
$this->visits = $visits;
return $this;
}
}

137
src/Entity/Visit.php Normal file
View file

@ -0,0 +1,137 @@
<?php
namespace Acelaya\UrlShortener\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* Class Visit
* @author
* @link
*
* @ORM\Entity
* @ORM\Table(name="visits")
*/
class Visit extends AbstractEntity
{
/**
* @var string
* @ORM\Column(type="string", length=256, nullable=true)
*/
protected $referer;
/**
* @var \DateTime
* @ORM\Column(type="datetime", nullable=false)
*/
protected $date;
/**
* @var string
* @ORM\Column(type="string", length=256, name="remote_addr", nullable=true)
*/
protected $remoteAddr;
/**
* @var string
* @ORM\Column(type="string", length=256, name="user_agent", nullable=true)
*/
protected $userAgent;
/**
* @var ShortUrl
* @ORM\ManyToOne(targetEntity=ShortUrl::class)
* @ORM\JoinColumn(name="short_url_id", referencedColumnName="id")
*/
protected $shortUrl;
public function __construct()
{
$this->date = new \DateTime();
}
/**
* @return string
*/
public function getReferer()
{
return $this->referer;
}
/**
* @param string $referer
* @return $this
*/
public function setReferer($referer)
{
$this->referer = $referer;
return $this;
}
/**
* @return \DateTime
*/
public function getDate()
{
return $this->date;
}
/**
* @param \DateTime $date
* @return $this
*/
public function setDate($date)
{
$this->date = $date;
return $this;
}
/**
* @return ShortUrl
*/
public function getShortUrl()
{
return $this->shortUrl;
}
/**
* @param ShortUrl $shortUrl
* @return $this
*/
public function setShortUrl($shortUrl)
{
$this->shortUrl = $shortUrl;
return $this;
}
/**
* @return string
*/
public function getRemoteAddr()
{
return $this->remoteAddr;
}
/**
* @param string $remoteAddr
* @return $this
*/
public function setRemoteAddr($remoteAddr)
{
$this->remoteAddr = $remoteAddr;
return $this;
}
/**
* @return string
*/
public function getUserAgent()
{
return $this->userAgent;
}
/**
* @param string $userAgent
* @return $this
*/
public function setUserAgent($userAgent)
{
$this->userAgent = $userAgent;
return $this;
}
}

View file

@ -0,0 +1,6 @@
<?php
namespace Acelaya\UrlShortener\Exception;
interface ExceptionInterface
{
}

View file

@ -0,0 +1,15 @@
<?php
namespace Acelaya\UrlShortener\Exception;
class InvalidShortCodeException extends RuntimeException
{
public static function fromShortCode($shortCode, $charSet, \Exception $previous = null)
{
$code = isset($previous) ? $previous->getCode() : -1;
return new static(
sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
$code,
$previous
);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace Acelaya\UrlShortener\Exception;
class InvalidUrlException extends RuntimeException
{
public static function fromUrl($url, \Exception $previous = null)
{
$code = isset($previous) ? $previous->getCode() : -1;
return new static(sprintf('Provided URL "%s" is not an exisitng and valid URL', $url), $code, $previous);
}
}

View file

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

View file

@ -0,0 +1,30 @@
<?php
namespace Acelaya\UrlShortener\Factory;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class CacheFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return getenv('APP_ENV') === 'pro' ? new ApcuCache() : new ArrayCache();
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Acelaya\UrlShortener\Factory;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Setup;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class EntityManagerFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$globalConfig = $container->get('config');
$isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false;
$cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache();
$dbConfig = isset($globalConfig['database']) ? $globalConfig['database'] : [];
return EntityManager::create($dbConfig, Setup::createAnnotationMetadataConfiguration(
['src/Entity'],
$isDevMode,
'data/proxies',
$cache,
false
));
}
}

View file

@ -0,0 +1,71 @@
<?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);
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace Acelaya\UrlShortener\Middleware\CliRoutable;
use Acelaya\UrlShortener\Exception\InvalidUrlException;
use Acelaya\UrlShortener\Service\UrlShortener;
use Acelaya\UrlShortener\Service\UrlShortenerInterface;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Diactoros\Uri;
use Zend\Stratigility\MiddlewareInterface;
class GenerateShortcodeMiddleware implements MiddlewareInterface
{
/**
* @var 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)
{
$longUrl = $request->getAttribute('longUrl');
try {
if (! isset($longUrl)) {
$response->getBody()->write('A URL was not provided!' . PHP_EOL);
return;
}
$shortcode = $this->urlShortener->urlToShortCode(new Uri($longUrl));
$shortUrl = (new Uri())->withPath($shortcode)
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
$response->getBody()->write(
sprintf('Processed URL "%s".%sGenerated short URL "%s"', $longUrl, PHP_EOL, $shortUrl) . PHP_EOL
);
} catch (InvalidUrlException $e) {
$response->getBody()->write(
sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl) . PHP_EOL
);
} catch (\Exception $e) {
$response->getBody()->write($e);
} finally {
return $response;
}
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Acelaya\UrlShortener\Middleware\Factory;
use Acelaya\UrlShortener\Middleware\CliParamsMiddleware;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class CliParamsMiddlewareFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new CliParamsMiddleware(
isset($_SERVER['argv']) ? $_SERVER['argv'] : [],
php_sapi_name()
);
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Acelaya\UrlShortener\Middleware\Routable;
use Acelaya\UrlShortener\Service\UrlShortener;
use Acelaya\UrlShortener\Service\UrlShortenerInterface;
use Acelaya\UrlShortener\Service\VisitsTracker;
use Acelaya\UrlShortener\Service\VisitsTrackerInterface;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Diactoros\Response\RedirectResponse;
use Zend\Stratigility\MiddlewareInterface;
class RedirectMiddleware implements MiddlewareInterface
{
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var VisitsTracker|VisitsTrackerInterface
*/
private $visitTracker;
/**
* RedirectMiddleware constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param VisitsTrackerInterface|VisitsTracker $visitTracker
*
* @Inject({UrlShortener::class, VisitsTracker::class})
*/
public function __construct(UrlShortenerInterface $urlShortener, VisitsTrackerInterface $visitTracker)
{
$this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker;
}
/**
* Process an incoming request and/or response.
*
* Accepts a server-side request and a response instance, and does
* something with them.
*
* If the response is not complete and/or further processing would not
* interfere with the work done in the middleware, or if the middleware
* wants to delegate to another process, it can use the `$out` callable
* if present.
*
* If the middleware does not return a value, execution of the current
* request is considered complete, and the response instance provided will
* be considered the response to return.
*
* Alternately, the middleware may return a response instance.
*
* Often, middleware will `return $out();`, with the assumption that a
* later middleware will return a response.
*
* @param Request $request
* @param Response $response
* @param null|callable $out
* @return null|Response
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$shortCode = $request->getAttribute('shortCode', '');
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
// If provided shortCode does not belong to a valid long URL, dispatch next middleware, which is 404
// middleware
if (! isset($longUrl)) {
return $out($request, $response);
}
// Track visit to this shortcode
$this->visitTracker->track($shortCode);
// Return a redirect response to the long URL.
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
return new RedirectResponse($longUrl);
} catch (\Exception $e) {
// In case of error, dispatch 404 error
return $out($request, $response);
}
}
}

View file

@ -0,0 +1,154 @@
<?php
namespace Acelaya\UrlShortener\Service;
use Acelaya\UrlShortener\Entity\ShortUrl;
use Acelaya\UrlShortener\Exception\InvalidShortCodeException;
use Acelaya\UrlShortener\Exception\InvalidUrlException;
use Acelaya\UrlShortener\Exception\RuntimeException;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMException;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Message\UriInterface;
class UrlShortener implements UrlShortenerInterface
{
const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var EntityManagerInterface
*/
private $em;
/**
* @var string
*/
private $chars;
/**
* UrlShortener constructor.
* @param ClientInterface $httpClient
* @param EntityManagerInterface $em
* @param string $chars
*
* @Inject({"httpClient", "em", "config.url_shortener.shortcode_chars"})
*/
public function __construct(
ClientInterface $httpClient,
EntityManagerInterface $em,
$chars = self::DEFAULT_CHARS
) {
$this->httpClient = $httpClient;
$this->em = $em;
$this->chars = $chars;
}
/**
* Creates and persists a unique shortcode generated for provided url
*
* @param UriInterface $url
* @return string
* @throws InvalidUrlException
* @throws RuntimeException
*/
public function urlToShortCode(UriInterface $url)
{
// If the url already exists in the database, just return its short code
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'originalUrl' => $url
]);
if (isset($shortUrl)) {
return $shortUrl->getShortCode();
}
// Check that the URL exists
$this->checkUrlExists($url);
// Transactionally insert the short url, then generate the short code and finally update the short code
try {
$this->em->beginTransaction();
// First, create the short URL with an empty short code
$shortUrl = new ShortUrl();
$shortUrl->setOriginalUrl($url);
$this->em->persist($shortUrl);
$this->em->flush();
// Generate the short code and persist it
$shortCode = $this->convertAutoincrementIdToShortCode($shortUrl->getId());
$shortUrl->setShortCode($shortCode);
$this->em->flush();
$this->em->commit();
return $shortCode;
} catch (ORMException $e) {
if ($this->em->getConnection()->isTransactionActive()) {
$this->em->rollback();
$this->em->close();
}
throw new RuntimeException('An error occured while persisting the short URL', -1, $e);
}
}
/**
* Tries to perform a GET request to provided url, returning true on success and false on failure
*
* @param UriInterface $url
* @return bool
*/
protected function checkUrlExists(UriInterface $url)
{
try {
$this->httpClient->request('GET', $url);
} catch (GuzzleException $e) {
throw InvalidUrlException::fromUrl($url, $e);
}
}
/**
* Generates the unique shortcode for an autoincrement ID
*
* @param int $id
* @return string
*/
protected function convertAutoincrementIdToShortCode($id)
{
$id = intval($id) + 200000; // Increment the Id so that the generated shortcode is not too short
$length = strlen($this->chars);
$code = '';
while ($id > 0) {
// Determine the value of the next higher character in the short code and prepend it
$code = $this->chars[intval(fmod($id, $length))] . $code;
$id = floor($id / $length);
}
return $this->chars[intval($id)] . $code;
}
/**
* Tries to find the mapped URL for provided short code. Returns null if not found
*
* @param string $shortCode
* @return string|null
* @throws InvalidShortCodeException
*/
public function shortCodeToUrl($shortCode)
{
// Validate short code format
if (! preg_match('|[' . $this->chars . "]+|", $shortCode)) {
throw InvalidShortCodeException::fromShortCode($shortCode, $this->chars);
}
/** @var ShortUrl $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
return isset($shortUrl) ? $shortUrl->getOriginalUrl() : null;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Acelaya\UrlShortener\Service;
use Acelaya\UrlShortener\Exception\InvalidShortCodeException;
use Acelaya\UrlShortener\Exception\InvalidUrlException;
use Acelaya\UrlShortener\Exception\RuntimeException;
use Psr\Http\Message\UriInterface;
interface UrlShortenerInterface
{
/**
* Creates and persists a unique shortcode generated for provided url
*
* @param UriInterface $url
* @return string
* @throws InvalidUrlException
* @throws RuntimeException
*/
public function urlToShortCode(UriInterface $url);
/**
* Tries to find the mapped URL for provided short code. Returns null if not found
*
* @param string $shortCode
* @return string|null
* @throws InvalidShortCodeException
*/
public function shortCodeToUrl($shortCode);
}

View file

@ -0,0 +1,61 @@
<?php
namespace Acelaya\UrlShortener\Service;
use Acelaya\UrlShortener\Entity\ShortUrl;
use Acelaya\UrlShortener\Entity\Visit;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Doctrine\ORM\EntityManagerInterface;
class VisitsTracker implements VisitsTrackerInterface
{
/**
* @var EntityManagerInterface
*/
private $em;
/**
* VisitsTracker constructor.
* @param EntityManagerInterface $em
*
* @Inject({"em"})
*/
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* Tracks a new visit to provided short code, using an array of data to look up information
*
* @param string $shortCode
* @param array $visitorData Defaults to global $_SERVER
*/
public function track($shortCode, array $visitorData = null)
{
$visitorData = $visitorData ?: $_SERVER;
/** @var ShortUrl $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
$visit = new Visit();
$visit->setShortUrl($shortUrl)
->setUserAgent($this->getArrayValue($visitorData, 'HTTP_USER_AGENT'))
->setReferer($this->getArrayValue($visitorData, 'HTTP_REFERER'))
->setRemoteAddr($this->getArrayValue($visitorData, 'REMOTE_ADDR'));
$this->em->persist($visit);
$this->em->flush();
}
/**
* @param array $array
* @param $key
* @param null $default
* @return mixed|null
*/
protected function getArrayValue(array $array, $key, $default = null)
{
return isset($array[$key]) ? $array[$key] : $default;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Acelaya\UrlShortener\Service;
interface VisitsTrackerInterface
{
/**
* Tracks a new visit to provided short code, using an array of data to look up information
*
* @param string $shortCode
* @param array $visitorData Defaults to global $_SERVER
*/
public function track($shortCode, array $visitorData = null);
}

View file

@ -1,113 +0,0 @@
{% extends 'layout/default.html.twig' %}
{% block title %}Home{% endblock %}
{% block content %}
<div class="jumbotron">
<h1>Welcome to <span class="zf-green">zend-expressive</span></h1>
<p>
Congratulations! You have successfully installed the
<a href="https://github.com/zendframework/zend-expressive-skeleton" target="_blank">zend-expressive skeleton application</a>.
This skeleton can serve as a simple starting point for you to begin building your application.
</p>
<p>
Expressive builds on zend-stratigility to provide a minimalist PSR-7 middleware framework for PHP.
</p>
</div>
<div class="row">
<div class="col-md-4">
<h2>
<a href="https://zendframework.github.io/zend-expressive/getting-started/features/" target="_blank">
<i class="fa fa-refresh"></i> Agile &amp; Lean
</a>
</h2>
<p>
Expressive is fast, small and perfect for rapid application development, prototyping and api's. You decide how you
extend it and choose the best packages from major framework or standalone projects.
</p>
</div>
<div class="col-md-4">
<h2>
<a href="https://github.com/zendframework/zend-diactoros" target="_blank">
<i class="fa fa-exchange"></i> HTTP Messages
</a>
</h2>
<p>
HTTP messages are the foundation of web development. Web browsers and HTTP clients such as cURL create
HTTP request messages that are sent to a web server, which provides an HTTP response message.
Server-side code receives an HTTP request message, and returns an HTTP response message.
</p>
</div>
<div class="col-md-4">
<h2>
<a href="https://github.com/zendframework/zend-stratigility" target="_blank">
<i class="fa fa-dot-circle-o"></i> Middleware
</a>
</h2>
<p>
Middleware is code that exists between the request and response, and which can take the incoming
request, perform actions based on it, and either complete the response or pass delegation on to the
next middleware in the queue. Your application is easily extended with custom middleware created by
yourself or <a href="https://packagist.org/search/?q=middleware" target="_blank">others</a>.
</p>
</div>
</div>
<div class="row">
<div class="col-md-4">
<h2>
<a href="https://zendframework.github.io/zend-expressive/features/container/intro/" target="_blank">
<i class="fa fa-cube"></i> Containers
</a>
</h2>
<p>
Expressive promotes and advocates the usage of Dependency Injection/Inversion of Control containers
when writing your applications. Expressive supports multiple containers which typehints against
<a href="https://github.com/container-interop/container-interop" target="_blank">container-interop</a>.
</p>
</div>
<div class="col-md-4">
<h2>
<a href="https://zendframework.github.io/zend-expressive/features/router/intro/" target="_blank">
<i class="fa fa-plane"></i> Routers
</a>
</h2>
<p>
One fundamental feature of zend-expressive is that it provides mechanisms for implementing dynamic
routing, a feature required in most modern web applications. Expressive ships with multiple adapters.
</p>
{% if routerName is defined %}
<p>
<a href="{{ routerDocs }}" target="_blank">
Get started with {{ routerName }}.
</a>
</p>
{% endif %}
</div>
<div class="col-md-4">
<h2>
<a href="https://zendframework.github.io/zend-expressive/features/template/twig/" target="_blank">
<i class="fa fa-files-o"></i> Templating
</a>
</h2>
<p>
By default, no middleware in Expressive is templated. We do not even provide a default templating
engine, as the choice of templating engine is often very specific to the project and/or organization.
However, Expressive does provide abstraction for templating, which allows you to write middleware that
is engine-agnostic.
</p>
{% if templateName is defined %}
<p>
<a href="{{ templateDocs }}" target="_blank">
Get started with {{ templateName }}.
</a>
</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -1,13 +1,17 @@
{% extends 'layout/default.html.twig' %}
{% block title %}404 Not Found{% endblock %}
{% block title %}URL Not Found{% endblock %}
{% block stylesheets %}
<style>
p {margin-bottom: 20px;}
body {text-align: center;}
</style>
{% endblock %}
{% block content %}
<h1>Oops!</h1>
<h2>This is awkward.</h2>
<p>We encountered a 404 Not Found error.</p>
<p>
You are looking for something that doesn't exist or may have moved. Check out one of the links on this page
or head back to <a href="{{ path('home') }}">Home</a>.
</p>
<hr>
<p>This short URL doesn't seem to be valid.</p>
<p>Make sure you included all the characters, with no extra punctuation.</p>
{% endblock %}

View file

@ -2,14 +2,19 @@
{% block title %}{{ status }} {{ reason }}{% endblock %}
{% block stylesheets %}
<style>
p {margin-bottom: 20px;}
body {text-align: center;}
</style>
{% endblock %}
{% block content %}
<h1>Oops!</h1>
<h2>This is awkward.</h2>
<hr>
<p>We encountered a {{ status }} {{ reason }} error.</p>
{% if status == 404 %}
<p>
You are looking for something that doesn't exist or may have moved. Check out one of the links on this page
or head back to <a href="{{ path('home') }}">Home</a>.
</p>
<p>This short URL doesn't seem to be valid.</p>
<p>Make sure you included all the characters, with no extra punctuation.</p>
{% endif %}
{% endblock %}

View file

@ -3,54 +3,19 @@
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>{% block title %}{% endblock %} - zend-expressive</title>
<title>{% block title %}{% endblock %} | URL shortener</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css" />
<style>
body { padding-top: 60px; }
.app { display: flex; min-height: 100vh; flex-direction: column; }
.app-content { flex: 1; }
.app-footer { padding-bottom: 1em; }
.zf-green, h2 a { color: #68b604; }
body {padding-top: 60px;}
.app {display: flex; min-height: 100vh; flex-direction: column;}
.app-content {flex: 1;}
.app-footer p {margin-bottom: 20px;}
</style>
{% block stylesheets %}{% endblock %}
</head>
<body class="app">
<header class="app-header">
<nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ path('home') }}">
<img src="{{ asset('zf-logo.png') }}" alt="Zend Expressive" />
</a>
</div>
<div class="collapse navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a href="https://zendframework.github.io/zend-expressive/" target="_blank">
<i class="fa fa-book"></i> Docs
</a>
</li>
<li>
<a href="https://github.com/zendframework/zend-expressive" target="_blank">
<i class="fa fa-wrench"></i> Contribute
</a>
</li>
<li>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="app-content">
<main class="container">
{% block content %}{% endblock %}
@ -62,7 +27,7 @@
<hr />
{% block footer %}
<p>
&copy; 2005 - {{ "now"|date("Y") }} by Zend Technologies Ltd. All rights reserved.
&copy; {{ "now" | date("Y") }} by <a href="http://www.alejandrocelaya.com">Alejandro Celaya</a>.
</p>
{% endblock %}
</div>

View file

@ -0,0 +1,46 @@
<?php
namespace AcelayaTest\UrlShortener\Factory;
use Acelaya\UrlShortener\Factory\CacheFactory;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use PHPUnit_Framework_TestCase as TestCase;
use Zend\ServiceManager\ServiceManager;
class CacheFactoryTest extends TestCase
{
/**
* @var CacheFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new CacheFactory();
}
public static function tearDownAfterClass()
{
putenv('APP_ENV');
}
/**
* @test
*/
public function productionReturnsApcAdapter()
{
putenv('APP_ENV=pro');
$instance = $this->factory->__invoke(new ServiceManager(), '');
$this->assertInstanceOf(ApcuCache::class, $instance);
}
/**
* @test
*/
public function developmentReturnsArrayAdapter()
{
putenv('APP_ENV=dev');
$instance = $this->factory->__invoke(new ServiceManager(), '');
$this->assertInstanceOf(ArrayCache::class, $instance);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace AcelayaTest\UrlShortener\Factory;
use Acelaya\UrlShortener\Factory\EntityManagerFactory;
use Doctrine\ORM\EntityManager;
use PHPUnit_Framework_TestCase as TestCase;
use Zend\ServiceManager\ServiceManager;
class EntityManagerFactoryTest extends TestCase
{
/**
* @var EntityManagerFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new EntityManagerFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$sm = new ServiceManager(['services' => [
'config' => [
'debug' => true,
'database' => [
'driver' => 'pdo_sqlite',
],
],
]]);
$em = $this->factory->__invoke($sm, EntityManager::class);
$this->assertInstanceOf(EntityManager::class, $em);
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace AcelayaTest\UrlShortener\Middleware;
use Acelaya\UrlShortener\Middleware\CliParamsMiddleware;
use PHPUnit_Framework_TestCase as TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Expressive\Router\RouteResult;
class CliParamsMiddlewareTest extends TestCase
{
/**
* @test
*/
public function nonCliRequestsJustInvokeNextMiddleware()
{
$middleware = new CliParamsMiddleware([], 'non-cli');
$invoked = false;
$originalResponse = new Response();
$response = $middleware->__invoke(
ServerRequestFactory::fromGlobals(),
$originalResponse,
function ($req, $resp) use (&$invoked) {
$invoked = true;
return $resp;
}
);
$this->assertSame($originalResponse, $response);
$this->assertTrue($invoked);
}
/**
* @test
*/
public function nonSuccessRouteResultJustInvokesNextMiddleware()
{
$middleware = new CliParamsMiddleware([], 'cli');
$invoked = false;
$originalResponse = new Response();
$routeResult = $this->prophesize(RouteResult::class);
$routeResult->isSuccess()->willReturn(false)->shouldBeCalledTimes(1);
$response = $middleware->__invoke(
ServerRequestFactory::fromGlobals()->withAttribute(RouteResult::class, $routeResult->reveal()),
$originalResponse,
function ($req, $resp) use (&$invoked) {
$invoked = true;
return $resp;
}
);
$this->assertSame($originalResponse, $response);
$this->assertTrue($invoked);
}
/**
* @test
*/
public function properRouteWillInjectAttributeInResponse()
{
$expectedLongUrl = 'http://www.google.com';
$middleware = new CliParamsMiddleware(['foo', 'bar', $expectedLongUrl], 'cli');
$invoked = false;
$originalResponse = new Response();
$routeResult = $this->prophesize(RouteResult::class);
$routeResult->isSuccess()->willReturn(true)->shouldBeCalledTimes(1);
$routeResult->getMatchedRouteName()->willReturn('cli-generate-shortcode')->shouldBeCalledTimes(1);
/** @var ServerRequestInterface $request */
$request = null;
$response = $middleware->__invoke(
ServerRequestFactory::fromGlobals()->withAttribute(RouteResult::class, $routeResult->reveal()),
$originalResponse,
function ($req, $resp) use (&$invoked, &$request) {
$invoked = true;
$request = $req;
return $resp;
}
);
$this->assertSame($originalResponse, $response);
$this->assertEquals($expectedLongUrl, $request->getAttribute('longUrl'));
$this->assertTrue($invoked);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace AcelayaTest\UrlShortener\Middleware\Factory;
use Acelaya\UrlShortener\Middleware\CliParamsMiddleware;
use Acelaya\UrlShortener\Middleware\Factory\CliParamsMiddlewareFactory;
use PHPUnit_Framework_TestCase as TestCase;
use Zend\ServiceManager\ServiceManager;
class CliParamsMiddlewareFactoryTest extends TestCase
{
/**
* @var CliParamsMiddlewareFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new CliParamsMiddlewareFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke(new ServiceManager(), '');
$this->assertInstanceOf(CliParamsMiddleware::class, $instance);
}
}

View file

@ -0,0 +1,136 @@
<?php
namespace AcelayaTest\UrlShortener\Service;
use Acelaya\UrlShortener\Entity\ShortUrl;
use Acelaya\UrlShortener\Service\UrlShortener;
use Doctrine\Common\Persistence\ObjectRepository;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMException;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Zend\Diactoros\Uri;
class UrlShortenerTest extends TestCase
{
/**
* @var UrlShortener
*/
protected $urlShortener;
/**
* @var ObjectProphecy
*/
protected $em;
/**
* @var ObjectProphecy
*/
protected $httpClient;
public function setUp()
{
$this->httpClient = $this->prophesize(ClientInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class);
$conn = $this->prophesize(Connection::class);
$conn->isTransactionActive()->willReturn(false);
$this->em->getConnection()->willReturn($conn->reveal());
$this->em->flush()->willReturn(null);
$this->em->commit()->willReturn(null);
$this->em->beginTransaction()->willReturn(null);
$this->em->persist(Argument::any())->will(function ($arguments) {
/** @var ShortUrl $shortUrl */
$shortUrl = $arguments[0];
$shortUrl->setId(10);
});
$repo = $this->prophesize(ObjectRepository::class);
$repo->findOneBy(Argument::any())->willReturn(null);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->urlShortener = new UrlShortener($this->httpClient->reveal(), $this->em->reveal());
}
/**
* @test
*/
public function urlIsProperlyShortened()
{
// 10 -> 12C1c
$shortCode = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
$this->assertEquals('12C1c', $shortCode);
}
/**
* @test
* @expectedException \Acelaya\UrlShortener\Exception\RuntimeException
*/
public function exceptionIsThrownWhenOrmThrowsException()
{
$conn = $this->prophesize(Connection::class);
$conn->isTransactionActive()->willReturn(true);
$this->em->getConnection()->willReturn($conn->reveal());
$this->em->rollback()->shouldBeCalledTimes(1);
$this->em->close()->shouldBeCalledTimes(1);
$this->em->flush()->willThrow(new ORMException());
$this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
}
/**
* @test
* @expectedException \Acelaya\UrlShortener\Exception\InvalidUrlException
*/
public function exceptionIsThrownWhenUrlDoesNotExist()
{
$this->httpClient->request(Argument::cetera())->willThrow(
new ClientException('', $this->prophesize(Request::class)->reveal())
);
$this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
}
/**
* @test
*/
public function whenShortUrlExistsItsShortcodeIsReturned()
{
$shortUrl = new ShortUrl();
$shortUrl->setShortCode('expected_shortcode');
$repo = $this->prophesize(ObjectRepository::class);
$repo->findOneBy(Argument::any())->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$shortCode = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
$this->assertEquals($shortUrl->getShortCode(), $shortCode);
}
/**
* @test
*/
public function shortCodeIsProperlyParsed()
{
// 12C1c -> 10
$shortUrl = new ShortUrl();
$shortUrl->setShortCode('12C1c')
->setOriginalUrl('expected_url');
$repo = $this->prophesize(ObjectRepository::class);
$repo->findOneBy(['shortCode' => '12C1c'])->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$url = $this->urlShortener->shortCodeToUrl('12C1c');
$this->assertEquals($shortUrl->getOriginalUrl(), $url);
}
/**
* @test
* @expectedException \Acelaya\UrlShortener\Exception\InvalidShortCodeException
*/
public function invalidCharSetThrowsException()
{
$this->urlShortener->shortCodeToUrl('&/(');
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace AcelayaTest\UrlShortener\Service;
use Acelaya\UrlShortener\Entity\ShortUrl;
use Acelaya\UrlShortener\Service\VisitsTracker;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityRepository;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
class VisitsTrackerTest extends TestCase
{
/**
* @test
*/
public function trackPersistsVisit()
{
$shortCode = '123ABC';
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl());
$em = $this->prophesize(EntityManager::class);
$em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
$em->persist(Argument::any())->shouldBeCalledTimes(1);
$em->flush()->shouldBeCalledTimes(1);
$visitsTracker = new VisitsTracker($em->reveal());
$visitsTracker->track($shortCode);
}
}