Files
docs/apps/backend-content-crud.md

29 KiB
Raw Permalink Blame History

Backend: CRUD для контентных сущностей

Эта страница объясняет рефакторинг CRUD-эндпоинтов для контентных сущностей backend API. Цель изменений - чтобы backend-разработчик быстро понял, где находится код, как проходит запрос, как добавить новый похожий ресурс и почему пагинация сделана через Pagerfanta.

Задача (#27)

Нужно дать API для админ-панели контент-менеджеров (UI), чтобы управлять контентом не напрямую через Bitrix, а через backend:

  • новости;
  • акции;
  • заболевания;
  • центры;
  • статьи;
  • услуги.

Основные требования:

  • единый CRUD-подход для всех шести ресурсов;
  • пагинация в стиле Symfony/проекта, без ручного LIMIT/OFFSET + COUNT;
  • минимум дублирования в контроллерах и сервисах;
  • запись только для пользователей с ROLE_ADMIN;
  • сохранить существующую синхронизацию из SQL view/Bitrix.

Ресурсы и маршруты

Сущность Контроллер Базовый путь Read group Write group
News NewsController /news news:read news:write
Promo PromoController /promo promo:read promo:write
Disease DiseaseController /disease disease:read disease:write
MedicalCenter MedicalCenterController /medical-center medical_center:read medical_center:write
Article ArticleController /article article:read article:write
SiteService SiteServiceController /site-services site_service:read site_service:write

У всех ресурсов есть одинаковый набор CRUD-маршрутов:

Метод Путь Доступ Назначение
GET /{resource}/list публичный список с пагинацией и фильтрами
GET /{resource}/{id} публичный детальная карточка
POST /{resource}/create ROLE_ADMIN создание
PUT /{resource}/{id} ROLE_ADMIN обновление
DELETE /{resource}/{id} ROLE_ADMIN удаление

У ArticleController дополнительно есть GET /article/alias/{alias}.

Общая схема

flowchart TD
    Client[HTTP client / admin panel] --> Controller[Content Controller]

    Controller -->|GET /list| Repository[Repository::createFilteredQueryBuilder]
    Repository --> Filter[ContentFilterTrait]
    Filter --> QueryBuilder[Doctrine QueryBuilder]
    QueryBuilder --> Paginator[Pagination\\Paginator]
    Paginator --> Pagerfanta[Pagerfanta + QueryAdapter]
    Pagerfanta --> JsonList[JSON: data + pagination/meta]

    Controller -->|POST/PUT/DELETE/GET detail| CrudResponder[CrudResponder]
    CrudResponder --> Serializer[Symfony Serializer groups]
    CrudResponder --> Validator[Symfony Validator]
    CrudResponder --> EntityManager[Doctrine EntityManager]
    EntityManager --> Db[(PostgreSQL)]

    Commands[Upload*Command] --> SyncService[*CrudService::syncFromView*]
    SyncService --> Db

Ключевой принцип

Контроллер не должен знать, как парсить JSON, как делать persist/flush, как форматировать ошибки валидации и как считать страницы.

Контроллер отвечает только за HTTP-маршрут, выбор entity-класса, serializer groups и вызов общего сервиса.

Тонкий контроллер

Пример NewsController:

#[Route('/news')]
final class NewsController extends AbstractController
{
    private const READ_GROUPS = ['news:read'];
    private const WRITE_GROUPS = ['news:write'];

    public function __construct(
        private readonly CrudResponder $crud,
        private readonly Paginator $paginator,
    ) {
    }

    #[Route('/list', name: 'news_list', methods: ['GET'])]
    public function list(Request $request, NewsRepository $repository): JsonResponse
    {
        $qb = $repository->createFilteredQueryBuilder(
            ContentFilterDto::fromRequest($request, defaultActive: true),
        );

        return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
            'groups' => self::READ_GROUPS,
        ]);
    }

    #[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\\d+'])]
    public function show(News $news): JsonResponse
    {
        return $this->crud->read($news, self::READ_GROUPS);
    }

    #[IsGranted('ROLE_ADMIN')]
    #[Route('/create', name: 'news_create', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        return $this->crud->create($request, News::class, self::WRITE_GROUPS, self::READ_GROUPS);
    }

    #[IsGranted('ROLE_ADMIN')]
    #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\\d+'])]
    public function update(Request $request, News $news): JsonResponse
    {
        return $this->crud->update($request, $news, self::WRITE_GROUPS, self::READ_GROUPS);
    }

    #[IsGranted('ROLE_ADMIN')]
    #[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\\d+'])]
    public function delete(News $news): JsonResponse
    {
        return $this->crud->delete($news);
    }
}

