Skip to content

[Feature] Web API: расширенные фильтры product/list для headless #564

Closed

Description

@Ibochkarev

Описание функции

Расширить публичный 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

  1. ms3_products (сниппет) — optionFilters (JSON, JOIN msProductOption, префиксы OR:/AND:), parents + CategoryProductScopeService (nested + msCategoryMember, [Bug] msProducts не выводит товары дополнительных категорий на витрине #481), showZeroPrice, join Vendor.
  2. CategoryProductsListService (Manager) — filter_* по колонкам/опциям грида, nested через CategoryTreeService. Не публичный канал.
  3. 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=1 later на том же applier (дорого без кэша).

Обратная совместимость

  • Без новых параметров — тот же SQL scope, что сейчас.
  • parent / category не ломать; parents + nested — additive.
  • Не менять shape items / allowlist полей.
  • Не менять auth: list остаётся публичным (CORS + rate limit).

Затрагиваемые файлы / классы

Не трогать: Manager CategoryProductsListService контракт, сниппет ms3_products (можно выровнять семантику позже).

Тесты

  1. Smoke/unit: парсинг parents, price_min/max, flags, vendor_id, options JSON / bracket form.
  2. Reject: unknown option key, negative price_max < min, non-int vendor, oversized options map.
  3. Semantics: AND между ключами опций, OR внутри ключа; combo price ∩ option ∩ parent.
  4. nested=1 включает потомков / member products через scope service (с stub/fixtures).
  5. Regress: запрос только с parent+page совпадает с прежним поведением (контрактный тест на критерии).
  6. countList корректный при нескольких JOINах (DISTINCT).

Альтернативные варианты

  1. GET /api/v1/product/search — дублирует list, два клиента Nuxt. Имеет смысл только если search станет full-text/ES отдельно.
  2. Проброс optionFilters как в сниппете — мощно, но легко протащить небезопасные операторы/ключи; хуже для OpenAPI/Nuxt.
  3. Только клиентская фильтрация — не масштабируется, ломает total/SEO pagination.
  4. 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=web

Acceptance 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.price vs plugin getPrice().

Дополнительный контекст

  • Стиль: PHP 8.2+, логика в Services/, DI ServiceRegistry, публичный allowlist как у каталога.
  • Follow-up: facets endpoint; align getPrice/модификаторы с filter price; публичный vendor list для UI фильтра (если ещё нет).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestpriority: mediumСредний приоритет

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions