Files
backend/src/Service/Crud/CrudResponder.php
T
2026-05-27 19:36:32 +03:00

196 lines
7.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}