From 20b1b0983fc362fdda9eac38812540b38e872718 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 16 Jul 2026 00:09:57 +0600 Subject: [PATCH] feat(web-api): implement public product catalog endpoints Closes #332 --- assets/components/minishop3/api.php | 2 + .../minishop3/config/routes/web.php | 12 +- .../minishop3/lexicon/en/default.inc.php | 2 + .../minishop3/lexicon/ru/default.inc.php | 2 + .../Controllers/Api/Web/ProductController.php | 77 ++++ .../src/Middleware/TokenMiddleware.php | 2 +- .../minishop3/src/ServiceRegistry.php | 4 + .../Product/ProductCatalogService.php | 420 ++++++++++++++++++ .../tests/ProductCatalogServiceTest.php | 109 +++++ readme.md | 15 + 10 files changed, 638 insertions(+), 7 deletions(-) create mode 100644 core/components/minishop3/src/Controllers/Api/Web/ProductController.php create mode 100644 core/components/minishop3/src/Services/Product/ProductCatalogService.php create mode 100644 core/components/minishop3/tests/ProductCatalogServiceTest.php diff --git a/assets/components/minishop3/api.php b/assets/components/minishop3/api.php index 3ce8f978..22b5f68f 100644 --- a/assets/components/minishop3/api.php +++ b/assets/components/minishop3/api.php @@ -10,6 +10,8 @@ * * Использование: * api.php?route=/api/v1/customer/token/get + * api.php?route=/api/v1/product/list + * api.php?route=/api/v1/product/get/123 * api.php?route=/api/v1/cart/add * * @package MiniShop3 diff --git a/core/components/minishop3/config/routes/web.php b/core/components/minishop3/config/routes/web.php index a3381528..8b9a5e12 100644 --- a/core/components/minishop3/config/routes/web.php +++ b/core/components/minishop3/config/routes/web.php @@ -1,4 +1,5 @@ cancel($params); }, [$tokenMiddleware]); - }); + // Public catalog — no TokenMiddleware (headless storefront without customer session) $router->group('/product', function($router) use ($modx) { - $router->get('/get/{id}', function($params) use ($modx) { - return Response::success(['message' => 'Product get endpoint - not implemented yet', 'id' => $params['id'] ?? null]); + $controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx); + return $controller->get($params); }); - $router->get('/list', function($params) use ($modx) { - return Response::success(['message' => 'Product list endpoint - not implemented yet']); + $controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx); + return $controller->getList($params); }); - }); $router->get('/health', function() use ($modx) { return Response::success([ diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 2a79dfe9..9b5f5779 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -173,6 +173,8 @@ $_lang['ms3_repeater_validation_error'] = 'Repeater field "[[+field]]": [[+error]]'; $_lang['ms3_err_user_nf'] = 'User not found.'; +$_lang['ms3_err_product_nf'] = 'Product not found.'; +$_lang['ms3_err_product_id_ns'] = 'Product ID is required.'; $_lang['ms3_err_order_nf'] = 'Order with this identifier not found.'; $_lang['ms3_err_order_load'] = 'Error loading order.'; $_lang['ms3_err_status_nf'] = 'Status with this identifier not found.'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 1a01983e..d17b41e7 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -173,6 +173,8 @@ $_lang['ms3_repeater_validation_error'] = 'Поле повторителя «[[+field]]»: [[+error]]'; $_lang['ms3_err_user_nf'] = 'Пользователь не найден.'; +$_lang['ms3_err_product_nf'] = 'Товар не найден.'; +$_lang['ms3_err_product_id_ns'] = 'Не указан ID товара.'; $_lang['ms3_err_order_nf'] = 'Заказ с таким идентификатором не найден.'; $_lang['ms3_err_order_load'] = 'Ошибка при загрузке заказа.'; $_lang['ms3_err_status_nf'] = 'Статус с таким идентификатором не найден.'; diff --git a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php new file mode 100644 index 00000000..8c82e809 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php @@ -0,0 +1,77 @@ +modx = $modx; + $this->modx->lexicon->load('minishop3:default'); + } + + /** + * GET /api/v1/product/get/{id} + * + * @param array $params + */ + public function get(array $params = []): Response + { + $productId = (int) ($params['id'] ?? 0); + + if ($productId <= 0) { + return Response::error( + $this->modx->lexicon('ms3_err_product_id_ns'), + HttpStatus::BAD_REQUEST + ); + } + + $product = $this->catalog()->getById($productId, $params); + + if ($product === null) { + return Response::error( + $this->modx->lexicon('ms3_err_product_nf'), + HttpStatus::NOT_FOUND + ); + } + + return Response::success($product); + } + + /** + * GET /api/v1/product/list + * + * Query: parent|category, limit, offset|page, sort, dir, query, + * context, include_options, include_content + * + * @param array $params Route + query params (Router merges $_GET) + */ + public function getList(array $params = []): Response + { + $result = $this->catalog()->getList($params); + + return Response::success($result); + } + + private function catalog(): ProductCatalogService + { + /** @var ProductCatalogService $service */ + $service = $this->modx->services->get('ms3_product_catalog'); + + return $service; + } +} diff --git a/core/components/minishop3/src/Middleware/TokenMiddleware.php b/core/components/minishop3/src/Middleware/TokenMiddleware.php index 46a0c6cd..581be60d 100644 --- a/core/components/minishop3/src/Middleware/TokenMiddleware.php +++ b/core/components/minishop3/src/Middleware/TokenMiddleware.php @@ -20,7 +20,7 @@ * Cookie injection at start of handle() copies $_COOKIE['ms3_token'] → $_REQUEST['ms3_token'] * for backward compatibility with controllers reading $_REQUEST. * - * For public endpoints (cart/get, product/get) token is optional. + * For public endpoints listed in $publicRoutes (product/*, token/get, …) token is optional. * For non-public endpoints without token — auto-creates anonymous token. */ class TokenMiddleware implements MiddlewareInterface diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 44840b80..0ba31a1f 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -63,6 +63,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Product\ProductDataService::class, 'interface' => null, ], + 'ms3_product_catalog' => [ + 'class' => \MiniShop3\Services\Product\ProductCatalogService::class, + 'interface' => null, + ], 'ms3_repeater_field' => [ 'class' => \MiniShop3\Services\ExtraFields\RepeaterFieldService::class, 'interface' => null, diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogService.php b/core/components/minishop3/src/Services/Product/ProductCatalogService.php new file mode 100644 index 00000000..86dbae53 --- /dev/null +++ b/core/components/minishop3/src/Services/Product/ProductCatalogService.php @@ -0,0 +1,420 @@ + */ + private const RESOURCE_FIELDS = [ + 'id', + 'pagetitle', + 'longtitle', + 'description', + 'introtext', + 'alias', + 'uri', + 'parent', + 'menuindex', + 'context_key', + 'publishedon', + 'createdon', + 'editedon', + ]; + + /** @var list */ + private const DATA_FIELDS = [ + 'article', + 'price', + 'old_price', + 'stock', + 'weight', + 'image', + 'thumb', + 'vendor_id', + 'made_in', + 'new', + 'popular', + 'favorite', + ]; + + /** @var array sort request key => SQL expression */ + private const SORT_MAP = [ + 'id' => 'msProduct.id', + 'pagetitle' => 'msProduct.pagetitle', + 'menuindex' => 'msProduct.menuindex', + 'createdon' => 'msProduct.createdon', + 'publishedon' => 'msProduct.publishedon', + 'price' => 'Data.price', + 'article' => 'Data.article', + ]; + + public function __construct( + private modX $modx, + ) { + } + + /** + * Clamp list page size (default 20, max 100). + * + * @param array $params + */ + public static function resolveLimit(array $params): int + { + $limit = (int) ($params['limit'] ?? self::DEFAULT_LIMIT); + if ($limit < 1) { + return self::DEFAULT_LIMIT; + } + + return min($limit, self::MAX_LIMIT); + } + + /** + * Offset from explicit offset or 1-based page. + * + * @param array $params + */ + public static function resolveOffset(array $params, int $limit): int + { + if (isset($params['offset']) && $params['offset'] !== '') { + return max(0, (int) $params['offset']); + } + + $page = (int) ($params['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + + return ($page - 1) * $limit; + } + + /** + * Whitelisted sort column + ASC|DESC. + * + * @param array $params + * @return array{0: string, 1: string} SQL field, direction + */ + public static function resolveSort(array $params): array + { + $sortKey = strtolower(trim((string) ($params['sort'] ?? 'menuindex'))); + $sortField = self::SORT_MAP[$sortKey] ?? self::SORT_MAP['menuindex']; + + $dir = strtoupper(trim((string) ($params['dir'] ?? $params['sortdir'] ?? 'ASC'))); + if ($dir !== 'DESC') { + $dir = 'ASC'; + } + + return [$sortField, $dir]; + } + + /** + * Keep option values only; drop dotted metadata keys (color.caption, …). + * + * @param array $options + * @return array + */ + public static function stripOptionMetadata(array $options): array + { + $result = []; + foreach ($options as $key => $value) { + if (!is_string($key) || str_contains($key, '.')) { + continue; + } + $result[$key] = $value; + } + + return $result; + } + + /** + * Drop keys outside the public catalog contract (after plugin modifyFields). + * + * @param array $payload + * @return array + */ + public static function whitelistPublicPayload( + array $payload, + bool $includeContent, + bool $includeOptions, + ): array { + $allowed = array_merge(self::RESOURCE_FIELDS, self::DATA_FIELDS); + if ($includeContent) { + $allowed[] = 'content'; + } + + $result = []; + foreach ($allowed as $field) { + if (array_key_exists($field, $payload)) { + $result[$field] = $payload[$field]; + } + } + + if ($includeOptions && isset($payload['options']) && is_array($payload['options'])) { + $result['options'] = self::stripOptionMetadata($payload['options']); + } + + return $result; + } + + public static function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true); + } + + /** + * Single published product by ID (same visibility rules as list). + * + * @param array $params Optional context override + * @return array|null + */ + public function getById(int $productId, array $params = []): ?array + { + if ($productId <= 0) { + return null; + } + + $criteria = ['id' => $productId]; + $context = $this->resolveContext($params); + if ($context !== '') { + $criteria['context_key'] = $context; + } + + /** @var msProduct|null $product */ + $product = $this->modx->getObject(msProduct::class, $this->publicCriteria($criteria)); + + if (!$product) { + return null; + } + + $options = self::stripOptionMetadata( + $this->optionService()->loadOptionsForProduct($productId, false) + ); + + return $this->formatProduct($product, true, $options); + } + + /** + * Paginated product list. + * + * Supported filters in $params: + * - parent|category: primary parent resource id (not msCategoryMember) + * - limit, offset | page + * - sort, dir (ASC|DESC) + * - query: pagetitle / article search + * - context: MODX context key (default: current) + * - include_options: 0|1 (default 0 for list) + * - include_content: 0|1 (default 0 for list) + * + * @param array $params + * @return array{items: list>, total: int, limit: int, offset: int} + */ + public function getList(array $params): array + { + $limit = self::resolveLimit($params); + $offset = self::resolveOffset($params, $limit); + $includeOptions = self::toBool($params['include_options'] ?? false); + $includeContent = self::toBool($params['include_content'] ?? false); + + $total = $this->countList($params); + + $listQuery = $this->buildListQuery($params); + $this->applySort($listQuery, $params); + $listQuery->limit($limit, $offset); + + /** @var list $products */ + $products = $this->modx->getCollection(msProduct::class, $listQuery) ?: []; + + $optionsByProduct = []; + if ($includeOptions && $products !== []) { + $ids = array_map(static fn (msProduct $p) => (int) $p->get('id'), array_values($products)); + $optionsByProduct = $this->loadOptionsForProducts($ids); + } + + $items = []; + foreach ($products as $product) { + $productId = (int) $product->get('id'); + $options = $includeOptions ? ($optionsByProduct[$productId] ?? []) : null; + $items[] = $this->formatProduct($product, $includeContent, $options); + } + + return [ + 'items' => $items, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]; + } + + /** + * @param array $extra + * @return array + */ + private function publicCriteria(array $extra = []): array + { + return array_merge([ + 'class_key' => msProduct::class, + 'published' => 1, + 'deleted' => 0, + 'hidemenu' => 0, + ], $extra); + } + + /** + * @param array $params + */ + private function resolveContext(array $params): string + { + return trim((string) ($params['context'] ?? $this->modx->context->key ?? 'web')); + } + + /** + * @param array $params + */ + private function countList(array $params): int + { + $countQuery = $this->buildListQuery($params); + $countQuery->select('COUNT(DISTINCT msProduct.id)'); + if (!$countQuery->prepare() || !$countQuery->stmt->execute()) { + return 0; + } + + return (int) $countQuery->stmt->fetchColumn(); + } + + /** + * @param array $params + */ + private function buildListQuery(array $params): xPDOQuery + { + $c = $this->modx->newQuery(msProduct::class); + $c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id'); + $c->where($this->publicCriteria()); + + $context = $this->resolveContext($params); + if ($context !== '') { + $c->where(['msProduct.context_key' => $context]); + } + + $parent = (int) ($params['parent'] ?? $params['category'] ?? 0); + if ($parent > 0) { + $c->where(['msProduct.parent' => $parent]); + } + + $query = trim((string) ($params['query'] ?? '')); + if ($query !== '') { + $c->where([ + 'msProduct.pagetitle:LIKE' => '%' . $query . '%', + 'OR:Data.article:LIKE' => '%' . $query . '%', + ]); + } + + return $c; + } + + /** + * @param array $params + */ + private function applySort(xPDOQuery $query, array $params): void + { + [$sortField, $dir] = self::resolveSort($params); + $query->sortby($sortField, $dir); + } + + /** + * @param list $productIds + * @return array> + */ + private function loadOptionsForProducts(array $productIds): array + { + $raw = $this->optionService()->loadOptionsForProducts($productIds, false); + + $cleaned = []; + foreach ($raw as $productId => $options) { + $cleaned[(int) $productId] = self::stripOptionMetadata($options); + } + + return $cleaned; + } + + private function optionService(): OptionService + { + /** @var OptionService $service */ + $service = $this->modx->services->get('ms3_option_service'); + + return $service; + } + + /** + * @param array|null $options null = omit options key; array = include + * @return array + */ + private function formatProduct( + msProduct $product, + bool $includeContent, + ?array $options = null, + ): array { + $data = $product->loadData(); + $payload = []; + + foreach (self::RESOURCE_FIELDS as $field) { + $payload[$field] = $product->get($field); + } + + $dataValues = []; + if ($data) { + foreach (self::DATA_FIELDS as $field) { + $dataValues[$field] = $data->get($field); + } + } else { + foreach (self::DATA_FIELDS as $field) { + $dataValues[$field] = null; + } + } + + $originalPrice = (float) ($dataValues['price'] ?? 0); + $price = (float) $product->getPrice($dataValues); + $dataValues['price'] = $price; + if ($price < $originalPrice && empty($dataValues['old_price'])) { + $dataValues['old_price'] = $originalPrice; + } + $dataValues['weight'] = (float) $product->getWeight($dataValues); + + $payload = array_merge($payload, $dataValues); + + if ($includeContent) { + $payload['content'] = $product->get('content'); + } + + if ($options !== null) { + $payload['options'] = $options; + } + + $modified = $product->modifyFields($payload); + if (is_array($modified)) { + $payload = $modified; + } + + return self::whitelistPublicPayload($payload, $includeContent, $options !== null); + } +} diff --git a/core/components/minishop3/tests/ProductCatalogServiceTest.php b/core/components/minishop3/tests/ProductCatalogServiceTest.php new file mode 100644 index 00000000..30d36c0b --- /dev/null +++ b/core/components/minishop3/tests/ProductCatalogServiceTest.php @@ -0,0 +1,109 @@ + 0]), 'invalid limit falls back'); +$assertSame(50, ProductCatalogService::resolveLimit(['limit' => 50]), 'custom limit'); +$assertSame(100, ProductCatalogService::resolveLimit(['limit' => 500]), 'limit capped at 100'); + +$assertSame(0, ProductCatalogService::resolveOffset([], 20), 'default offset'); +$assertSame(40, ProductCatalogService::resolveOffset(['page' => 3], 20), 'page to offset'); +$assertSame(0, ProductCatalogService::resolveOffset(['page' => 0], 20), 'page < 1 → first page'); +$assertSame(15, ProductCatalogService::resolveOffset(['offset' => 15], 20), 'explicit offset'); +$assertSame(0, ProductCatalogService::resolveOffset(['offset' => -5], 20), 'negative offset clamped'); +$assertSame(30, ProductCatalogService::resolveOffset(['offset' => 30, 'page' => 9], 20), 'offset wins over page'); + +[$field, $dir] = ProductCatalogService::resolveSort([]); +$assertSame('msProduct.menuindex', $field, 'default sort field'); +$assertSame('ASC', $dir, 'default sort dir'); + +[$field, $dir] = ProductCatalogService::resolveSort(['sort' => 'price', 'dir' => 'desc']); +$assertSame('Data.price', $field, 'price sort field'); +$assertSame('DESC', $dir, 'desc dir'); + +[$field, $dir] = ProductCatalogService::resolveSort(['sort' => 'unknown', 'sortdir' => 'DESC']); +$assertSame('msProduct.menuindex', $field, 'unknown sort → menuindex'); +$assertSame('DESC', $dir, 'sortdir alias'); + +[$field, $dir] = ProductCatalogService::resolveSort(['sort' => 'id', 'dir' => 'sideways']); +$assertSame('msProduct.id', $field, 'id sort'); +$assertSame('ASC', $dir, 'invalid dir → ASC'); + +$assertSame(true, ProductCatalogService::toBool(true), 'bool true'); +$assertSame(false, ProductCatalogService::toBool(false), 'bool false'); +$assertSame(true, ProductCatalogService::toBool('1'), 'string 1'); +$assertSame(true, ProductCatalogService::toBool('yes'), 'string yes'); +$assertSame(false, ProductCatalogService::toBool('0'), 'string 0'); +$assertSame(false, ProductCatalogService::toBool('no'), 'string no'); + +$assertSame( + ['color' => ['Red'], 'size' => ['M']], + ProductCatalogService::stripOptionMetadata([ + 'color' => ['Red'], + 'color.caption' => 'Color', + 'size' => ['M'], + 'size.description' => 'Size chart', + 0 => 'orphan', + ]), + 'strip dotted option metadata' +); + +$assertSame( + [ + 'id' => 1, + 'pagetitle' => 'Tea', + 'price' => 10.5, + 'content' => '

