196 lines
7.3 KiB
PHP
196 lines
7.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Service\Crud;
|
||
|
||
use Doctrine\DBAL\Exception as DbalException;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use JsonException;
|
||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface;
|
||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||
use Symfony\Component\Serializer\SerializerInterface;
|
||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||
|
||
/**
|
||
* Универсальный CRUD-ответчик для тонких контент-контроллеров.
|
||
*
|
||
* Контракт ответов специально сохранён близким к старым *CrudService/контроллерам,
|
||
* чтобы не ломать существующих клиентов (фронтенд/мобильное):
|
||
* - валидация: HTTP 400 + сериализованный ConstraintViolationList
|
||
* (формат Symfony Serializer по умолчанию, т.е. RFC 7807 с ключом violations);
|
||
* - удаление с ошибкой БД (например, FK constraint): HTTP 500 + {error, message};
|
||
* - JSON-ключи запросов/ответов используют camelCase (см. свойства сущностей и группы *:write).
|
||
* Name converter в config/packages/serializer.yaml не задан намеренно — клиенту
|
||
* нужен консистентный camelCase, иначе незнакомые ключи будут проигнорированы.
|
||
*/
|
||
final class CrudResponder
|
||
{
|
||
public function __construct(
|
||
private EntityManagerInterface $em,
|
||
private SerializerInterface $serializer,
|
||
private DenormalizerInterface $denormalizer,
|
||
private ValidatorInterface $validator,
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $readGroups
|
||
*/
|
||
public function read(object $entity, array $readGroups): JsonResponse
|
||
{
|
||
return $this->json($entity, Response::HTTP_OK, $readGroups);
|
||
}
|
||
|
||
/**
|
||
* @template T of object
|
||
*
|
||
* @param class-string<T> $entityClass
|
||
* @param list<string> $writeGroups
|
||
* @param list<string> $readGroups
|
||
*/
|
||
public function create(
|
||
Request $request,
|
||
string $entityClass,
|
||
array $writeGroups,
|
||
array $readGroups,
|
||
): JsonResponse {
|
||
$payload = $this->decodePayload($request);
|
||
if ($payload === null) {
|
||
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
|
||
}
|
||
unset($payload['id']);
|
||
|
||
try {
|
||
/** @var T $entity */
|
||
$entity = $this->denormalizer->denormalize(
|
||
$payload,
|
||
$entityClass,
|
||
null,
|
||
[
|
||
AbstractNormalizer::GROUPS => $writeGroups,
|
||
],
|
||
);
|
||
} catch (SerializerExceptionInterface $e) {
|
||
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
|
||
}
|
||
|
||
if (($validationResponse = $this->validate($entity)) !== null) {
|
||
return $validationResponse;
|
||
}
|
||
|
||
$this->em->persist($entity);
|
||
$this->em->flush();
|
||
|
||
return $this->json($entity, Response::HTTP_CREATED, $readGroups);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $writeGroups
|
||
* @param list<string> $readGroups
|
||
*/
|
||
public function update(
|
||
Request $request,
|
||
object $entity,
|
||
array $writeGroups,
|
||
array $readGroups,
|
||
): JsonResponse {
|
||
$payload = $this->decodePayload($request);
|
||
if ($payload === null) {
|
||
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
|
||
}
|
||
unset($payload['id']);
|
||
|
||
try {
|
||
$this->denormalizer->denormalize(
|
||
$payload,
|
||
$entity::class,
|
||
null,
|
||
[
|
||
AbstractNormalizer::GROUPS => $writeGroups,
|
||
AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
|
||
],
|
||
);
|
||
} catch (SerializerExceptionInterface $e) {
|
||
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
|
||
}
|
||
|
||
if (($validationResponse = $this->validate($entity)) !== null) {
|
||
return $validationResponse;
|
||
}
|
||
|
||
$this->em->flush();
|
||
|
||
return $this->json($entity, Response::HTTP_OK, $readGroups);
|
||
}
|
||
|
||
public function delete(object $entity): JsonResponse
|
||
{
|
||
try {
|
||
$this->em->remove($entity);
|
||
$this->em->flush();
|
||
} catch (DbalException $e) {
|
||
// Сохраняем легаси-контракт: при FK / NOT NULL / unique ошибках БД
|
||
// отдаём 500 + {error, message}. См. старый ArticleController::delete.
|
||
return new JsonResponse(
|
||
['error' => 'Ошибка при удалении записи', 'message' => $e->getMessage()],
|
||
Response::HTTP_INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
|
||
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>|null null если тело не является JSON-объектом
|
||
*
|
||
* Ловим как нативный \JsonException, так и Symfony\...\HttpFoundation\Exception\JsonException
|
||
* (последний наследует UnexpectedValueException, а не \JsonException, и без
|
||
* широкого перехвата Symfony ErrorListener перехватит ошибку до нашего try/catch).
|
||
*/
|
||
private function decodePayload(Request $request): ?array
|
||
{
|
||
try {
|
||
return $request->toArray();
|
||
} catch (JsonException|\UnexpectedValueException) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function validate(object $entity): ?JsonResponse
|
||
{
|
||
$errors = $this->validator->validate($entity);
|
||
if (count($errors) === 0) {
|
||
return null;
|
||
}
|
||
|
||
// BC: легаси-контроллеры возвращали именно сериализованный ConstraintViolationList
|
||
// с кодом 400. Этот же формат продолжаем отдавать здесь, чтобы фронтенду
|
||
// не пришлось переписывать парсинг ошибок.
|
||
$json = $this->serializer->serialize($errors, 'json');
|
||
|
||
return new JsonResponse($json, Response::HTTP_BAD_REQUEST, [], true);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $groups
|
||
*/
|
||
private function json(mixed $data, int $status, array $groups): JsonResponse
|
||
{
|
||
$json = $this->serializer->serialize($data, 'json', [
|
||
AbstractNormalizer::GROUPS => $groups,
|
||
]);
|
||
|
||
return new JsonResponse($json, $status, [], true);
|
||
}
|
||
|
||
private function jsonError(string $message, int $status): JsonResponse
|
||
{
|
||
return new JsonResponse(['error' => $message], $status);
|
||
}
|
||
}
|