18/08/2018 - SYMFONY
This example is very similar to the previously one where we simplified the way of handling request, response and exceptions in Symfony APIs. The only difference is, we are not calling validation methods in controller anymore. It is done with an even listener. I personally prefer this version.
Make sure that the jms/serializer-bundle
and symfony/validator
composer packages are installed. Also don't forget to add JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true]
to bundles.php
file.
Every controller must extent this class.
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]
);
}
}
All you are interest below is getQueryString()
, getPayload()
and createResponse()
methods. Rest is just for demonstration purposes.
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);
}
}
When you have new request models, you must add them here.
parameters:
controllers:
AppleController:
actions:
create: 'App\Model\Apple\Create'
getAll: 'App\Model\Apple\QueryString'
Every request related models must implement this class.
declare(strict_types=1);
namespace App\Model;
interface RequestModelInterface
{
}
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;
}
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;
}
Every response related models must implement this class.
declare(strict_types=1);
namespace App\Model;
interface ResponseModelInterface
{
}
declare(strict_types=1);
namespace App\Model\Apple;
use App\Model\ResponseModelInterface;
class Result implements ResponseModelInterface
{
public $data;
public $createdAt;
}
Every custom exception class must extend this class.
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);
}
}
declare(strict_types=1);
namespace App\Exception;
class ValidationException extends AbstractApplicationException
{
}
This is the one that automatically handles request serialisation and validation for you.
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 }
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 }
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);
}
}
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]);
}
}
$ 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"
}