WooCommerce Code Reference

CatalogSearch.php

Source code

<?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;

use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;

/**
 * UCP `POST /catalog/search` route.
 */
class CatalogSearch extends AbstractCatalogRoute {
	/**
	 * The route identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'ucp-catalog-search';

	/**
	 * Maximum number of `filters.categories` references honoured per request.
	 *
	 * Each reference costs a term lookup on an unauthenticated endpoint.
	 */
	const MAX_CATEGORY_FILTERS = 25;

	/**
	 * Maximum accepted length of the free-text `query`, in bytes.
	 *
	 * The term reaches a `LIKE` scan over product titles and SKUs, so its length
	 * is paid per row examined.
	 */
	const MAX_QUERY_LENGTH = 256;

	/**
	 * Get the path of this REST route.
	 *
	 * @return string
	 */
	public function get_path() {
		return self::get_path_regex();
	}

	/**
	 * Get the path of this rest route.
	 *
	 * @return string
	 */
	public static function get_path_regex() {
		return '/catalog/search';
	}

	/**
	 * The UCP capability this route serves.
	 *
	 * @return string
	 */
	protected function get_capability(): string {
		return UcpUtils::CAPABILITY_CATALOG_SEARCH;
	}

	/**
	 * Request body arguments accepted by this route.
	 *
	 * Bounds the UCP contract names its own error codes for (query length, price
	 * range) stay in the handler, so the response carries those codes rather than
	 * `rest_invalid_param`.
	 *
	 * @return array
	 */
	protected function get_request_args(): array {
		return array_merge(
			parent::get_request_args(),
			array(
				'query'      => array(
					'description' => __( 'Free-text search query.', 'woocommerce' ),
					'type'        => 'string',
				),
				'filters'    => array(
					'description' => __( 'Filter criteria to narrow search results.', 'woocommerce' ),
					'type'        => 'object',
				),
				'pagination' => array(
					'description' => __( 'Requested page size and cursor.', 'woocommerce' ),
					'type'        => 'object',
				),
			)
		);
	}

	/**
	 * Handle the request.
	 *
	 * @param \WP_REST_Request $request Request object.
	 *
	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
	 *
	 * @return \WP_REST_Response
	 */
	protected function get_route_post_response( \WP_REST_Request $request ) {
		$query      = trim( (string) $request->get_param( 'query' ) );
		$filters    = (array) ( $request->get_param( 'filters' ) ?? array() );
		$pagination = (array) ( $request->get_param( 'pagination' ) ?? array() );
		$context    = $this->get_context( $request );

		$invalid = $this->validate_search_input( $query, $filters );
		if ( null !== $invalid ) {
			return $invalid;
		}

		$limit  = isset( $pagination['limit'] ) ? min( max( (int) $pagination['limit'], 1 ), 100 ) : 10;
		$offset = $this->decode_cursor( isset( $pagination['cursor'] ) ? (string) $pagination['cursor'] : '' );

		$category_slugs = null;

		if ( ! empty( $filters['categories'] ) && is_array( $filters['categories'] ) ) {
			$category_slugs = $this->resolve_category_slugs( $filters['categories'] );
		}

		// A category list naming no existing term can match nothing; querying
		// without the filter would return the whole catalog instead.
		if ( null !== $category_slugs && empty( $category_slugs ) ) {
			$results = array(
				'objects' => array(),
				'total'   => 0,
			);
		} else {
			$results = $this->query_products( $this->store_api_params( $query, $filters, $category_slugs, $limit, $offset ) );
		}

		$mapped = $this->mapper()->map_products( $results['objects'] );

		$pagination_result = array( 'has_next_page' => $offset + $limit < $results['total'] );

		if ( $pagination_result['has_next_page'] ) {
			$pagination_result['cursor'] = $this->encode_cursor( $offset + $limit );
		}

		$response = array(
			'ucp'        => $this->ucp_metadata( 'success' ),
			'products'   => $mapped,
			'pagination' => $pagination_result,
		);

		$messages = $this->truncation_messages( $this->mapper()->take_truncations(), $mapped, '$.products' );

		$currency_warning = $this->currency_warning( $context, $this->store_currency() );
		if ( null !== $currency_warning ) {
			array_unshift( $messages, $currency_warning );
		}

		if ( ! empty( $messages ) ) {
			$response['messages'] = $messages;
		}

		return new \WP_REST_Response( $response, 200 );
	}

	/**
	 * Validate the search query string and filters before any query runs.
	 *
	 * Values that would silently degrade to something meaningless — a price cast
	 * from a non-numeric string, a range whose minimum exceeds its maximum — are
	 * refused rather than executed, so a caller cannot spend database work on a
	 * request that was never going to match.
	 *
	 * @param string $query Free-text search term.
	 * @param array  $filters Request filters.
	 * @return \WP_REST_Response|null Error response, or null when the input is acceptable.
	 */
	private function validate_search_input( string $query, array $filters ) {
		if ( strlen( $query ) > self::MAX_QUERY_LENGTH ) {
			return $this->ucp_error(
				'VALIDATION_ERROR',
				sprintf(
					/* translators: %d: maximum accepted query length in characters. */
					__( 'query exceeds the maximum length of %d characters.', 'woocommerce' ),
					self::MAX_QUERY_LENGTH
				),
				'recoverable',
				400
			);
		}

		$has_categories = ! empty( $filters['categories'] ) && is_array( $filters['categories'] );
		$has_price      = isset( $filters['price'] ) && is_array( $filters['price'] ) && ( isset( $filters['price']['min'] ) || isset( $filters['price']['max'] ) );

		// UCP requires at least one recognized input; an unknown filter on its
		// own would otherwise browse the whole catalog.
		if ( '' === $query && ! $has_categories && ! $has_price ) {
			return $this->ucp_error(
				'VALIDATION_ERROR',
				__( 'A query or at least one supported filter (categories, price) is required.', 'woocommerce' ),
				'recoverable',
				400
			);
		}

		if ( ! $has_price ) {
			return null;
		}

		$bounds = array();

		foreach ( array( 'min', 'max' ) as $bound ) {
			if ( ! isset( $filters['price'][ $bound ] ) ) {
				continue;
			}

			$value = $filters['price'][ $bound ];

			if ( ! is_numeric( $value ) || (float) $value < 0 ) {
				return $this->ucp_error(
					'VALIDATION_ERROR',
					sprintf(
						/* translators: %s: price filter bound name, `min` or `max`. */
						__( 'filters.price.%s must be a non-negative amount in minor units.', 'woocommerce' ),
						$bound
					),
					'recoverable',
					400
				);
			}

			$bounds[ $bound ] = (int) $value;
		}

		if ( isset( $bounds['min'], $bounds['max'] ) && $bounds['min'] > $bounds['max'] ) {
			return $this->ucp_error(
				'VALIDATION_ERROR',
				__( 'filters.price.min must not exceed filters.price.max.', 'woocommerce' ),
				'recoverable',
				400
			);
		}

		return null;
	}

