issues/27: denormalize CRUD payloads and align error responses with legacy clients

This commit is contained in:
Valery Petrov
2026-05-15 16:11:53 +03:00
committed by Valeriy Petrov
parent 76044381fd
commit 02897a1fdb
2 changed files with 55 additions and 42 deletions
+15 -8
View File
@@ -17,17 +17,24 @@ use Doctrine\ORM\QueryBuilder;
* - regionId / region_id: целое > 0; * - regionId / region_id: целое > 0;
* - active: bool; * - active: bool;
* - alias: точное совпадение; * - alias: точное совпадение;
* - search / q: LIKE по name в lower-case. * - search / q: LIKE по lower-case значению заданного поля (по умолчанию `name`).
* *
* Важно: search использует LOWER(name), поэтому для больших таблиц нужен * Поле поиска параметризовано через $searchField на случай сущностей,
* функциональный индекс в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)). * где основное текстовое поле называется иначе (например, `title`).
* Если у сущности нет такого свойства, Doctrine упадёт с QueryException — это
* лучше ловится тестами на этапе разработки, чем 500 в проде.
*
* Важно: LOWER($alias.$searchField) при больших таблицах требует функционального
* индекса в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)).
*/ */
trait ContentFilterTrait trait ContentFilterTrait
{ {
/** private function applyCommonFilters(
*/ QueryBuilder $qb,
private function applyCommonFilters(QueryBuilder $qb, string $alias, ContentFilterDto $filters): void string $alias,
{ ContentFilterDto $filters,
string $searchField = 'name',
): void {
if ($filters->regionId !== null) { if ($filters->regionId !== null) {
$qb->andWhere("$alias.regionId = :regionId") $qb->andWhere("$alias.regionId = :regionId")
->setParameter('regionId', $filters->regionId); ->setParameter('regionId', $filters->regionId);
@@ -44,7 +51,7 @@ trait ContentFilterTrait
} }
if ($filters->search !== null) { if ($filters->search !== null) {
$qb->andWhere("LOWER($alias.name) LIKE :search") $qb->andWhere("LOWER($alias.$searchField) LIKE :search")
->setParameter('search', '%' . mb_strtolower($filters->search) . '%'); ->setParameter('search', '%' . mb_strtolower($filters->search) . '%');
} }
} }
+40 -34
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\Crud; namespace App\Service\Crud;
use Doctrine\DBAL\Exception as DbalException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use JsonException; use JsonException;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -11,23 +12,28 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
/** /**
* Универсальный CRUD-ответчик для тонких контент-контроллеров. * Универсальный CRUD-ответчик для тонких контент-контроллеров.
* *
* Инкапсулирует общую логику десериализации тела запроса по группе :write, * Контракт ответов специально сохранён близким к старым *CrudService/контроллерам,
* валидации сущности, persist/flush и сериализации ответа по группе :read. * чтобы не ломать существующих клиентов (фронтенд/мобильное):
* * - валидация: HTTP 400 + сериализованный ConstraintViolationList
* Контроллер становится декларативным: * (формат Symfony Serializer по умолчанию, т.е. RFC 7807 с ключом violations);
* return $this->crud->create($request, News::class, ['news:write'], ['news:read']); * - удаление с ошибкой БД (например, FK constraint): HTTP 500 + {error, message};
* - JSON-ключи запросов/ответов используют camelCase (см. свойства сущностей и группы *:write).
* Name converter в config/packages/serializer.yaml не задан намеренно — клиенту
* нужен консистентный camelCase, иначе незнакомые ключи будут проигнорированы.
*/ */
final class CrudResponder final class CrudResponder
{ {
public function __construct( public function __construct(
private EntityManagerInterface $em, private EntityManagerInterface $em,
private SerializerInterface $serializer, private SerializerInterface $serializer,
private DenormalizerInterface $denormalizer,
private ValidatorInterface $validator, private ValidatorInterface $validator,
) { ) {
} }
@@ -61,15 +67,15 @@ final class CrudResponder
try { try {
/** @var T $entity */ /** @var T $entity */
$entity = $this->serializer->deserialize( $entity = $this->denormalizer->denormalize(
$this->encodePayload($payload), $payload,
$entityClass, $entityClass,
'json', null,
[ [
AbstractNormalizer::GROUPS => $writeGroups, AbstractNormalizer::GROUPS => $writeGroups,
], ],
); );
} catch (JsonException|SerializerExceptionInterface $e) { } catch (SerializerExceptionInterface $e) {
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
} }
@@ -100,16 +106,16 @@ final class CrudResponder
unset($payload['id']); unset($payload['id']);
try { try {
$this->serializer->deserialize( $this->denormalizer->denormalize(
$this->encodePayload($payload), $payload,
$entity::class, $entity::class,
'json', null,
[ [
AbstractNormalizer::GROUPS => $writeGroups, AbstractNormalizer::GROUPS => $writeGroups,
AbstractNormalizer::OBJECT_TO_POPULATE => $entity, AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
], ],
); );
} catch (JsonException|SerializerExceptionInterface $e) { } catch (SerializerExceptionInterface $e) {
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
} }
@@ -124,34 +130,37 @@ final class CrudResponder
public function delete(object $entity): JsonResponse public function delete(object $entity): JsonResponse
{ {
$this->em->remove($entity); try {
$this->em->flush(); $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 new JsonResponse(null, Response::HTTP_NO_CONTENT);
} }
/** /**
* @return array<string, mixed>|null null если тело не является JSON-объектом * @return array<string, mixed>|null null если тело не является JSON-объектом
*
* Ловим как нативный \JsonException, так и Symfony\...\HttpFoundation\Exception\JsonException
* (последний наследует UnexpectedValueException, а не \JsonException, и без
* широкого перехвата Symfony ErrorListener перехватит ошибку до нашего try/catch).
*/ */
private function decodePayload(Request $request): ?array private function decodePayload(Request $request): ?array
{ {
try { try {
return $request->toArray(); return $request->toArray();
} catch (JsonException) { } catch (JsonException|\UnexpectedValueException) {
return null; return null;
} }
} }
/**
* @param array<string, mixed> $payload
*
* @throws JsonException
*/
private function encodePayload(array $payload): string
{
return json_encode($payload, JSON_THROW_ON_ERROR);
}
private function validate(object $entity): ?JsonResponse private function validate(object $entity): ?JsonResponse
{ {
$errors = $this->validator->validate($entity); $errors = $this->validator->validate($entity);
@@ -159,15 +168,12 @@ final class CrudResponder
return null; return null;
} }
$formatted = []; // BC: легаси-контроллеры возвращали именно сериализованный ConstraintViolationList
foreach ($errors as $error) { // с кодом 400. Этот же формат продолжаем отдавать здесь, чтобы фронтенду
$formatted[] = [ // не пришлось переписывать парсинг ошибок.
'property' => $error->getPropertyPath(), $json = $this->serializer->serialize($errors, 'json');
'message' => $error->getMessage(),
];
}
return new JsonResponse(['errors' => $formatted], Response::HTTP_UNPROCESSABLE_ENTITY); return new JsonResponse($json, Response::HTTP_BAD_REQUEST, [], true);
} }
/** /**