issues/27: denormalize CRUD payloads and align error responses with legacy clients
This commit is contained in:
committed by
Valeriy Petrov
parent
76044381fd
commit
02897a1fdb
@@ -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) . '%');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user