<?php
namespace Can\RestBundle\EventListener;
use Can\RestBundle\CanRestBundle;
use Can\RestBundle\Incident\IncidentReporterInterface;
use Can\RestBundle\Mapping\MetadataStoreInterface;
use Can\RestBundle\RateLimit\RateLimit;
use Can\RestBundle\RateLimit\RateLimitAbusedException;
use Can\RestBundle\RateLimit\RateLimitConfiguration;
use Can\RestBundle\RateLimit\RateLimitConstraint;
use Can\RestBundle\RateLimit\RateLimitEntry;
use Can\RestBundle\RateLimit\RateLimitEventDispatcher;
use Can\RestBundle\RateLimit\RateLimitExceededException;
use Can\RestBundle\RateLimit\RateLimitHandler;
use Can\RestBundle\RateLimit\RateLimitPolicy;
use Can\RestBundle\RateLimit\RateLimitRegistry;
use Can\RestBundle\URI\ServiceNode;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
/**
* @group ratelimit
*
* @package can/rest-bundle
* @author lechecacharro <lechecacharro@gmail.com>
*/
class RateLimitListener
{
/**
* @var RateLimitConfiguration
*/
private $configuration;
/**
* @var RateLimitHandler
*/
private $handler;
/**
* @var RateLimitEventDispatcher
*/
private $dispatcher;
/**
* @var RateLimitRegistry
*/
private $registry;
/**
* @var MetadataStoreInterface
*/
private $metadataStore;
/**
* @var IncidentReporterInterface
*/
private $reporter;
/**
* RateLimitListener constructor.
*
* @param RateLimitConfiguration $configuration
* @param RateLimitHandler $handler
* @param RateLimitEventDispatcher $dispatcher
* @param RateLimitRegistry $registry
* @param MetadataStoreInterface $metadataStore
* @param IncidentReporterInterface $reporter
*/
public function __construct(
RateLimitConfiguration $configuration,
RateLimitHandler $handler,
RateLimitEventDispatcher $dispatcher,
RateLimitRegistry $registry,
MetadataStoreInterface $metadataStore,
IncidentReporterInterface $reporter
)
{
$this->configuration = $configuration;
$this->handler = $handler;
$this->dispatcher = $dispatcher;
$this->registry = $registry;
$this->metadataStore = $metadataStore;
$this->reporter = $reporter;
}
/**
* @param RequestEvent $event
*/
public function onRequest(RequestEvent $event): void
{
if (! $this->configuration->isEnabled()) {
return;
}
// Do process only the master request
if (! $event->isMasterRequest()) {
return;
}
$request = $event->getRequest();
if (! $request->attributes->get(CanRestBundle::ATTR_SERVICE_ZONE)) {
return;
}
// Determine the applicable rate limit, according to the registry
// and the current configuration, but allow third-parties to modify
// the determined rate limit
$rateLimit = $this->dispatcher->dispatchGetRateLimit($request, $this->getRateLimit($request))->getRateLimit();
// Skip if the endpoint is not rate-limited
if (null === $rateLimit || $rateLimit->isUnspecified() || $rateLimit->isUnlimited()) {
$request->attributes->set(CanRestBundle::ATTR_RATE_LIMIT_CONSTRAINT, RateLimitConstraint::NONE);
return;
}
// Handle current rate limit
$rateLimitEntry = $this->handler->handle($request, $rateLimit);
$rateLimitInfo = $rateLimitEntry->getRateLimitInfo();
$request->attributes->set(CanRestBundle::ATTR_RATE_LIMIT_INFO, $rateLimitInfo);
$request->attributes->set(CanRestBundle::ATTR_RATE_LIMIT_KEY, $rateLimitEntry->getKey());
if ($rateLimitInfo->isRateLimitExceeded()) {
// Notify listeners *first*
$this->dispatcher->dispatchRateLimitsExceeded($request, $rateLimitEntry);
switch ($this->configuration->getPolicy()) {
case RateLimitPolicy::REPORT:
$this->reportRateLimitExceeded($request, $rateLimitEntry);
break;
case RateLimitPolicy::REDIRECT:
$this->redirectRateLimitExceeded($request, $rateLimitEntry);
break;
case RateLimitPolicy::THROW:
$this->throwRateLimitExceeded($request, $rateLimitEntry);
break;
case RateLimitPolicy::BLOCK:
$this->blockRateLimitExceeded($request, $rateLimitEntry);
break;
}
}
}
/**
* @param Request $request
* @param RateLimitEntry $rateLimitEntry
*
* @return RateLimit
*/
protected function redirectRateLimitExceeded(Request $request, RateLimitEntry $rateLimitEntry): RateLimit
{
}
/**
* @param Request $request
* @param RateLimitEntry $rateLimitEntry
*
* @return RateLimit
*/
protected function reportRateLimitExceeded(Request $request, RateLimitEntry $rateLimitEntry): RateLimit
{
}
/**
* Throws a {@link RateLimitExceededHttpException} and maybe blocks the
* consumer, if it already exceeded the {@link blockAfter} counter.
*
* @param Request $request
* @param RateLimitEntry $rateLimitEntry
*
* @return RateLimit
*/
protected function throwRateLimitExceeded(Request $request, RateLimitEntry $rateLimitEntry): RateLimit
{
$blockAfter = $this->configuration->getBlockAfter();
if ($blockAfter > 0 && $rateLimitEntry->getRateLimitInfo()->isRateLimitAbused($blockAfter)) {
// Dispatch a rate limit abused event *first*
$this->dispatcher->dispatchRateLimitsAbused($request, $rateLimitEntry, $blockAfter);
throw new RateLimitAbusedException($rateLimitEntry->getRateLimitInfo(), $blockAfter);
} else {
throw new RateLimitExceededException($rateLimitEntry->getRateLimitInfo());
}
}
/**
* Throws a {@link RateLimitAbusedException} and blocks the consumer.
*
* @param Request $request
* @param RateLimitEntry $rateLimitEntry
*
* @return RateLimit
*/
protected function blockRateLimitExceeded(Request $request, RateLimitEntry $rateLimitEntry): RateLimit
{
$blockAfter = $this->configuration->getBlockAfter();
// Dispatch a rate limit abused event *first*
$this->dispatcher->dispatchRateLimitsAbused($request, $rateLimitEntry, $blockAfter);
throw new RateLimitAbusedException($rateLimitEntry->getRateLimitInfo(), $blockAfter);
}
/**
* @param Request $request
*
* @return RateLimit
*/
protected function getRateLimit(Request $request): RateLimit
{
// If someone has already determined the rate limit for the
// current request, then use that value
if (null !== $rateLimit = $request->attributes->get(CanRestBundle::ATTR_RATE_LIMIT)) {
$request->attributes->set(CanRestBundle::ATTR_RATE_LIMIT_CONSTRAINT, RateLimitConstraint::EXTERNAL);
return $rateLimit;
}
// Else, try to figure out the rate limit from the resource
// metadata which corresponds to the requested service node
// Start with the service default limit, and if, there's an
// entry which is more specific then override
$rateLimit = $this->configuration->getDefault();
$request->attributes->set(CanRestBundle::ATTR_RATE_LIMIT_CONSTRAINT, RateLimitConstraint::SERVICE);
/** @var ServiceNode $serviceNode */
$serviceNode = $request->attributes->get(CanRestBundle::ATTR_SERVICE_NODE);
$versionNumber = $serviceNode->getVersionNumber();
if (! empty($versionNumber) && $this->metadataStore->hasVersionMetadata($versionNumber)) {
$resourceName = $serviceNode->getResourceName();
$metadata = $this->metadataStore->getVersionMetadata($versionNumber);
$versionRateLimit = new RateLimit(
$metadata->getRateLimit(),
$metadata->getRateLimitWindow()
);
if (! $versionRateLimit->isUnspecified()) {
$rateLimit = $versionRateLimit;
$request->attributes->set(CanRestBundle::ATTR_RATE_LIMIT_CONSTRAINT, RateLimitConstraint::VERSION);
}
if (! empty($resourceName) && $metadata->getResources()->hasItem($resourceName)) {
$metadata = $metadata->getResources()->getItem($resourceName);
$resourceRateLimit = new RateLimit(
$metadata->getRateLimit(),
$metadata->getRateLimitWindow()
);
if (! $resourceRateLimit->isUnspecified()) {
$rateLimit = $resourceRateLimit;
$request->attributes->set(CanRestBundle::ATTR_RATE_LIMIT_CONSTRAINT, RateLimitConstraint::RESOURCE);
}
}
return $rateLimit;
}
// As fallback, check the registry
$request->attributes->set(CanRestBundle::ATTR_RATE_LIMIT_CONSTRAINT, RateLimitConstraint::FALLBACK);
return $this->registry->getRateLimit($request);
}
}