Bu örnek, Symfony json API uygulamalarında basit bir şekilde request, response ve exception yönetimi yapmak için kullanılabilir. Temel amaç, manuel işlemi azaltarak request doğrulama, geçerli ve geçersiz response ile birlikte exception gönderme yapmaktır.


Önkoşullar


Composer ile jms/serializer-bundle ve symfony/validator paketlerini yüklemeyi unutmayın. 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\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


Aşağıdaki testKnownExceptions, testUnknownExceptions ve get methodları sadece test amaçlıdır. Bununla birlikte get methodu size sadece query string parametrelerinin bir modele nasıl bağlanıp doğrulandığını gösterme amaçlıdır.


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

Modeller


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


Yukarıda da göründüğü gibi tüm response model classları bu classı kullanmalılar.


declare(strict_types=1);

namespace App\Model;

interface ResponseInterface
{
}

ExceptionListener


Eğer request hatalı ise veya normal bir cevap verilecekse, response içeriği json string (application/json) olacaktır. Eğer bir exception oluşursa, response içeriği text string (text/html) olacaktır.


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


Aşağıda da göründüğü gibi kendi yarattığınız tüm exception classları bu classı kullanmalılar.


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
{
}

Testler


Bu örnekte "content type" yanlış çünkü sadece json kabul ediliyor.


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.

Log'un içeriği aşağıdaki gibidir.


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')

Bu örnekte request içeriği yanlış çünkü username ve full_name alanları boş vaziyette. Log tutulmamasının nedeni ise ben kullanıcıların ne tür hatalar yaptığını önemsemiyorum.


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."}}

Varsayalım ki bir tane exception yakalıyor ve kendi yarattıklarımızdan bir tane gönderiyoruz.


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.

Log'un içeriği aşağıdaki gibidir.


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()

Varsayalım ki bir tane exception oluşuyor ama bizim haberimiz olmadığından yakalamıyoruz.


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.

Log'un içeriği aşağıdaki gibidir.


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()

Request içeriğinin doğru olduğunu varsayalım.


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"}