diff --git a/migrations/Version20260515142000.php b/migrations/Version20260515142000.php new file mode 100644 index 0000000..e3fb5e2 --- /dev/null +++ b/migrations/Version20260515142000.php @@ -0,0 +1,53 @@ +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)); + } + } +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index f33b043..798b0d4 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -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 { diff --git a/src/Controller/DiseaseController.php b/src/Controller/DiseaseController.php index 43e10cb..1c18794 100644 --- a/src/Controller/DiseaseController.php +++ b/src/Controller/DiseaseController.php @@ -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 { diff --git a/src/Controller/MedicalCenterController.php b/src/Controller/MedicalCenterController.php index ad53c4e..8125326 100644 --- a/src/Controller/MedicalCenterController.php +++ b/src/Controller/MedicalCenterController.php @@ -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 { diff --git a/src/Controller/NewsController.php b/src/Controller/NewsController.php index 96f4534..dd57dda 100644 --- a/src/Controller/NewsController.php +++ b/src/Controller/NewsController.php @@ -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 { diff --git a/src/Controller/PromoController.php b/src/Controller/PromoController.php index d2984e3..dff9926 100644 --- a/src/Controller/PromoController.php +++ b/src/Controller/PromoController.php @@ -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 { diff --git a/src/Controller/SiteServiceController.php b/src/Controller/SiteServiceController.php index 92c517d..50893a7 100644 --- a/src/Controller/SiteServiceController.php +++ b/src/Controller/SiteServiceController.php @@ -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 { diff --git a/src/Entity/Disease.php b/src/Entity/Disease.php index 26fc0bd..9bbd65c 100644 --- a/src/Entity/Disease.php +++ b/src/Entity/Disease.php @@ -15,6 +15,7 @@ class Disease { #[Groups(['disease:read'])] #[ORM\Id] + #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] private ?int $id = null; diff --git a/src/Entity/MedicalCenter.php b/src/Entity/MedicalCenter.php index b116c78..028cee6 100644 --- a/src/Entity/MedicalCenter.php +++ b/src/Entity/MedicalCenter.php @@ -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; diff --git a/src/Entity/News.php b/src/Entity/News.php index 94dd5e9..552af9f 100644 --- a/src/Entity/News.php +++ b/src/Entity/News.php @@ -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; diff --git a/src/Entity/Promo.php b/src/Entity/Promo.php index 94bb004..4f1e983 100644 --- a/src/Entity/Promo.php +++ b/src/Entity/Promo.php @@ -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; diff --git a/src/Entity/SiteService.php b/src/Entity/SiteService.php index cf48e0f..ac29328 100644 --- a/src/Entity/SiteService.php +++ b/src/Entity/SiteService.php @@ -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; diff --git a/src/Repository/ContentFilterTrait.php b/src/Repository/ContentFilterTrait.php index eddfebd..c95453b 100644 --- a/src/Repository/ContentFilterTrait.php +++ b/src/Repository/ContentFilterTrait.php @@ -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 { diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php index 522c693..138511c 100644 --- a/src/Service/Crud/CrudResponder.php +++ b/src/Service/Crud/CrudResponder.php @@ -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 $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);