Остальные пять контроллеров устроены так же. Отличаются только:

  • базовый #[Route];
  • entity-класс;
  • repository-класс;
  • serializer groups.

Пагинация

Пагинация вынесена в App\Service\Pagination\Paginator.

Причина: в проекте уже есть похожий подход в PriceListController: QueryAdapter + Pagerfanta. Поэтому для новых списков не нужно писать свою пагинацию и отдельный COUNT-запрос.

Сервис принимает готовый Doctrine QueryBuilder и Request:

public function paginate(
    QueryBuilder $qb,
    Request $request,
    int $defaultPerPage = self::DEFAULT_PER_PAGE,
    int $maxPerPage = self::MAX_PER_PAGE,
): array

Он читает:

  • page - номер страницы, минимум 1;
  • perPage - размер страницы, минимум 1, максимум 500.

Формат ответа для news, promo, disease, medical-center, site-services:

{
  "data": [],
  "pagination": {
    "total": 42,
    "count": 10,
    "per_page": 10,
    "current_page": 1,
    "total_pages": 5,
    "has_previous_page": false,
    "has_next_page": true
  }
}

Для article сохранён старый API-контракт фронтенда: параметр размера страницы называется limit, а метаданные лежат в ключе meta.

{
  "data": [],
  "meta": {
    "total": 42,
    "page": 1,
    "limit": 20,
    "totalPages": 3
  }
}

Это сделано через отдельный метод Paginator::paginateWithLegacyMeta(), чтобы не ломать клиентов, которые читают response.data.meta.total.

Важно: Paginator не знает ничего про конкретную сущность. Он работает с любым QueryBuilder.

Фильтрация списков

Каждый repository имеет метод:

public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder

Пример NewsRepository:

class NewsRepository extends ServiceEntityRepository
{
    use ContentFilterTrait;

    public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
    {
        $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC');

        $this->applyCommonFilters($qb, 'n', $filters);

        return $qb;
    }
}

Общие фильтры лежат в App\Repository\ContentFilterTrait. Он подключается в нужные Doctrine-репозитории и не требует статических helper-вызовов.

HTTP query-параметры не передаются в repository сырым массивом. Контроллер сначала мапит Request в ContentFilterDto:

// По умолчанию без фильтра по active (как /disease, /article):
ContentFilterDto::fromRequest($request)

// Легаси: если параметр active не передан, считать active = true (news, promo, medical-center, site-services):
ContentFilterDto::fromRequest($request, defaultActive: true)

Так слой БД получает типизированный объект с уже разобранными значениями (?int, ?bool, ?string), а не array<string, mixed> из HTTP.

Нежелательные формы query вроде ?regionId[]=1 или ?active[]=1 отдают Symfony в get() как массив: ContentFilterDto обрабатывает только scalar-значения для числовых и булевых полей, такие случаи трактуются как «фильтр не задан» и не вызывают 500 (TypeError).

Поддерживаемые query-параметры:

Параметр Тип Что делает
regionId / region_id integer фильтр по региону
active boolean фильтр активности; для /news, /promo, /medical-center, /site-services/list при отсутствии параметра применяется active = true (легаси). Для /disease и /article без параметра фильтр по active не накладывается
alias string точное совпадение alias
search / q string поиск по LOWER(name) LIKE ...

Для больших таблиц под search нужен функциональный индекс PostgreSQL по LOWER(name). Если решите заменить это на ILIKE, потребуется отдельная Doctrine DQL-функция или переход на native SQL для этого фильтра.

Поле, по которому ищем, параметризовано в трейте: applyCommonFilters($qb, $alias, $filters, $searchField = 'name'). Все шесть текущих сущностей имеют свойство $name, поэтому в репозиториях оно передаётся неявно по умолчанию. Если новая сущность хранит основное название в другом поле (например, title), достаточно явно прокинуть его именем в трейт.

Naming strategy

В config/packages/serializer.yaml сознательно не настроен NameConverter. JSON-ключи запросов и ответов используют camelCase ровно так, как названы свойства сущности (regionId, previewPicture, updateAt, …). Если клиент пришлёт snake_case (region_id, preview_picture), Symfony Serializer молча проигнорирует такие ключи, и поля не запишутся в сущность - это и есть причина, по которой старые *CrudService поддерживали оба формата вручную через array_key_exists. Теперь поддержка обоих форматов сознательно убрана: клиент должен присылать консистентный camelCase, иначе будет тихая потеря данных.

