This example can be used for json Symfony APIs where request, response and exceptions are handled in a simple way. The main aim of this example is to reduce manual processes for validating requests and returning appropriate responses for valid and invalid requests as well as exceptions.


Prerequisite


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.


AbstractController


Every controller must extent this class.


declare(strict_types=1);

namespace App\Controller;

use App\Exception\ValidationException;
use App\Model\ResponseInterface;
use Doctrine\Common\Inflector\Inflector;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\SerializerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

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

protected $data;
private $serializer;
private $validator;

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

protected function validateContentType(string $contentType): void
{
if (self::CONTENT_TYPE !== $contentType) {
throw new ValidationException(
'Invalid content type header.',
Response::HTTP_UNSUPPORTED_MEDIA_TYPE
);
}
}

protected function validateRequestData(string $data, string $model)
{
$this->data = $this->serializer->deserialize($data, $model, self::RESPONSE_FORMAT);

$errors = $this->validator->validate($this->data);
if ($errors->count() > 0) {
throw new ValidationException($this->createErrorMessage($errors), Response::HTTP_BAD_REQUEST);
}
}

protected function createResponse(ResponseInterface $content = null, int $status = Response::HTTP_OK)
{
$context = new SerializationContext();
$context->setSerializeNull(false);

$content = $this->serializer->serialize($content, self::RESPONSE_FORMAT, $context);

return new Response($content, $status, ['Content-Type' => self::CONTENT_TYPE]);
}

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]);
}
}

UserController


The testKnownExceptions, testUnknownExceptions and get methods are just testing purposes. The get is there for you to see how query string parameters are mapped to a model class and validated.


declare(strict_types=1);

namespace App\Controller;

use App\Exception\UserException;
use App\Model\User\Create;
use App\Model\User\Result;
use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
* @Route("/users")
*/
class UserController extends AbstractController
{
/**
* @Route("/test-known-exceptions", methods="GET")
*/
public function testKnownExceptions(): void
{
throw new UserException('Just testing known exceptions.', Response::HTTP_CONFLICT);
}

/**
* @Route("/test-unknown-exceptions", methods="GET")
*/
public function testUnknownExceptions(): void
{
throw new RuntimeException('Just testing unknown exceptions.', Response::HTTP_CONFLICT);
}

/**
* @Route("", methods="POST")
*/
public function create(Request $request): Response
{
$this->validateContentType($request->headers->get('content_type'));
$this->validateRequestData($request->getContent(), Create::class);

// If all go well you have model representation of the request in $this->data variable.
// Assume that you passed it to a relevant class where the record is created in database.
// As a result you should either return null or a resulting model that implements ResponseInterface.

$result = new Result();
$result->id = 123;
$result->fullName = $this->data->fullName;

return $this->createResponse($result, Response::HTTP_CREATED);
}

/**
* @Route("", methods="GET")
*/
public function get(Request $request): Response
{
$this->validateRequestData(json_encode($request->query->all()), Create::class);

// If all go well you have model representation of the request in $this->data variable.
// Assume that you passed it to a relevant class where the record is created in database.
// As a result you should either return null or a resulting model that implements ResponseInterface.

$result = new Result();
$result->id = 123;
$result->fullName = $this->data->fullName;

return $this->createResponse($result, Response::HTTP_CREATED);
}
}

Models


Create


declare(strict_types=1);

namespace App\Model\User;

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

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

/**
* @var string
*
* @Assert\NotBlank(message="Required field.")
*
* @Serializer\Type("string")
*/
public $password;

/**
* @var string
*
* @Assert\NotBlank(message="Required field.")
*
* @Serializer\Type("string")
*/
public $fullName;
}

Result


declare(strict_types=1);

namespace App\Model\User;

use App\Model\ResponseInterface;

class Result implements ResponseInterface
{
/**
* @var int
*/
public $id;

/**
* @var string
*/
public $fullName;
}

ResponseInterface


