Bu örnek, bir önceki request, response ve exception yönetimi yaptığımız örneğe çok benziyor. Aradaki tek fark, controller içinde manuel olarak istek doğrulama metodunu kullanmıyoruz. Bu işlem bir event listener ile yapılıyor. Ben şahsen bu örneği kullanırım.


Önkoşullar


Composer ile jms/serializer-bundle ve symfony/validator paketlerini yükleyin. Ayrıca bundles.php dosyasına JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true] ekini ekleyin.


AbstractController


Tüm controller classları bu classı kullanmalılar.


declare(strict_types=1);

namespace App\Controller;

use App\Model\RequestModelInterface;
use App\Model\ResponseModelInterface;
use App\Util\Request\SerializerUtilInterface;
use Symfony\Component\HttpFoundation\Response;

abstract class AbstractController
{
public const REQUEST_CONTENT_TYPE = 'application/json';
public const RESPONSE_FORMAT = 'json';

private $serializerUtil;
private $queryString;
private $payload;

public function __construct(SerializerUtilInterface $serializerUtil)
{
$this->serializerUtil = $serializerUtil;
}

public function setQueryString(?RequestModelInterface $queryString): void
{
$this->queryString = $queryString;
}

protected function getQueryString(): ?RequestModelInterface
{
return $this->queryString;
}

public function setPayload(?RequestModelInterface$payload): void
{
$this->payload = $payload;
}

protected function getPayload(): ?RequestModelInterface
{
return $this->payload;
}

protected function createResponse(?ResponseModelInterface $content, int $status = Response::HTTP_OK): Response
{
return new Response(
$this->serializerUtil->serializeResponseData($content),
$status,
['Content-Type' => self::REQUEST_CONTENT_TYPE]
);
}
}

AppleController


Burada sizi ilgilendiren tek şey getQueryString(), getPayload() ve createResponse() metodlarıdır. Geriye kalan sadece gösteri amaçlıdır.


declare(strict_types=1);

namespace App\Controller;

use App\Model\Apple\Result;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Response;

/**
* @Route("/apples")
*/
class AppleController extends AbstractController
{
/**
* @Route("", methods="GET")
*/
public function getAll(): Response
{
$result = new Result();
$result->data = $this->getQueryString();
$result->createdAt = date('H:i:s');

return $this->createResponse($result);
}

/**
* @Route("", methods="POST")
*/
public function create(): Response
{
$result = new Result();
$result->data = $this->getPayload();
$result->createdAt = date('H:i:s');

return $this->createResponse($result);
}
}

Modeller


Konfigürasyon


Yeni istek modelleriniz olduğunda onları buraya eklemelisiniz.



parameters:
controllers:
AppleController:
actions:
create: 'App\Model\Apple\Create'
getAll: 'App\Model\Apple\QueryString'

RequestModelInterface


Request ile alakalı tüm modeller bunu kullanmalılar.


declare(strict_types=1);

namespace App\Model;

interface RequestModelInterface
{
}

QueryString


declare(strict_types=1);

namespace App\Model\Apple;

use App\Model\RequestModelInterface;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;

class QueryString implements RequestModelInterface
{
/**
* @Assert\NotBlank(message="Required field.")
*
* @Serializer\Type("integer")
*/
public $page;

/**
* @Serializer\Type("integer")
*/
public $limit;
}

Create


declare(strict_types=1);

namespace App\Model\Apple;

use App\Model\RequestModelInterface;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;

class Create implements RequestModelInterface
{
/**
* @Assert\NotBlank(message="Required field.")
*
* @Serializer\Type("string")
*/
public $name;

/**
* @Serializer\Type("string")
*/
public $colour;
}

ResponseModelInterface


Response ile alakalı tüm modeller bunu kullanmalılar.


declare(strict_types=1);

namespace App\Model;

interface ResponseModelInterface
{
}

Result


declare(strict_types=1);

namespace App\Model\Apple;

use App\Model\ResponseModelInterface;

class Result implements ResponseModelInterface
{
public $data;

public $createdAt;
}

Exceptions


AbstractApplicationException


Kendi yarattığınız tüm exception classlar bunu kullanmalılar.


declare(strict_types=1);

namespace App\Exception;

