Repository navigation
[Feature] Web API: расширенные фильтры product/list для headless #564
Description
Описание функции
Расширить публичный GET /api/v1/product/list валидируемыми фильтрами витрины (цена, наличие, вендор, флаги, несколько категорий, опции с несколькими значениями), без сырого SQL и с сохранением текущего контракта.
Отдельный /product/search в этом issue не вводим: Nuxt уже ходит в product/list. Facets (counts по значениям) — follow-up на том же query-builder.
Проблема, которую решает
Nuxt-каталог с боковыми фильтрами не собрать на текущем Web API. Фронт либо тянет широкие страницы и фильтрует у себя, либо обходит API сниппетом msProducts / Manager API.
После #332 список товаров умеет пагинацию и простой поиск. Для production headless этого мало.
Текущие возможности (проверено в коде)
Источник: ProductCatalogService::buildListQuery / docblock getList, ProductController::getList, config/routes/web.php.
| Параметр | Поведение |
|---|---|
parent | category |
Один int → msProduct.parent = id. Без nested, без msCategoryMember |
query |
pagetitle LIKE OR Data.article LIKE |
context |
msProduct.context_key |
limit / offset | page |
Clamp (default 20, max 100) |
sort / dir |
Whitelist: id, pagetitle, menuindex, createdon, publishedon, price, article |
include_options |
Только ответ: подгрузка опций после выборки |
include_content |
Только ответ |
Фиксированный scope витрины: class_key=msProduct, published=1, deleted=0, hidemenu=0.
В ответе уже есть поля price, stock, vendor_id, new, popular, favorite (DATA_FIELDS), но в WHERE они не участвуют.
Чего нет в Web API
| Возможность | В ProductCatalogService |
|---|---|
| Диапазон цены | Нет |
| Наличие / stock | Нет |
vendor_id / бренд |
Нет |
Флаги new / popular / favorite |
Нет |
| Несколько категорий / nested / доп. категории | Нет (один parent) |
| Фильтр по опциям | Нет (include_options ≠ filter) |
| Несколько значений одной опции | Нет |
| AND между разными ключами опций / OR внутри ключа | Нет |
| Комбинация фильтров (цена ∩ опции ∩ категория) | Нет инфраструктуры |
Где логика уже есть, но не в публичном REST
ms3_products(сниппет) —optionFilters(JSON, JOINmsProductOption, префиксыOR:/AND:),parents+CategoryProductScopeService(nested +msCategoryMember, [Bug] msProducts не выводит товары дополнительных категорий на витрине #481),showZeroPrice, join Vendor.CategoryProductsListService(Manager) —filter_*по колонкам/опциям грида, nested черезCategoryTreeService. Не публичный канал.ProductOptionLoader/OptionService— чтение значений опций, не построение витринного filter WHERE.
Дублировать mgr/сниппет «как есть» в Web API нельзя: нужен allowlist полей, без произвольных ключей и без SQL из клиента (см. урок #420 про filter_* на customers).
Связанные issues: #332 (product list, closed), #563 (Category API, отдельно). Отдельного issue на product filters не найдено.
Предлагаемое решение
Принцип
Расширить тот же GET /api/v1/product/list новыми query-параметрами. Старые клиенты без них работают как сейчас.
Вынести разбор фильтров в helper рядом с каталогом, например ProductCatalogFilterApplier / методы на ProductCatalogService, чтобы:
- валидировать ключи и типы;
- собирать xPDO WHERE/JOIN без сырого SQL от клиента;
- позже повесить facets на тот же applier.
Не открывать Manager filter_* публично. Не тащить полный синтаксис optionFilters сниппета (строковые операторы в ключах) — только явный JSON/query contract.
Контракт (черновик)
Все фильтры AND между собой. Внутри multi-value одного ключа опции — OR (товар подходит, если есть хотя бы одно из значений).
GET /api/v1/product/list
?context=web
&parents=12,15
&nested=1
&price_min=100
&price_max=5000
&in_stock=1
&vendor_id=3
&new=1
&options={"color":["red","blue"],"size":["M"]}
&query=чай
&sort=price&dir=ASC
&page=1&limit=24
| Параметр | Семантика |
|---|---|
parents |
Список ID категорий (CSV или повтор query). OR по категориям после resolve. Совместимость: одиночный parent|category остаётся |
nested |
0|1. При 1 — expand через CategoryProductScopeService / tree (как msProducts depth), плюс msCategoryMember |
price_min / price_max |
Фильтр по Data.price (stored). Документировать: плагины getPrice() меняют отображаемую цену в format, не обязательно колонку для WHERE |
in_stock |
1 → Data.stock > 0 (или >= порог; уточнить в PR по принятому смыслу stock в MS3) |
stock_min |
Опционально, int ≥ 0 |
vendor_id |
Один int или CSV → Data.vendor_id IN (...) |
new / popular / favorite |
Bool flags → Data.* = 1 |
options |
JSON object: ключ опции → scalar или array значений. Неизвестный ключ → 400. Пустой object игнорируется |
| существующие | query, context, sort, dir, limit, offset/page, include_* без изменений |
Альтернатива для Nuxt без JSON в query (тот же смысл):
options[color]=red,blue&options[size]=M
Парсер нормализует в ту же структуру. Один канонический путь в сервисе.
Пример ответа (без смены envelope)
Как сейчас:
{
"success": true,
"message": "",
"object": {
"items": [ { "id": 22, "pagetitle": "…", "price": 1990, "stock": 3, "vendor_id": 3, "…": "" } ],
"total": 42,
"limit": 24,
"offset": 0
}
}Ошибки валидации: HTTP 400 + лексикон (ms3_err_*), без SQL/stack в message.
Семантика опций (MySQL)
- JOIN
ms3_product_options(классmsProductOption) per option key:product_id+key = :key. - Multi-value одного ключа:
value IN (...)(OR внутри ключа). - Разные ключи: отдельные JOIN + AND (товар должен удовлетворять каждому ключу).
COUNT(DISTINCT msProduct.id)уже используется вcountList— сохранить при JOINах.- Ключи опций: только существующие
msOption.key(и при желании активные для scope категорий). Произвольные ключи отклонять.
Производительность
- Индексы: опираться на PK/indexes
ms3_product_options (product_id, key)иData.price/vendor_id(проверить meta/migrations в PR). - Лимит числа option-ключей в одном запросе (например ≤ 10) и длины CSV.
- Не делать N+1: фильтр в SQL,
include_optionsпо-прежнему batch после page. - Facets не в этом issue: отдельный endpoint или
include_facets=1later на том же applier (дорого без кэша).
Обратная совместимость
- Без новых параметров — тот же SQL scope, что сейчас.
parent/categoryне ломать;parents+nested— additive.- Не менять shape
items/ allowlist полей. - Не менять auth: list остаётся публичным (CORS + rate limit).
Затрагиваемые файлы / классы
Services/Product/ProductCatalogService.php—buildListQuery/ filter apply- новый helper (по желанию)
ProductCatalogFilterApplierили аналог вServices/Product/ Services/Category/CategoryProductScopeService.php— reuse дляparents+nested+membersControllers/Api/Web/ProductController.php— docblock query params- лексиконы ru/en — ошибки валидации фильтров
- тесты:
ProductCatalogServiceTest+ unit/integration на WHERE semantics - документация Web API (если ведётся рядом с [Feature] Web API: реализовать каталог товаров (product/get, product/list) #332/[Feature] Web API: публичный Category API для headless (list / get / tree) #563)
Не трогать: Manager CategoryProductsListService контракт, сниппет ms3_products (можно выровнять семантику позже).
Тесты
- Smoke/unit: парсинг
parents,price_min/max, flags,vendor_id,optionsJSON / bracket form. - Reject: unknown option key, negative price_max < min, non-int vendor, oversized options map.
- Semantics: AND между ключами опций, OR внутри ключа; combo price ∩ option ∩ parent.
nested=1включает потомков / member products через scope service (с stub/fixtures).- Regress: запрос только с
parent+pageсовпадает с прежним поведением (контрактный тест на критерии). countListкорректный при нескольких JOINах (DISTINCT).
Альтернативные варианты
GET /api/v1/product/search— дублирует list, два клиента Nuxt. Имеет смысл только если search станет full-text/ES отдельно.- Проброс
optionFiltersкак в сниппете — мощно, но легко протащить небезопасные операторы/ключи; хуже для OpenAPI/Nuxt. - Только клиентская фильтрация — не масштабируется, ломает
total/SEO pagination. - Facets в том же PR — раздувает scope; лучше follow-up.
Примеры использования
Nuxt PLP:
GET /api/v1/product/list?parents=12&nested=1&price_min=500&price_max=3000&options={"color":["чёрный"]}&in_stock=1&sort=price&dir=ASC&limit=24&page=1Бренд + флаг:
GET /api/v1/product/list?vendor_id=3&new=1&context=webAcceptance criteria
-
product/listпринимает documented filter params; без них поведение как до изменений. - Цена, stock/in_stock, vendor_id, new/popular/favorite фильтруют SQL (не post-filter PHP page).
-
parents(+ опциональноnested) используют category scope (primary + members при nested), не только одинparent. -
options: multi-value OR внутри ключа, AND между ключами; неизвестный ключ → 400. - Нет сырого SQL / произвольных имён колонок от клиента; allowlist полей и option keys.
- Пагинация
totalсогласована с фильтрами (DISTINCT при JOINах). - Тесты на парсинг, reject, semantics, regress без фильтров.
- Лексиконы ru+en для ошибок валидации.
- В описании PR/docs: оговорка stored
Data.pricevs plugingetPrice().
Дополнительный контекст
- Стиль: PHP 8.2+, логика в
Services/, DIServiceRegistry, публичный allowlist как у каталога. - Follow-up: facets endpoint; align
getPrice/модификаторы с filter price; публичный vendor list для UI фильтра (если ещё нет).
Activity
feat(web-api): product/filters — facets для headless PLP (#597)
chore: sync beta with upstream modx-pro/MiniShop3 (#1)