Bu örneğimizde API uygulamamıza kullanım kısıtlaması (rate limiting) özelliği ekleyip, kullanıcılara sadece verilen süre içinde toplam kullanım hakkı vereceğiz.


Notlar



Yapmalı


Aşağıdakiler şu an için aklıma gelenler ama size göre eksik olanlar varsa kendiniz eklemeler de yapabilirsiniz.



Çalışma prensibi


Eğer herhangi bir nedenden dolayı beklenmedik bir hata oluşursa, sistem içinde "An unknown error occurred." hata mesajı barındıran bir 404 Not Found hatası oluşturur.


  1. Kullanıcı istekte bulunur.

  2. Event listener isteğin yolunu keser ve doğrulamak için kotrol eder.

    • Eğer istek API için değilse, kontrolleri sonlandırır.

    • Eğer istek API içinse, kullanım kısıtlaması kontrolleri yapılır.

  3. Kullanıcı bilgileri aranır. Bulunamazsa hata mesajı oluşturulur.

  4. Eğer kullanıcı hakkını doldurmuş ise, hata mesajı oluşturulur.

  5. Kullanımına 1 eklenip, kullanıcı isteğine cevap verilir.

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

Hiç problem yaşamadan http://rate-limiter.dev/app_dev.php/ adresine istediğiniz kadar ulaşabilirsiniz.


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"

Veritabanı


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)

Testler


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.