use Exception;
use Symfony\Component\HttpKernel\Exception\HttpException;

abstract class AbstractApplicationException extends HttpException
{
public function __construct(string $message, int $code, Exception $previous = null)
{
parent::__construct($code, $message, $previous);
}
}

ValidationException


declare(strict_types=1);

namespace App\Exception;

class ValidationException extends AbstractApplicationException
{
}

Listeners


ControllerListener


İstekleri otomatik olarak seri hale getirme ve doğrulama işlemi burada gerçekleşiyor.


declare(strict_types=1);

namespace App\Event\Listener;

use App\Controller\AbstractController;
use App\Util\Request\SerializerUtilInterface;
use App\Util\Request\ValidatorUtilInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;

class ControllerListener
{
private $serializerUtil;
private $validatorUtil;

public function __construct(
SerializerUtilInterface $serializerUtil,
ValidatorUtilInterface $validatorUtil
) {
$this->serializerUtil = $serializerUtil;
$this->validatorUtil = $validatorUtil;
}

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

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

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

$request = $event->getRequest();

$this->validatorUtil->validateContentType($request->headers->get('content_type'));

$queryStringData = $this->serializerUtil->deserializeQueryString($request, $controller[0]);
$payloadData = $this->serializerUtil->deserializePayload($request, $controller[0]);

if ($queryStringData) {
$this->validatorUtil->validateRequestData($queryStringData);
}

if ($payloadData) {
$this->validatorUtil->validateRequestData($payloadData);
}

$controller[0]->setQueryString($queryStringData);
$controller[0]->setPayload($payloadData);
}
}

services:
App\Event\Listener\ControllerListener:
tags:
- { name: kernel.event_listener, event: kernel.controller, method: onKernelController }

ExceptionListener


declare(strict_types=1);

namespace App\Event\Listener;

use App\Controller\AbstractController;
use App\Exception\AbstractApplicationException;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;

class ExceptionListener
{
private $logger;

public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}

public function onKernelException(GetResponseForExceptionEvent $event): void
{
if ($event->getException() instanceof AbstractApplicationException) {
$response = $this->handleKnownExceptions($event->getException());
} else {
$response = $this->handleUnknownExceptions($event->getException());
}

$event->setResponse($response);
}

private function handleKnownExceptions(Exception $exception): Response
{
$header = [];

if (Response::HTTP_BAD_REQUEST === $exception->getStatusCode()) {
$header = ['Content-Type' => AbstractController::REQUEST_CONTENT_TYPE];
} else {
$this->logger->error($exception);
}

return new Response($exception->getMessage(), $exception->getStatusCode(), $header);
}