	/**
	 * Translate the UCP search body into Store API product collection parameters.
	 *
	 * Price bounds pass through in minor units for ProductQuery to convert and tax-adjust.
	 * Like core's other price filters it matches on range overlap: a variable product
	 * qualifies when its min–max span intersects the bounds, even with no variation inside them.
	 *
	 * @param string     $query Free-text search term.
	 * @param array      $filters Request filters.
	 * @param array|null $category_slugs Resolved category slugs, or null when none were requested.
	 * @param int        $limit Page size.
	 * @param int        $offset Offset of the first result.
	 * @return array
	 */
	private function store_api_params( string $query, array $filters, ?array $category_slugs, int $limit, int $offset ): array {
		$params = array(
			'orderby'            => 'date',
			'order'              => 'desc',
			'per_page'           => $limit,
			'offset'             => $offset,
			'catalog_visibility' => $query ? CatalogVisibility::SEARCH : CatalogVisibility::CATALOG,
		);

		if ( '' !== $query ) {
			$params['search'] = $query;
		}

		if ( ! empty( $category_slugs ) ) {
			$params['category'] = $category_slugs;
		}

		if ( isset( $filters['price'] ) && is_array( $filters['price'] ) ) {
			foreach ( array( 'min', 'max' ) as $bound ) {
				if ( isset( $filters['price'][ $bound ] ) ) {
					$params[ $bound . '_price' ] = (int) $filters['price'][ $bound ];
				}
			}
		}

		return $params;
	}

	/**
	 * Resolve category references to the term slugs ProductQuery filters on.
	 *
	 * ProductQuery types a category list from its first element, so a request
	 * mixing ids and slugs is normalized to slugs here. The list is deduplicated
	 * and capped: one request must not drive an unbounded number of term lookups
	 * on an unauthenticated endpoint.
	 *
	 * @param array $references Category term ids or slugs.
	 * @return array
	 */
	private function resolve_category_slugs( array $references ): array {
		$slugs = array();

		foreach ( array_slice( array_unique( $references ), 0, self::MAX_CATEGORY_FILTERS ) as $reference ) {
			$reference = (string) $reference;
			$term      = ctype_digit( $reference )
				? get_term_by( 'id', (int) $reference, 'product_cat' )
				: get_term_by( 'slug', $reference, 'product_cat' );

			if ( $term instanceof \WP_Term ) {
				$slugs[] = $term->slug;
			}
		}

		return $slugs;
	}

	/**
	 * Run the Store API product query, recounting when the cursor is past the end.
	 *
	 * `found_posts` is only computed when the page returned rows, and ProductQuery's
	 * own recount keys off `page` rather than `offset`, so an empty page at a non-zero
	 * offset is recounted here with a single-row query from the start.
	 *
	 * @param array $params Store API product collection parameters.
	 * @return array `{ objects, total }`.
	 */
	private function query_products( array $params ): array {
		$product_query = new ProductQuery();
		$results       = $product_query->get_objects( $this->store_api_request( $params ) );

		if ( empty( $results['objects'] ) && ! empty( $params['offset'] ) ) {
			$params['offset']   = 0;
			$params['per_page'] = 1;
			$results['total']   = $product_query->get_results( $this->store_api_request( $params ) )['total'];
		}

		return $results;
	}

	/**
	 * Wrap collection parameters in the request object ProductQuery reads.
	 *
	 * @param array $params Store API product collection parameters.
	 * @return \WP_REST_Request<array<string, mixed>>
	 */
	private function store_api_request( array $params ): \WP_REST_Request {
		/**
		 * Typed for static analysis; WP_REST_Request is generic in the stubs.
		 *
		 * @var \WP_REST_Request<array<string, mixed>> $request
		 */
		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
		$request->set_query_params( $params );

		return $request;
	}

	/**
	 * Encode a pagination offset into an opaque cursor string.
	 *
	 * @param int $offset Numeric offset.
	 * @return string
	 */
	private function encode_cursor( int $offset ): string {
		return base64_encode( 'offset:' . $offset ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
	}

	/**
	 * Decode a cursor string back into a numeric offset.
	 *
	 * @param string $cursor Opaque cursor string.
	 * @return int
	 */
	private function decode_cursor( string $cursor ): int {
		if ( '' === $cursor ) {
			return 0;
		}

		$decoded = base64_decode( $cursor, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		if ( false === $decoded || 0 !== strpos( $decoded, 'offset:' ) ) {
			return 0;
		}

		return max( 0, (int) substr( $decoded, 7 ) );
	}
}