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\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -61,6 +62,7 @@ final class ArticleController extends AbstractController
}
#[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'])]
public function create(Request $request): JsonResponse
{
@@ -68,6 +70,7 @@ final class ArticleController extends AbstractController
}
#[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+'])]
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\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class DiseaseController extends AbstractController
}
#[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'])]
public function create(Request $request): JsonResponse
{
@@ -56,6 +58,7 @@ final class DiseaseController extends AbstractController
}
#[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+'])]
public function update(Request $request, Disease $disease): JsonResponse
{
@@ -6,6 +6,7 @@ use App\Entity\MedicalCenter;
use App\Repository\MedicalCenterRepository;
use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class MedicalCenterController extends AbstractController
}
#[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'])]
public function create(Request $request): JsonResponse
{
@@ -56,6 +58,7 @@ final class MedicalCenterController extends AbstractController
}
#[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+'])]
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\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class NewsController extends AbstractController
}
#[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'])]
public function create(Request $request): JsonResponse
{
@@ -56,6 +58,7 @@ final class NewsController extends AbstractController
}
#[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+'])]
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\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class PromoController extends AbstractController
}
#[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'])]
public function create(Request $request): JsonResponse
{
@@ -56,6 +58,7 @@ final class PromoController extends AbstractController
}
#[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+'])]
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\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -49,6 +50,7 @@ final class SiteServiceController extends AbstractController
}
#[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'])]
public function create(Request $request): JsonResponse
{
@@ -56,6 +58,7 @@ final class SiteServiceController extends AbstractController
}
#[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+'])]
public function update(Request $request, SiteService $siteService): JsonResponse
{
+1
View File
@@ -15,6 +15,7 @@ class Disease
{
#[Groups(['disease:read'])]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
+1
View File
@@ -12,6 +12,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
class MedicalCenter
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
#[Groups(['medical_center:read'])]
private ?int $id = null;
+1
View File
@@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
class News
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
#[Groups(['news:read'])]
private ?int $id = null;
+1
View File
@@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
class Promo
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
#[Groups(['promo:read'])]
private ?int $id = null;
+1
View File
@@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
class SiteService
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
#[Groups(['site_service:read'])]
private ?int $id = null;
+3
View File
@@ -17,6 +17,9 @@ use Doctrine\ORM\QueryBuilder;
* - active: bool;
* - alias: точное совпадение;
* - search / q: LIKE по name в lower-case.
*
* Важно: search использует LOWER(name), поэтому для больших таблиц нужен
* функциональный индекс в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)).
*/
trait ContentFilterTrait
{
+25 -9
View File
@@ -52,30 +52,34 @@ final class CrudResponder
string $entityClass,
array $writeGroups,
array $readGroups,
bool $allowIdFromPayload = true,
bool $allowIdFromPayload = false,
): JsonResponse {
$payload = $this->decodePayload($request);
if ($payload === null) {
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
}
$deserializationPayload = $payload;
if (!$allowIdFromPayload) {
unset($deserializationPayload['id']);
}
try {
/** @var T $entity */
$entity = $this->serializer->deserialize(
$request->getContent(),
$this->encodePayload($deserializationPayload),
$entityClass,
'json',
[
AbstractNormalizer::GROUPS => $writeGroups,
],
);
} catch (SerializerExceptionInterface $e) {
} catch (JsonException|SerializerExceptionInterface $e) {
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
}
// Id в группе :write не присутствует (чтобы запретить инъекцию при update).
// На create поддерживаем явный id из payload, потому что у контент-сущностей
// нет GeneratedValue (id приходит из Bitrix-view).
// По умолчанию публичный CRUD не принимает id от клиента. Если системной
// интеграции понадобится внешний id, конкретный вызов должен явно передать true.
if ($allowIdFromPayload && isset($payload['id']) && method_exists($entity, 'setId')) {
$id = (int) $payload['id'];
if ($id > 0) {
@@ -103,13 +107,15 @@ final class CrudResponder
array $writeGroups,
array $readGroups,
): JsonResponse {
if ($this->decodePayload($request) === null) {
$payload = $this->decodePayload($request);
if ($payload === null) {
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
}
unset($payload['id']);
try {
$this->serializer->deserialize(
$request->getContent(),
$this->encodePayload($payload),
$entity::class,
'json',
[
@@ -117,7 +123,7 @@ final class CrudResponder
AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
],
);
} catch (SerializerExceptionInterface $e) {
} catch (JsonException|SerializerExceptionInterface $e) {
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
{
$errors = $this->validator->validate($entity);