issues/27: sequence/default migration and #[OA\RequestBody(... Model(... groups: *:write))]
This commit is contained in:
committed by
Valeriy Petrov
parent
656f79ff4e
commit
da5f7bb242
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ class Disease
|
||||
{
|
||||
#[Groups(['disease:read'])]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: "IDENTITY")]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user