diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 6b701bf..f33b043 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -28,7 +28,7 @@ final class ArticleController extends AbstractController #[OA\Tag(name: 'Статьи')] #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] - #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] #[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))] @@ -38,7 +38,7 @@ final class ArticleController extends AbstractController { $qb = $repository->createFilteredQueryBuilder($request->query->all()); - return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ + return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, ]); } diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 7a63323..7b70263 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -12,6 +12,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class ArticleRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Article::class); @@ -24,7 +26,7 @@ class ArticleRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC'); - ContentRepositoryFilter::applyCommon($qb, 'a', $filters); + $this->applyCommonFilters($qb, 'a', $filters); return $qb; } diff --git a/src/Repository/ContentRepositoryFilter.php b/src/Repository/ContentFilterTrait.php similarity index 66% rename from src/Repository/ContentRepositoryFilter.php rename to src/Repository/ContentFilterTrait.php index d4235d8..eddfebd 100644 --- a/src/Repository/ContentRepositoryFilter.php +++ b/src/Repository/ContentFilterTrait.php @@ -9,43 +9,41 @@ use Doctrine\ORM\QueryBuilder; /** * Общие фильтры для контентных репозиториев (News/Promo/Disease/MedicalCenter/Article/SiteService). * - * Назначение — не плодить одинаковые if-блоки в каждом репозитории и сохранить - * единый контракт query-параметров для list-эндпоинтов. + * Trait подключается в Doctrine-репозитории, чтобы не держать бизнес-фильтры + * в статическом helper-классе и при этом не копировать одинаковые if-блоки. * * Поддерживается: - * - regionId целое > 0 - * - active bool (по умолчанию true, если не передан) - * - alias строка (строгое равенство) - * - search LIKE по name (case-insensitive) - * - * Параметры пагинации (page/perPage) обрабатываются Paginator'ом и здесь игнорируются. + * - regionId / region_id: целое > 0; + * - active: bool; + * - alias: точное совпадение; + * - search / q: LIKE по name в lower-case. */ -final class ContentRepositoryFilter +trait ContentFilterTrait { /** * @param array $filters */ - public static function applyCommon(QueryBuilder $qb, string $alias, array $filters): void + private function applyCommonFilters(QueryBuilder $qb, string $alias, array $filters): void { - $regionId = self::extractInt($filters, ['regionId', 'region_id']); + $regionId = $this->extractIntFilter($filters, ['regionId', 'region_id']); if ($regionId !== null && $regionId > 0) { $qb->andWhere("$alias.regionId = :regionId") ->setParameter('regionId', $regionId); } - $active = self::extractBool($filters, ['active']); + $active = $this->extractBoolFilter($filters, ['active']); if ($active !== null) { $qb->andWhere("$alias.active = :active") ->setParameter('active', $active); } - $aliasFilter = self::extractNonEmptyString($filters, ['alias']); + $aliasFilter = $this->extractNonEmptyStringFilter($filters, ['alias']); if ($aliasFilter !== null) { $qb->andWhere("$alias.alias = :aliasValue") ->setParameter('aliasValue', $aliasFilter); } - $search = self::extractNonEmptyString($filters, ['search', 'q']); + $search = $this->extractNonEmptyStringFilter($filters, ['search', 'q']); if ($search !== null) { $qb->andWhere("LOWER($alias.name) LIKE :search") ->setParameter('search', '%' . mb_strtolower($search) . '%'); @@ -56,16 +54,18 @@ final class ContentRepositoryFilter * @param array $filters * @param list $keys */ - private static function extractInt(array $filters, array $keys): ?int + private function extractIntFilter(array $filters, array $keys): ?int { foreach ($keys as $key) { if (!array_key_exists($key, $filters)) { continue; } + $value = $filters[$key]; if ($value === null || $value === '') { continue; } + if (is_numeric($value)) { return (int) $value; } @@ -78,16 +78,18 @@ final class ContentRepositoryFilter * @param array $filters * @param list $keys */ - private static function extractBool(array $filters, array $keys): ?bool + private function extractBoolFilter(array $filters, array $keys): ?bool { foreach ($keys as $key) { if (!array_key_exists($key, $filters)) { continue; } + $value = $filters[$key]; if ($value === null || $value === '') { continue; } + if (is_bool($value)) { return $value; } @@ -102,16 +104,18 @@ final class ContentRepositoryFilter * @param array $filters * @param list $keys */ - private static function extractNonEmptyString(array $filters, array $keys): ?string + private function extractNonEmptyStringFilter(array $filters, array $keys): ?string { foreach ($keys as $key) { if (!array_key_exists($key, $filters)) { continue; } + $value = $filters[$key]; if (!is_string($value)) { continue; } + $trimmed = trim($value); if ($trimmed !== '') { return $trimmed; diff --git a/src/Repository/DiseaseRepository.php b/src/Repository/DiseaseRepository.php index 5e9eca7..77faff4 100644 --- a/src/Repository/DiseaseRepository.php +++ b/src/Repository/DiseaseRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class DiseaseRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Disease::class); @@ -27,7 +29,7 @@ class DiseaseRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC'); - ContentRepositoryFilter::applyCommon($qb, 'd', $filters); + $this->applyCommonFilters($qb, 'd', $filters); return $qb; } diff --git a/src/Repository/MedicalCenterRepository.php b/src/Repository/MedicalCenterRepository.php index 2b14f2a..9c00e8e 100644 --- a/src/Repository/MedicalCenterRepository.php +++ b/src/Repository/MedicalCenterRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class MedicalCenterRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MedicalCenter::class); @@ -27,7 +29,7 @@ class MedicalCenterRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC'); - ContentRepositoryFilter::applyCommon($qb, 'm', $filters); + $this->applyCommonFilters($qb, 'm', $filters); return $qb; } diff --git a/src/Repository/NewsRepository.php b/src/Repository/NewsRepository.php index 7703f0d..c2d3c41 100644 --- a/src/Repository/NewsRepository.php +++ b/src/Repository/NewsRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class NewsRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, News::class); @@ -31,7 +33,7 @@ class NewsRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC'); - ContentRepositoryFilter::applyCommon($qb, 'n', $filters); + $this->applyCommonFilters($qb, 'n', $filters); return $qb; } diff --git a/src/Repository/PromoRepository.php b/src/Repository/PromoRepository.php index 35e0b1e..0f89df0 100644 --- a/src/Repository/PromoRepository.php +++ b/src/Repository/PromoRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class PromoRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Promo::class); @@ -27,7 +29,7 @@ class PromoRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC'); - ContentRepositoryFilter::applyCommon($qb, 'p', $filters); + $this->applyCommonFilters($qb, 'p', $filters); return $qb; } diff --git a/src/Repository/SiteServiceRepository.php b/src/Repository/SiteServiceRepository.php index c44abbd..7964a99 100644 --- a/src/Repository/SiteServiceRepository.php +++ b/src/Repository/SiteServiceRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class SiteServiceRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, SiteService::class); @@ -27,7 +29,7 @@ class SiteServiceRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC'); - ContentRepositoryFilter::applyCommon($qb, 's', $filters); + $this->applyCommonFilters($qb, 's', $filters); return $qb; } diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php index 4e40251..522c693 100644 --- a/src/Service/Crud/CrudResponder.php +++ b/src/Service/Crud/CrudResponder.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service\Crud; use Doctrine\ORM\EntityManagerInterface; +use JsonException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -142,9 +143,11 @@ final class CrudResponder */ private function decodePayload(Request $request): ?array { - $data = json_decode($request->getContent(), true); - - return is_array($data) ? $data : null; + try { + return $request->toArray(); + } catch (JsonException) { + return null; + } } private function validate(object $entity): ?JsonResponse diff --git a/src/Service/Pagination/Paginator.php b/src/Service/Pagination/Paginator.php index 527908a..bb53ee6 100644 --- a/src/Service/Pagination/Paginator.php +++ b/src/Service/Pagination/Paginator.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; * * Соответствует существующему стилю проекта (см. PriceListController/SpecialistController): * читает page/perPage из Request, ограничивает perPage и возвращает массив - * ['data' => [...], 'pagination' => [...]] в едином формате для всех контроллеров. + * ['data' => [...], 'pagination' => [...]] в едином формате для новых list-контрактов. */ final class Paginator { @@ -62,4 +62,46 @@ final class Paginator ], ]; } + + /** + * Legacy-формат для ArticleController. + * + * Старый контракт /article/list уже использовался клиентами: + * - размер страницы приходит в query-параметре limit; + * - метаданные лежат в ключе meta; + * - поля называются total/page/limit/totalPages. + * + * @return array{data: list, meta: array{total: int, page: int, limit: int, totalPages: int}} + */ + public function paginateWithLegacyMeta( + QueryBuilder $qb, + Request $request, + int $defaultLimit = 20, + int $maxLimit = 100, + ): array { + $page = max(1, $request->query->getInt('page', 1)); + $limit = min( + max(1, $request->query->getInt('limit', $defaultLimit)), + $maxLimit, + ); + + $pagerfanta = (new Pagerfanta(new QueryAdapter($qb))) + ->setMaxPerPage($limit); + + try { + $pagerfanta->setCurrentPage($page); + } catch (NotValidCurrentPageException) { + $pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages())); + } + + return [ + 'data' => iterator_to_array($pagerfanta->getCurrentPageResults(), false), + 'meta' => [ + 'total' => $pagerfanta->getNbResults(), + 'page' => $pagerfanta->getCurrentPage(), + 'limit' => $pagerfanta->getMaxPerPage(), + 'totalPages' => $pagerfanta->getNbPages(), + ], + ]; + } }