Пример запроса:

curl 'http://localhost:8081/news/list?page=1&perPage=20&regionId=91&active=true&search=акция'

Create / Update / Delete

Общая логика записи вынесена в App\Service\Crud\CrudResponder.

Что делает CrudResponder:

  • проверяет, что body - JSON-объект (ловит и нативный \JsonException, и Symfony\...\HttpFoundation\Exception\JsonException);
  • денормализует payload через DenormalizerInterface::denormalize($array, $class, null, [...]), без дополнительного json_encode/deserialize round-trip;
  • использует write-группу сущности, например news:write;
  • удаляет id из payload перед денормализацией для create/update;
  • валидирует entity через Symfony Validator и при ошибке отдаёт HTTP 400 + сериализованный ConstraintViolationList (формат Symfony по умолчанию, RFC 7807 с ключом violations) - тот же формат, что отдавали старые *CrudService-контроллеры;
  • делает persist/flush для create;
  • делает flush для update;
  • делает remove/flush для delete; при ошибке БД (например, foreign key constraint) ловит Doctrine\DBAL\Exception и возвращает HTTP 500 + {error, message} - сохраняем легаси-контракт старого ArticleController::delete;
  • сериализует ответ через read-группу, например news:read.

Контракты ответов на ошибки

Сценарий HTTP Тело
Тело не JSON-объект 400 {"error": "Ожидается JSON-объект в теле запроса"}
Денормализация упала (например, ждали int, прислали object) 400 {"error": "Ошибка десериализации: ..."}
Symfony Validator нашёл нарушения 400 сериализованный ConstraintViolationList (type, title, detail, violations: [{propertyPath, title, template, parameters}])
Удаление упало в БД (FK / not null / unique) 500 {"error": "Ошибка при удалении записи", "message": "..."}
Успешный delete 204 пустое тело

Create:

return $this->crud->create($request, News::class, ['news:write'], ['news:read']);

Update:

return $this->crud->update($request, $news, ['news:write'], ['news:read']);

Delete:

return $this->crud->delete($news);

Почему не ручной updateEntity

Раньше в сервисах были большие методы вида:

if (array_key_exists('name', $data)) {
    $news->setName($data['name']);
}

if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) {
    $v = $data['regionId'] ?? $data['region_id'];
    $news->setRegionId($v === null || $v === '' ? null : (int) $v);
}

Такой подход плохо масштабируется:

  • одинаковые проверки копируются между сущностями;
  • легко забыть поле;
  • легко по-разному обработать один и тот же тип;
  • controller/service превращается в ручной mapper;
  • сложнее держать безопасность записи через allowlist.

Теперь allowlist полей задаётся serializer groups прямо в entity:

#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['news:read', 'news:write'])]
private ?string $name = null;

#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
#[Groups(['news:read'])]
private ?int $id = null;

Если поле не входит в *:write, клиент не может изменить его через PUT.

По текущей проверке write-группы у контентных сущностей содержат только scalar/json поля. ORM-связей (OneToMany, ManyToMany и т.п.) в этих шести сущностях нет. Если такие связи появятся позже, их нельзя автоматически добавлять в *:write: сначала нужно отдельно решить, можно ли менять эту связь через публичный/admin JSON API.

Особенность id

Для публичного/admin CRUD id должен генерироваться приложением/БД, а не приходить от клиента. У контентных сущностей настроен GeneratedValue(strategy: "IDENTITY"), а миграция добавляет sequence/default для таблиц, где id раньше был assigned.

Поэтому поведение такое:

  • на POST /create CrudResponder по умолчанию удаляет id из payload до десериализации;
  • на PUT /{id} CrudResponder всегда удаляет id из payload до десериализации;
  • даже если кто-то случайно добавит id в *:write group, первичный ключ не будет перезаписан через HTTP CRUD.

Это защищает от ситуации, когда клиент обновляет /news/10, но присылает "id": 999 и случайно/намеренно меняет первичный ключ.

Пример create:

{
  "name": "Новость из админки",
  "active": true,
  "regionId": 91,
  "alias": "admin-news",
  "anons": "Короткий анонс",
  "content": "Полный текст"
}

Пример update:

{
  "id": 123,
  "name": "Новое название",
  "active": false
}

В этом update поле id будет проигнорировано.

Синхронизация из Bitrix/view остаётся отдельным SQL-процессом: syncFromView* по-прежнему вставляет исторические id напрямую через INSERT ... SELECT ... ON CONFLICT. PostgreSQL sequence настроена так, чтобы новые id брались выше текущего MAX(id).