private function handleUnknownExceptions(Exception $exception): Response
{
$this->logger->error($exception);

return new Response('An unknown exception occurred.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}

services:
App\Event\Listener\ExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }

SerializerUtil


declare(strict_types=1);

namespace App\Util\Request;

use App\Controller\AbstractController;
use App\Model\RequestModelInterface;
use App\Model\ResponseModelInterface;
use Symfony\Component\HttpFoundation\Request;

interface SerializerUtilInterface
{
public function deserializeQueryString(Request $request, AbstractController $controller): ?RequestModelInterface;

public function deserializePayload(Request $request, AbstractController $controller): ?RequestModelInterface;

public function serializeResponseData(?ResponseModelInterface $content): string;
}

declare(strict_types=1);

namespace App\Util\Request;

use App\Controller\AbstractController;
use App\Model\RequestModelInterface;
use App\Model\ResponseModelInterface;
use Exception;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\SerializerInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\HttpFoundation\Request;

class SerializerUtil implements SerializerUtilInterface
{
private $serializer;
private $controllers;

public function __construct(
SerializerInterface $serializer,
iterable $controllers
) {
$this->serializer = $serializer;
$this->controllers = $controllers;
}

public function deserializeQueryString(Request $request, AbstractController $controller): ?RequestModelInterface
{
$data = null;

if ($request->query->all()) {
$data = $this->createRequestModel(
$this->getRequstModelName($request, $controller),
json_encode($request->query->all())
);
}

return $data;
}

public function deserializePayload(Request $request, AbstractController $controller): ?RequestModelInterface
{
$data = null;

if ($request->getContent()) {
$data = $this->createRequestModel(
$this->getRequstModelName($request, $controller),
$request->getContent()
);
}

return $data;
}

public function serializeResponseData(?ResponseModelInterface $content): string
{
$context = new SerializationContext();
$context->setSerializeNull(true);

return $this->serializer->serialize($content, AbstractController::RESPONSE_FORMAT, $context);
}

private function getRequstModelName(Request $request, AbstractController $controller): ?string
{
$parts = explode('\\', get_class($controller));
$controllerName = end($parts);
$parts = explode('::', $request->attributes->get('_controller'));
$actionName = end($parts);

try {
return $this->controllers[$controllerName]['actions'][$actionName];
} catch (Exception $e) {
throw new InvalidConfigurationException(sprintf(
'Configuration is missing for %s::%s',
$controllerName,
$actionName
));
}
}

private function createRequestModel(string $requestModelName, string $data): RequestModelInterface
{
return $this->serializer->deserialize($data, $requestModelName, AbstractController::RESPONSE_FORMAT);
}
}

ValidatorUtil


declare(strict_types=1);

namespace App\Util\Request;

use App\Model\RequestModelInterface;

interface ValidatorUtilInterface
{
public function validateContentType(?string $contentType): void;

public function validateRequestData(RequestModelInterface $model): void;
}

declare(strict_types=1);

namespace App\Util\Request;

use App\Controller\AbstractController;
use App\Exception\ValidationException;
use App\Model\RequestModelInterface;
use Doctrine\Common\Inflector\Inflector;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class ValidatorUtil implements ValidatorUtilInterface
{
private $validator;

public function __construct(ValidatorInterface $validator)
{
$this->validator = $validator;
}

public function validateContentType(?string $contentType): void
{
if ($contentType && AbstractController::REQUEST_CONTENT_TYPE !== $contentType) {
throw new ValidationException(
'Invalid content type header.',
Response::HTTP_UNSUPPORTED_MEDIA_TYPE
);
}
}

public function validateRequestData(RequestModelInterface $model): void
{
$errors = $this->validator->validate($model);
if ($errors->count() > 0) {
throw new ValidationException($this->createErrorMessage($errors), Response::HTTP_BAD_REQUEST);
}
}

private function createErrorMessage(ConstraintViolationListInterface $violations): string
{
$errors = [];

/** @var ConstraintViolation $violation */
foreach ($violations as $violation) {
$errors[Inflector::tableize($violation->getPropertyPath())] = $violation->getMessage();
}

return json_encode(['errors' => $errors]);
}
}

Testler


$ curl -i -X POST \
> http://192.168.99.20:81/api/v1/apples \
> -d '{
> "name": "Pink Lady",
> "colour": "pink"
> }'

HTTP/1.1 415 Unsupported Media Type
Content-Type: text/html; charset=UTF-8

Invalid content type header

$ curl -i -X POST \
> http://192.168.99.20:81/api/v1/apples \
> -H 'Content-Type: application/json' \
> -d ''

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": null,
"created_at": "21:27:03"
}

$ curl -i -X POST \
> http://192.168.99.20:81/api/v1/apples \
> -H 'Content-Type: application/json' \
> -d '{
> "name": "Pink Lady",
> "colour": "pink"
> }'

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": {
"name": "Pink Lady",
"colour": "pink"
},
"created_at": "21:26:03"
}

$ curl -i -X POST \
> http://192.168.99.20:81/api/v1/apples \
> -H 'Content-Type: application/json' \
> -d '{
> "name": "",
> "colour": "pink"
> }'

HTTP/1.1 400 Bad Request
Content-Type: application/json

{"errors":{"name":"Required field."}}


$ curl -i -X GET \
> 'http://192.168.99.20:81/api/v1/apples' \
> -H 'Content-Type: application/xml'

HTTP/1.1 415 Unsupported Media Type
Content-Type: text/html; charset=UTF-8

Invalid content type header.

$ curl -i -X GET \
> 'http://192.168.99.20:81/api/v1/apples'

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": null,
"created_at": "21:30:37"
}

$ curl -i -X GET \
> 'http://192.168.99.20:81/api/v1/apples?page=1&limit=10'

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": {
"page": 1,
"limit": 10
},
"created_at": "21:30:00"
}