x

', + 'options' => ['color' => ['Red']], + ], + ProductCatalogService::whitelistPublicPayload([ + 'id' => 1, + 'pagetitle' => 'Tea', + 'price' => 10.5, + 'content' => '

x

', + 'options' => ['color' => ['Red'], 'color.caption' => 'Color'], + 'internal_secret' => 'leak', + 'template' => 5, + ], true, true), + 'whitelist drops unknown keys, keeps content/options' +); + +$assertSame( + [ + 'id' => 2, + 'pagetitle' => 'Coffee', + ], + ProductCatalogService::whitelistPublicPayload([ + 'id' => 2, + 'pagetitle' => 'Coffee', + 'content' => '

hidden

', + 'options' => ['size' => ['L']], + 'tv_private' => 'x', + ], false, false), + 'whitelist omits content/options when flags off' +); + +fwrite(STDOUT, "OK ProductCatalogServiceTest\n"); +exit(0); diff --git a/readme.md b/readme.md index a7516600..2274f776 100644 --- a/readme.md +++ b/readme.md @@ -84,6 +84,21 @@ php _build/build.php - [REST API](https://docs.modx.pro/components/minishop3/development/api) — интеграция с внешними системами - [События](https://docs.modx.pro/components/minishop3/development/events) — расширение функциональности +### Каталог товаров (Web API) + +Публичные endpoints без токена. В выборку попадают только товары с `published=1`, `deleted=0`, `hidemenu=0` в указанном (или текущем) `context`. + +``` +GET /assets/components/minishop3/api.php?route=/api/v1/product/get/{id} +GET /assets/components/minishop3/api.php?route=/api/v1/product/list +``` + +Параметры: `parent` / `category` (только primary parent, без `msCategoryMember`), `limit` (max 100), `offset` / `page`, `sort` + `dir`, `query`, `context`, `include_options`, `include_content`. + +Ответ `list`: `{ items, total, limit, offset }`. Цена и вес — через `msOnGetProductPrice` / `msOnGetProductWeight`; поля ответа allowlist’ятся после `msOnGetProductFields`. + +Полный справочник REST — на [docs.modx.pro](https://docs.modx.pro/components/minishop3/development/api) (раздел каталога стоит синхронизировать с этим релизом). + ### Подтверждение email (Web API) Ссылка в письме ведёт на `api.php` с путём верификации и параметром `html=1` — в ответ сервер отдаёт **HTTP-редирект** (302) на сайт с признаком `ms3_email_verified=1` либо `ms3_email_verified=0`. URL после успешной проверки задаётся системной настройкой `ms3_email_verification_success_url` (если пусто — `site_url`).