In this example we're going to limit our API with rate limiting feature where users will be consuming API maximum of given times.


Notes



Should Have


These are what I can think of for now so if you see something is missing then go ahead to implement.



How It Works


If anything unexpected happens along the way, 404 Not Found exception is thrown containing "An unknown error occurred." message.


  1. User request comes in.

  2. Event listener intercepts request and validates it.

    • If the request is not for API then don't intercept the request. Terminate.

    • If the request is for API then apply rate limiting checks.

  3. Try to find user record. If not found, throw an exception. Terminate.

  4. If user exceeded allowed rate limit, throw an exception. Terminate.

  5. Update total calls by 1 and let user consume API.

Routing.yml


api:
resource: "@ApiBundle/Controller/"
type: annotation
prefix: /1/

website:
resource: "@WebsiteBundle/Controller/"
type: annotation
prefix: /

WebsiteBundle


DefaultController


namespace Application\WebsiteBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Component\HttpFoundation\Response;

/**
* @Route("", service="website.controller.default")
*/
class DefaultController extends Controller
{
/**
* @Method({"GET"})
* @Route("")
*
* @return Response
*/
public function indexAction()
{
return new Response('You are not consuming the API...');
}
}

services:
website.controller.default:
class: Application\WebsiteBundle\Controller\DefaultController

You can call http://rate-limiter.dev/app_dev.php/ as many times as you wish without a problem.


ApiBundle


AbstractController


namespace Application\ApiBundle\Controller;

abstract class AbstractController
{
}

DefaultController


namespace Application\ApiBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Component\HttpFoundation\Response;

/**
* @Route("", service="api.controller.default")
*/
class DefaultController extends AbstractController
{
/**
* @Method({"GET"})
* @Route("")
*
* @return Response
*/
public function indexAction()
{
return new Response('You are consuming the API...');
}
}

AccountController


namespace Application\ApiBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Component\HttpFoundation\Response;

/**
* @Route("/account", service="api.controller.account")
*/
class AccountController extends AbstractController
{
/**
* @Method({"GET"})
* @Route("")
*
* @return Response
*/
public function indexAction()
{
return new Response('You are consuming the API...');
}
}

services:
api.controller.default:
class: Application\ApiBundle\Controller\DefaultController

api.controller.account:
class: Application\ApiBundle\Controller\AccountController

User


namespace Application\ApiBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity(repositoryClass="Application\ApiBundle\Repository\UserRepository")
* @ORM\Table(name="user",
* uniqueConstraints={
* @ORM\UniqueConstraint(name="username_unq", columns={"username"})
* }
* )
*/
class User
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
* @ORM\Column(name="`username`", type="string", length=100)
*/
private $username;

/**
* @ORM\OneToOne(targetEntity="RateLimit", cascade={"persist", "remove"})
* @ORM\JoinColumn(name="rate_limit_id", referencedColumnName="id")
*/
private $rateLimit;

...
}

RateLimit


namespace Application\ApiBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity
* @ORM\Table(name="rate_limit")
*/
class RateLimit
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
* @ORM\Column(
* name="`limit`",
* type="integer",
* options={"comment":"Max number of allowed calls in given period of time."}
* )
*/
private $limit;
/**
* @ORM\Column(
* name="`total_call`",
* type="integer",
* options={"comment":"Current total calls in given period of time."}
* )
*/
private $totalCall = 0;

...
}

RateLimitException


namespace Application\ApiBundle\Exception;

use RuntimeException;

class RateLimitException extends RuntimeException
{
}

UserRepository


namespace Application\ApiBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;

class UserRepository extends EntityRepository
{
public function findOneById($id)
{
return $this->createQueryBuilder('u')
->select('r.id, r.limit, r.totalCall')
->innerJoin('u.rateLimit', 'r')
->where('u.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult(Query::HYDRATE_ARRAY);
}
}

services:
api.repository.user:
class: Application\ApiBundle\Repository\UserRepository
factory: [ "@doctrine.orm.entity_manager", getRepository ]
arguments:
- Application\ApiBundle\Entity\User

ControllerListener


namespace Application\ApiBundle\EventListener;

use Application\ApiBundle\Controller\AbstractController;
use Application\ApiBundle\Exception\RateLimitException;
use Application\ApiBundle\Util\RateLimiter;
use Exception;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ControllerListener
{
private $rateLimiter;

public function __construct(RateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}

public function onKernelController(FilterControllerEvent $event)
{
if (!$event->isMasterRequest()) {
return;
}

$controller = $event->getController();
if (!is_array($controller)) {
return;
}

if (!$controller[0] instanceof AbstractController) {
return;
}

try {
$this->rateLimiter->check(1);
} catch (RateLimitException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (Exception $e) {
throw new NotFoundHttpException('An unknown error occurred.');
}
}
}

services:
api.listener.controller:
class: Application\ApiBundle\EventListener\ControllerListener
tags:
- { name: kernel.event_listener, event: kernel.controller, method: onKernelController }
arguments:
- "@api.util.rate_limiter"

RateLimiter


namespace Application\ApiBundle\Util;

use Application\ApiBundle\Entity\RateLimit;
use Application\ApiBundle\Exception\RateLimitException;
use Application\ApiBundle\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;

class RateLimiter
{
private $userRepository;
private $entityManager;

public function __construct(
UserRepository $userRepository,
EntityManagerInterface $entityManager
) {
$this->userRepository = $userRepository;
$this->entityManager = $entityManager;
}

public function check($userId)
{
$userRateLimit = $this->userRepository->findOneById($userId);
if (is_null($userRateLimit)) {
throw new RateLimitException('Rate limit not found');
}

if ($userRateLimit['totalCall'] >= $userRateLimit['limit']) {
throw new RateLimitException('Rate limit exceeded.');
}

$this->updateTotalCall($userRateLimit);
}

private function updateTotalCall(array $userRateLimit)
{
/** @var RateLimit $rateLimit */
$rateLimit = $this->entityManager->getReference(RateLimit::class, $userRateLimit['id']);
$rateLimit->setTotalCall($userRateLimit['totalCall']+1);

$this->entityManager->flush();
}
}

services:
api.util.rate_limiter:
class: Application\ApiBundle\Util\RateLimiter
arguments:
- "@api.repository.user"
- "@doctrine.orm.entity_manager"

Database


mysql> SELECT
-> `user`.`id` AS `UserID`,
-> `user`.`username` AS `UserUsername`,
-> `rate_limit`.`limit` AS `Limit`,
-> `rate_limit`.`total_call` AS `TotalCall`
-> FROM `user`
-> LEFT JOIN `rate_limit` ON `user`.`rate_limit_id` = `rate_limit`.`id`;
+--------+----------------+-------+-----------+
| UserID | UserUsername | Limit | TotalCall |
+--------+----------------+-------+-----------+
| 1 | AlPacino | 10 | 0 | # Can make maximum 10 calls at any time
| 2 | AndyGarcia | 2 | 2 | # Cannot make any more calls as TotalCall equals to Limit
| 3 | RobertDeNiro | NULL | NULL | # Cannot make any calls because Rate Limit data is not set yet
+--------+----------------+-------+-----------+
3 rows in set (0.00 sec)

Tests


AlPacino


# REQUEST
http://rate-limit.dev/app_dev.php/1/
http://rate-limit.dev/app_dev.php/1/account

# RESPONSE
200 OK
You are consuming the API...

AndyGarcia


# REQUEST
http://rate-limit.dev/app_dev.php/1/
http://rate-limit.dev/app_dev.php/1/account

# RESPONSE
400 Bad Request
Rate limit exceeded.

RobertDeNiro


# REQUEST
http://rate-limit.dev/app_dev.php/1/
http://rate-limit.dev/app_dev.php/1/account

# RESPONSE
400 Bad Request
Rate limit not found.