update_at

Поле updateAt больше не входит в *:write, поэтому фронтенд не управляет датой обновления напрямую.

Для автоматического заполнения используется Doctrine lifecycle callback:

#[ORM\HasLifecycleCallbacks]
class News
{
    use UpdateTimestampTrait;
}

UpdateTimestampTrait:

  • на PrePersist ставит текущую метку времени (\DateTimeImmutable), если updateAt ещё пустой;
  • на PreUpdate обновляет updateAt новой DateTimeImmutable;
  • в трейте объявлен @property \DateTimeInterface|null $updateAt для статического анализа (само поле остаётся в сущности с #[Groups]).

Импорт из Bitrix/view продолжает писать update_at напрямую SQL-ом и не зависит от HTTP CRUD lifecycle callbacks.

Синхронизация из Bitrix/view

У сервисов NewsCrudService, PromoCrudService, DiseaseCrudService, MedicalCenterCrudService, SiteServiceCrudService теперь оставлена только ответственность за импорт:

  • NewsCrudService::syncFromViewNews();
  • PromoCrudService::syncFromViewPromo();
  • DiseaseCrudService::syncFromViewDisease();
  • MedicalCenterCrudService::syncFromViewCenters();
  • SiteServiceCrudService::syncFromViewServices().

Эти методы используются командами:

  • UploadNewsCommand;
  • UploadPromoCommand;
  • UploadDiseasesCommand;
  • UploadMedicalCentersCommand;
  • UploadSiteServicesCommand.

Идея разделения:

  • HTTP CRUD - это controller + CrudResponder + Paginator + repository;
  • импорт из внешних view - это sync service + console command.

Так backend-разработчику проще понять, где менять API-поведение, а где менять импорт.

Swagger

Контентные маршруты должны попадать в Swagger через apps/backend/config/packages/nelmio_api_doc.yaml.

Path patterns:

path_patterns: [
    '^/news($|/)',
    '^/promo($|/)',
    '^/disease($|/)',
    '^/medical-center($|/)',
    '^/article($|/)',
    '^/site-services($|/)'
]

Swagger UI локально:

http://localhost:8081/docs

OpenAPI JSON:

http://localhost:8081/api/doc.json

Как добавить новый похожий CRUD-ресурс

  1. Проверить entity и serializer groups:
#[Groups(['example:read', 'example:write'])]
private ?string $name = null;

#[Groups(['example:read'])]
private ?\DateTimeInterface $updateAt = null;

#[Groups(['example:read'])]
private ?int $id = null;
  1. Если у сущности есть updateAt, подключить lifecycle callbacks:
#[ORM\HasLifecycleCallbacks]
class Example
{
    use UpdateTimestampTrait;
}
  1. В repository добавить createFilteredQueryBuilder:
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
    $qb = $this->createQueryBuilder('e')->orderBy('e.id', 'DESC');

    $this->applyCommonFilters($qb, 'e', $filters);

    return $qb;
}
  1. В controller подключить CrudResponder и Paginator.

  2. Для list использовать (для легаси-поведения «только активные», если клиент не передал active, см. второй аргумент — как у /news, /promo, /medical-center, /site-services):

$qb = $repository->createFilteredQueryBuilder(
    ContentFilterDto::fromRequest($request, defaultActive: true),
);

return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
    'groups' => self::READ_GROUPS,
]);

Если фильтр по активности по умолчанию не нужен (/disease, /article), передайте только $request.

  1. Для write-операций использовать:
return $this->crud->create($request, Example::class, self::WRITE_GROUPS, self::READ_GROUPS);
return $this->crud->update($request, $example, self::WRITE_GROUPS, self::READ_GROUPS);
return $this->crud->delete($example);
  1. Добавить #[OA\RequestBody(... Model(... groups: self::WRITE_GROUPS))] на create() и update().

  2. Добавить path pattern в nelmio_api_doc.yaml.

  3. Проверить маршруты:

php bin/console debug:router --env=dev

Проверка после изменений

Внутри контейнера backend:

cd /var/www/backend
composer dump-autoload -o
php bin/console cache:clear --env=dev --no-warmup
php bin/console cache:warmup --env=dev
php bin/console debug:router --env=dev

Публичная проверка списка:

curl 'http://localhost:8081/news/list?page=1&perPage=2'

Ожидаемый минимум:

{
  "data": [],
  "pagination": {
    "total": 0,
    "count": 0,
    "per_page": 2,
    "current_page": 1,
    "total_pages": 1,
    "has_previous_page": false,
    "has_next_page": false
  }
}