All your response model classes should extend this interface as seen above.


declare(strict_types=1);

namespace App\Model;

interface ResponseInterface
{
}

ExceptionListener


If validation fails or a normal response is to be returned then the response body is a json string (application/json). If an exception is occured then the response body is a simple text string (text/html).


declare(strict_types=1);

namespace App\Event\Listener;

use App\Controller\AbstractController;
use App\Exception\ApplicationException;
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 ApplicationException) {
$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::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 }

ApplicationException


All your custom exceptions should extends this class as seen below.


declare(strict_types=1);

namespace App\Exception;

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

abstract class ApplicationException 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 ApplicationException
{
}

UserException


declare(strict_types=1);

namespace App\Exception;

class UserException extends ApplicationException
{
}

Tests


In this example the content type is invalid because only json is acceptable.


curl -i -X POST \
http://192.168.99.20:81/api/v1/users \
-H 'Content-Type: application/xml' \
-d '{
"username": "u",
"password": "p",
"full_name": "f"
}'

HTTP/1.1 415 Unsupported Media Type
Server: nginx/1.6.2
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Thu, 16 Aug 2018 20:35:07 GMT

Invalid content type header.

This is what log file contains.


app.ERROR: App\Exception\ValidationException: Invalid content type header. in /srv/www/api/src/Controller/AbstractController.php:34
Stack trace: #0 /srv/www/api/src/Controller/UserController.php(41): App\Controller\AbstractController->validateContentType('application/xml')

In this example the request payload is invalid because the username and full_name fields are empty. No log taken because I don't care what mistakes users do in request payload.


curl -i -X POST \
http://192.168.99.20:81/api/v1/users \
-H 'Content-Type: application/json' \
-d '{
"username": "",
"password": "p",
"full_name": ""
}'

HTTP/1.1 400 Bad Request
Server: nginx/1.6.2
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Thu, 16 Aug 2018 20:41:15 GMT

{"errors":{"username":"Required field.","full_name":"Required field."}}

Assume that we are catching an exception and throwing one of our custom exception.


curl -i -X GET http://192.168.99.20:81/api/v1/users/test-known-exceptions

HTTP/1.1 409 Conflict
Server: nginx/1.6.2
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Thu, 16 Aug 2018 20:46:05 GMT

Just testing known exceptions.

This is what log file contains.


app.ERROR: App\Exception\UserException: Just testing known exceptions. in /srv/www/api/src/Controller/UserController.php:25
Stack trace: #0 /srv/www/api/vendor/symfony/http-kernel/HttpKernel.php(149): App\Controller\UserController->testKnownExceptions()

Assume that we are not catching an exception because we are not aware of it at all.


curl -i -X GET http://192.168.99.20:81/api/v1/users/test-unknown-exceptions

HTTP/1.1 500 Internal Server Error
Server: nginx/1.6.2
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Thu, 16 Aug 2018 20:58:41 GMT

An unknown exception occurred.

This is what log file contains.


app.ERROR: RuntimeException: Just testing unknown exceptions. in /srv/www/api/src/Controller/UserController.php:33
Stack trace: #0 /srv/www/api/vendor/symfony/http-kernel/HttpKernel.php(149): App\Controller\UserController->testUnknownExceptions()

Assume that the request is valid.


curl -i -X POST \
http://192.168.99.20:81/api/v1/users \
-H 'Content-Type: application/json' \
-d '{
"username": "u",
"password": "p",
"full_name": "f"
}'

HTTP/1.1 201 Created
Server: nginx/1.6.2
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Thu, 16 Aug 2018 21:05:38 GMT

{"id":123,"full_name":"f"}

curl -i -X GET 'http://192.168.99.20:81/api/v1/users?username=u&password=p&full_name=fn'

HTTP/1.1 201 Created
Server: nginx/1.6.2
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Thu, 16 Aug 2018 21:05:38 GMT

{"id":123,"full_name":"f"}