issues/27: sequence/default migration and #[OA\RequestBody(... Model(... groups: *:write))]

This commit is contained in:
Valery Petrov
2026-05-15 14:31:24 +03:00
committed by Valeriy Petrov
parent 656f79ff4e
commit da5f7bb242
14 changed files with 104 additions and 9 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260515142000 extends AbstractMigration
{
private const TABLES = [
'news',
'promo',
'disease',
'medical_center',
'site_services',
];
public function getDescription(): string
{
return 'Add generated id defaults for content CRUD entities';
}
public function up(Schema $schema): void
{
foreach (self::TABLES as $table) {
$sequence = $table . '_id_seq';
$this->addSql(sprintf('CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.id', $sequence, $table));
$this->addSql(sprintf(
'SELECT setval(\'%s\', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)',
$sequence,
$table,
));
$this->addSql(sprintf(
'ALTER TABLE %s ALTER COLUMN id SET DEFAULT nextval(\'%s\')',
$table,
$sequence,
));
}
}
public function down(Schema $schema): void
{
foreach (array_reverse(self::TABLES) as $table) {
$sequence = $table . '_id_seq';
$this->addSql(sprintf('ALTER TABLE %s ALTER COLUMN id DROP DEFAULT', $table));
$this->addSql(sprintf('DROP SEQUENCE IF EXISTS %s', $sequence));
}
}
}
+3
View File
@@ -6,6 +6,7 @@ use App\Entity\Article;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use App\Service\Crud\CrudResponder; use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator; use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -61,6 +62,7 @@ final class ArticleController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))]
#[Route('/create', name: 'article_create', methods: ['POST'])] #[Route('/create', name: 'article_create', methods: ['POST'])]
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -68,6 +70,7 @@ final class ArticleController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))]
#[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])] #[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(Request $request, Article $article): JsonResponse public function update(Request $request, Article $article): JsonResponse
{ {
+3
View File
@@ -6,6 +6,7 @@ use App\Entity\Disease;
use App\Repository\DiseaseRepository; use App\Repository\DiseaseRepository;
use App\Service\Crud\CrudResponder; use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator; use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class DiseaseController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))]
#[Route('/create', name: 'disease_create', methods: ['POST'])] #[Route('/create', name: 'disease_create', methods: ['POST'])]
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -56,6 +58,7 @@ final class DiseaseController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))]
#[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])] #[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(Request $request, Disease $disease): JsonResponse public function update(Request $request, Disease $disease): JsonResponse
{ {
@@ -6,6 +6,7 @@ use App\Entity\MedicalCenter;
use App\Repository\MedicalCenterRepository; use App\Repository\MedicalCenterRepository;
use App\Service\Crud\CrudResponder; use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator; use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class MedicalCenterController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))]
#[Route('/create', name: 'medical_center_create', methods: ['POST'])] #[Route('/create', name: 'medical_center_create', methods: ['POST'])]
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -56,6 +58,7 @@ final class MedicalCenterController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))]
#[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])] #[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(Request $request, MedicalCenter $medicalCenter): JsonResponse public function update(Request $request, MedicalCenter $medicalCenter): JsonResponse
{ {
+3
View File
@@ -6,6 +6,7 @@ use App\Entity\News;
use App\Repository\NewsRepository; use App\Repository\NewsRepository;
use App\Service\Crud\CrudResponder; use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator; use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class NewsController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))]
#[Route('/create', name: 'news_create', methods: ['POST'])] #[Route('/create', name: 'news_create', methods: ['POST'])]
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -56,6 +58,7 @@ final class NewsController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))]
#[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])] #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(Request $request, News $news): JsonResponse public function update(Request $request, News $news): JsonResponse
{ {
+3
View File
@@ -6,6 +6,7 @@ use App\Entity\Promo;
use App\Repository\PromoRepository; use App\Repository\PromoRepository;
use App\Service\Crud\CrudResponder; use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator; use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class PromoController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))]
#[Route('/create', name: 'promo_create', methods: ['POST'])] #[Route('/create', name: 'promo_create', methods: ['POST'])]
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -56,6 +58,7 @@ final class PromoController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))]
#[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])] #[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(Request $request, Promo $promo): JsonResponse public function update(Request $request, Promo $promo): JsonResponse
{ {
+3
View File
@@ -6,6 +6,7 @@ use App\Entity\SiteService;
use App\Repository\SiteServiceRepository; use App\Repository\SiteServiceRepository;
use App\Service\Crud\CrudResponder; use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator; use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class SiteServiceController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))]
#[Route('/create', name: 'site_service_create', methods: ['POST'])] #[Route('/create', name: 'site_service_create', methods: ['POST'])]
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -56,6 +58,7 @@ final class SiteServiceController extends AbstractController
} }
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))]
#[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])] #[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(Request $request, SiteService $siteService): JsonResponse public function update(Request $request, SiteService $siteService): JsonResponse
{ {
+1
View File
@@ -15,6 +15,7 @@ class Disease
{ {
#[Groups(['disease:read'])] #[Groups(['disease:read'])]
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
private ?int $id = null; private ?int $id = null;
+1
View File
@@ -12,6 +12,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
class MedicalCenter class MedicalCenter
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
#[Groups(['medical_center:read'])] #[Groups(['medical_center:read'])]
private ?int $id = null; private ?int $id = null;
+1
View File
@@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
class News class News
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
#[Groups(['news:read'])] #[Groups(['news:read'])]
private ?int $id = null; private ?int $id = null;
+1
View File
@@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
class Promo class Promo
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
#[Groups(['promo:read'])] #[Groups(['promo:read'])]
private ?int $id = null; private ?int $id = null;
+1
View File
@@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
class SiteService class SiteService
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
#[Groups(['site_service:read'])] #[Groups(['site_service:read'])]
private ?int $id = null; private ?int $id = null;
+3
View File
@@ -17,6 +17,9 @@ use Doctrine\ORM\QueryBuilder;
* - active: bool; * - active: bool;
* - alias: точное совпадение; * - alias: точное совпадение;
* - search / q: LIKE по name в lower-case. * - search / q: LIKE по name в lower-case.
*
* Важно: search использует LOWER(name), поэтому для больших таблиц нужен
* функциональный индекс в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)).
*/ */
trait ContentFilterTrait trait ContentFilterTrait
{ {
+25 -9
View File
@@ -52,30 +52,34 @@ final class CrudResponder
string $entityClass, string $entityClass,
array $writeGroups, array $writeGroups,
array $readGroups, array $readGroups,
bool $allowIdFromPayload = true, bool $allowIdFromPayload = false,
): JsonResponse { ): JsonResponse {
$payload = $this->decodePayload($request); $payload = $this->decodePayload($request);
if ($payload === null) { if ($payload === null) {
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
} }
$deserializationPayload = $payload;
if (!$allowIdFromPayload) {
unset($deserializationPayload['id']);
}
try { try {
/** @var T $entity */ /** @var T $entity */
$entity = $this->serializer->deserialize( $entity = $this->serializer->deserialize(
$request->getContent(), $this->encodePayload($deserializationPayload),
$entityClass, $entityClass,
'json', 'json',
[ [
AbstractNormalizer::GROUPS => $writeGroups, AbstractNormalizer::GROUPS => $writeGroups,
], ],
); );
} catch (SerializerExceptionInterface $e) { } catch (JsonException|SerializerExceptionInterface $e) {
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
} }
// Id в группе :write не присутствует (чтобы запретить инъекцию при update). // По умолчанию публичный CRUD не принимает id от клиента. Если системной
// На create поддерживаем явный id из payload, потому что у контент-сущностей // интеграции понадобится внешний id, конкретный вызов должен явно передать true.
// нет GeneratedValue (id приходит из Bitrix-view).
if ($allowIdFromPayload && isset($payload['id']) && method_exists($entity, 'setId')) { if ($allowIdFromPayload && isset($payload['id']) && method_exists($entity, 'setId')) {
$id = (int) $payload['id']; $id = (int) $payload['id'];
if ($id > 0) { if ($id > 0) {
@@ -103,13 +107,15 @@ final class CrudResponder
array $writeGroups, array $writeGroups,
array $readGroups, array $readGroups,
): JsonResponse { ): JsonResponse {
if ($this->decodePayload($request) === null) { $payload = $this->decodePayload($request);
if ($payload === null) {
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
} }
unset($payload['id']);
try { try {
$this->serializer->deserialize( $this->serializer->deserialize(
$request->getContent(), $this->encodePayload($payload),
$entity::class, $entity::class,
'json', 'json',
[ [
@@ -117,7 +123,7 @@ final class CrudResponder
AbstractNormalizer::OBJECT_TO_POPULATE => $entity, AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
], ],
); );
} catch (SerializerExceptionInterface $e) { } catch (JsonException|SerializerExceptionInterface $e) {
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
} }
@@ -150,6 +156,16 @@ final class CrudResponder
} }
} }
/**
* @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);