Проверка legacy-контракта статей:

curl 'http://localhost:8081/article/list?page=1&limit=2'

Ожидаемые верхнеуровневые ключи:

{
  "data": [],
  "meta": {
    "total": 0,
    "page": 1,
    "limit": 2,
    "totalPages": 1
  }
}

Для POST, PUT, DELETE нужен JWT пользователя с ролью ROLE_ADMIN.

Что не надо возвращать обратно

Не стоит возвращать:

  • отдельные getPaginatedList() в каждый CRUD-service;
  • ручные findByFilters() и countByFilters() для каждой сущности;
  • json_decode в каждом controller;
  • большие updateEntity() с десятками array_key_exists;
  • ручное изменение id в PUT.

Эти вещи уже закрыты общими классами и serializer groups.

Ветки Git и MR-файлы

Чтобы не смешивать архитектуру и шесть CRUD в одном MR, история разнесена по веткам. Ветка issues/27 — интеграционная (всё сразу), для новых MR не использовать.

origin/dev
  └── feature/content-crud-architecture    # общая инфраструктура (MR #1)
        ├── feature/content-crud-news      # MR по ресурсу
        ├── feature/content-crud-promo
        ├── feature/content-crud-disease
        ├── feature/content-crud-medical-center
        ├── feature/content-crud-article
        └── feature/content-crud-site-services

UI админки: admin-panel-content-crud — ветка issues/27-refactor от dev.

Файлы MR (корень монорепозитория)

Сгенерированы скриптом scripts/generate-backend-mr.sh (база origin/dev, не в git):

MR diff HTML
Архитектура mr-backend-content-crud-architecture.diff MR/mr-backend-content-crud-architecture.html
Новости mr-backend-content-crud-news.diff MR/mr-backend-content-crud-news.html
Промо mr-backend-content-crud-promo.diff MR/mr-backend-content-crud-promo.html
Заболевания mr-backend-content-crud-disease.diff MR/mr-backend-content-crud-disease.html
Медцентры mr-backend-content-crud-medical-center.diff MR/mr-backend-content-crud-medical-center.html
Статьи mr-backend-content-crud-article.diff MR/mr-backend-content-crud-article.html
Услуги сайта mr-backend-content-crud-site-services.diff MR/mr-backend-content-crud-site-services.html

Пересборка: ./scripts/generate-backend-mr.sh из корня репозитория.

MR 1 — feature/content-crud-architecture

Показать разработчикам общую доработку (без контроллеров и без привязки к одной сущности):

Файл Назначение
src/Dto/Content/ContentFilterDto.php query-параметры list (search, page, regionId, active)
src/Service/Pagination/Paginator.php Pagerfanta, pagination / meta
src/Repository/ContentFilterTrait.php фильтры в QueryBuilder
migrations/Version20260515142000.php IDENTITY + SEQUENCE / setval для контент-таблиц
src/Service/Crud/CrudResponder.php create/read/update/delete, валидация, denormalize
src/Entity/Behavior/UpdateTimestampTrait.php updateAt на persist

В полной ветке architecture также есть config/validator/ContentCrud.yaml и правки всех Entity — в точечных MR по ресурсу попадают изменения своей сущности (см. ниже).

MR 2…7 — ветки по ресурсу

Частичная реализация поверх архитектуры: тонкий контроллер, репозиторий с ContentFilterTrait, *CrudService (sync из view), правки entity.

Пример новостей (feature/content-crud-news):

Файл Что в MR
config/packages/nelmio_api_doc.yaml только строка `'^/news($
src/Controller/NewsController.php CRUD-маршруты
src/Entity/News.php trait, groups, GeneratedValue
src/Repository/NewsRepository.php list + фильтры
src/Service/NewsCrudService.php sync из view
Ветка Свои файлы (аналогично news)
feature/content-crud-promo PromoController, Promo.php, PromoRepository, PromoCrudService, `'^/promo($
feature/content-crud-disease DiseaseController, Disease.php, DiseaseRepository, DiseaseCrudService, `'^/disease($
feature/content-crud-medical-center MedicalCenterController, MedicalCenter.php, MedicalCenterRepository, MedicalCenterCrudService, `'^/medical-center($
feature/content-crud-article ArticleController, Article.php, ArticleRepository, `'^/article($
feature/content-crud-site-services SiteServiceController, SiteService.php, SiteServiceRepository, SiteServiceCrudService, `'^/site-services($

Порядок мержа: architecture → любые resource (параллельно). issues/27 — только локальная интеграция, не для review.