Файловый менеджер - Редактировать - /home/tuudkjt/globeasy/wp-includes/ID3/StoreApi.tar
Назад
Formatters/FormatterInterface.php 0000777 00000000557 15251730534 0013212 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Formatters; /** * FormatterInterface. */ interface FormatterInterface { /** * Format a given value and return the result. * * @param mixed $value Value to format. * @param array $options Options that influence the formatting. * @return mixed */ public function format( $value, array $options = [] ); } Formatters/MoneyFormatter.php 0000777 00000003140 15251730534 0012370 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Formatters; /** * Money Formatter. * * Formats monetary values using store settings. */ class MoneyFormatter implements FormatterInterface { /** * Format a given price value and return the result as a string without decimals. * * @param int|float|string $value Value to format. Int is allowed, as it may also represent a valid price. * @param array $options Options that influence the formatting. * @return string */ public function format( $value, array $options = [] ) { if ( ! is_int( $value ) && ! is_string( $value ) && ! is_float( $value ) ) { wc_doing_it_wrong( __FUNCTION__, 'Function expects a $value arg of type INT, STRING or FLOAT.', '9.2' ); return ''; } $options = wp_parse_args( $options, [ 'decimals' => wc_get_price_decimals(), 'rounding_mode' => PHP_ROUND_HALF_UP, ] ); // Ensure rounding mode is valid. $rounding_modes = [ PHP_ROUND_HALF_UP, PHP_ROUND_HALF_DOWN, PHP_ROUND_HALF_EVEN, PHP_ROUND_HALF_ODD ]; $options['rounding_mode'] = absint( $options['rounding_mode'] ); if ( ! in_array( $options['rounding_mode'], $rounding_modes, true ) ) { $options['rounding_mode'] = PHP_ROUND_HALF_UP; } $value = floatval( $value ); // Remove the price decimal points for rounding purposes. $value = $value * pow( 10, absint( $options['decimals'] ) ); $value = round( $value, 0, $options['rounding_mode'] ); // This ensures returning the value as a string without decimal points ready for price parsing. return wc_format_decimal( $value, 0, true ); } } Formatters/HtmlFormatter.php 0000777 00000001602 15251730534 0012206 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Formatters; /** * Html Formatter. * * Formats HTML in API responses. * * @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions. */ class HtmlFormatter implements FormatterInterface { /** * Format a given value and return the result. * * The wptexturize, convert_chars, and trim functions are also used in the `the_title` filter. * The function wp_kses_post removes disallowed HTML tags. * * @param string|array $value Value to format. * @param array $options Options that influence the formatting. * @return string */ public function format( $value, array $options = [] ) { if ( is_array( $value ) ) { return array_map( [ $this, 'format' ], $value ); } return is_scalar( $value ) ? wp_kses_post( trim( convert_chars( wptexturize( $value ) ) ) ) : $value; } } Formatters/DefaultFormatter.php 0000777 00000000633 15251730534 0012671 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Formatters; /** * Default Formatter. */ class DefaultFormatter implements FormatterInterface { /** * Format a given value and return the result. * * @param mixed $value Value to format. * @param array $options Options that influence the formatting. * @return mixed */ public function format( $value, array $options = [] ) { return $value; } } Formatters/CurrencyFormatter.php 0000777 00000002506 15251730534 0013100 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Formatters; /** * Currency Formatter. * * Formats an array of monetary values by inserting currency data. */ class CurrencyFormatter implements FormatterInterface { /** * Format a given value and return the result. * * @param array $value Value to format. * @param array $options Options that influence the formatting. * @return array */ public function format( $value, array $options = [] ) { $position = get_option( 'woocommerce_currency_pos' ); $symbol = html_entity_decode( get_woocommerce_currency_symbol() ); $prefix = ''; $suffix = ''; switch ( $position ) { case 'left_space': $prefix = $symbol . ' '; break; case 'left': $prefix = $symbol; break; case 'right_space': $suffix = ' ' . $symbol; break; case 'right': $suffix = $symbol; break; } return array_merge( (array) $value, [ 'currency_code' => get_woocommerce_currency(), 'currency_symbol' => $symbol, 'currency_minor_unit' => wc_get_price_decimals(), 'currency_decimal_separator' => wc_get_price_decimal_separator(), 'currency_thousand_separator' => wc_get_price_thousand_separator(), 'currency_prefix' => $prefix, 'currency_suffix' => $suffix, ] ); } } StoreApi.php 0000777 00000006404 15251730534 0007023 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi; use Automattic\WooCommerce\Blocks\Registry\Container; use Automattic\WooCommerce\StoreApi\Formatters; use Automattic\WooCommerce\StoreApi\Authentication; use Automattic\WooCommerce\StoreApi\Legacy; use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter; use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter; use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter; use Automattic\WooCommerce\StoreApi\RoutesController; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; /** * StoreApi Main Class. */ final class StoreApi { /** * Init and hook in Store API functionality. */ public function init() { add_action( 'rest_api_init', function () { if ( ! wc_rest_should_load_namespace( 'wc/store' ) && ! wc_rest_should_load_namespace( 'wc/private' ) ) { return; } self::container()->get( Legacy::class )->init(); self::container()->get( RoutesController::class )->register_all_routes(); } ); // Runs on priority 11 after rest_api_default_filters() which is hooked at 10. add_action( 'rest_api_init', function () { if ( ! wc_rest_should_load_namespace( 'wc/store' ) ) { return; } self::container()->get( Authentication::class )->init(); }, 11 ); add_action( 'woocommerce_blocks_pre_get_routes_from_namespace', function ( $routes, $ns ) { if ( 'wc/store/v1' !== $ns ) { return $routes; } $routes = array_merge( $routes, self::container()->get( RoutesController::class )->get_all_routes( 'v1' ) ); return $routes; }, 10, 2 ); } /** * Loads the DI container for Store API. * * @internal This uses the Blocks DI container. If Store API were to move to core, this container could be replaced * with a different compatible container. * * @param boolean $reset Used to reset the container to a fresh instance. Note: this means all dependencies will be reconstructed. * @return mixed */ public static function container( $reset = false ) { static $container; if ( $reset ) { $container = null; } if ( $container ) { return $container; } $container = new Container(); $container->register( Authentication::class, function () { return new Authentication(); } ); $container->register( Legacy::class, function () { return new Legacy(); } ); $container->register( RoutesController::class, function ( $container ) { return new RoutesController( $container->get( SchemaController::class ) ); } ); $container->register( SchemaController::class, function ( $container ) { return new SchemaController( $container->get( ExtendSchema::class ) ); } ); $container->register( ExtendSchema::class, function ( $container ) { return new ExtendSchema( $container->get( Formatters::class ) ); } ); $container->register( Formatters::class, function () { $formatters = new Formatters(); $formatters->register( 'money', MoneyFormatter::class ); $formatters->register( 'html', HtmlFormatter::class ); $formatters->register( 'currency', CurrencyFormatter::class ); return $formatters; } ); return $container; } } Authentication.php 0000777 00000031203 15251730534 0010247 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\StoreApi; use Automattic\WooCommerce\StoreApi\Utilities\RateLimits; use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils; use Automattic\WooCommerce\Utilities\FeaturesUtil; /** * Authentication class. */ class Authentication { /** * Hook into WP lifecycle events. This is hooked by the StoreAPI class on `rest_api_init`. */ public function init() { if ( ! $this->is_request_to_store_api() ) { return; } add_filter( 'rest_authentication_errors', array( $this, 'check_authentication' ) ); add_filter( 'rest_authentication_errors', array( $this, 'opt_in_checkout_endpoint' ), 9, 1 ); add_action( 'set_logged_in_cookie', array( $this, 'set_logged_in_cookie' ) ); add_filter( 'rest_pre_serve_request', array( $this, 'send_cors_headers' ), 10, 4 ); add_filter( 'rest_allowed_cors_headers', array( $this, 'allowed_cors_headers' ) ); add_filter( 'rest_exposed_cors_headers', array( $this, 'exposed_cors_headers' ) ); // Remove the default CORS headers--we will add our own. remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' ); } /** * Add allowed cors headers for store API headers. * * @param array $allowed_headers Allowed headers. * @return array */ public function allowed_cors_headers( $allowed_headers ) { $allowed_headers[] = 'Cart-Token'; $allowed_headers[] = 'Nonce'; return $allowed_headers; } /** * Expose Store API headers in CORS responses. * We're explicitly exposing the Cart-Token, not the nonce. Only one of them is needed. * * @param array $exposed_headers Exposed headers. * @return array */ public function exposed_cors_headers( $exposed_headers ) { $exposed_headers[] = 'Cart-Token'; return $exposed_headers; } /** * Add CORS headers to a response object. * * These checks prevent access to the Store API from non-allowed origins. By default, the WordPress REST API allows * access from any origin. Because some Store API routes return PII, we need to add our own CORS headers. * * Allowed origins can be changed using the WordPress `allowed_http_origins` or `allowed_http_origin` filters if * access needs to be granted to other domains. * * Users of valid Cart Tokens are also allowed access from any origin. * * @param bool $served Whether the request has already been served. * @param \WP_REST_Response $result The response object. * @param \WP_REST_Request $request The request object. * @param \WP_REST_Server $server The REST server instance. * @return bool */ public function send_cors_headers( $served, $result, $request, $server ) { $origin = get_http_origin(); if ( 'null' !== $origin ) { $origin = esc_url_raw( $origin ); } // Send standard CORS headers. $server->send_header( 'Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT, PATCH, DELETE' ); $server->send_header( 'Access-Control-Allow-Credentials', 'true' ); $server->send_header( 'Vary', 'Origin', false ); // Allow preflight requests, certain http origins, and any origin if a cart token is present. Preflight requests // are allowed because we'll be unable to validate cart token headers at that point. if ( $this->is_preflight() || CartTokenUtils::validate_cart_token( $this->get_cart_token( $request ) ) || is_allowed_http_origin( $origin ) ) { $server->send_header( 'Access-Control-Allow-Origin', $origin ); } // Exit early during preflight requests. This is so someone cannot access API data by sending an OPTIONS request // with preflight headers and a _GET property to override the method. if ( $this->is_preflight() ) { exit; } return $served; } /** * Is the request a preflight request? Checks the request method * * @return boolean */ protected function is_preflight() { return isset( $_SERVER['REQUEST_METHOD'], $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'], $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'], $_SERVER['HTTP_ORIGIN'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD']; } /** * Gets the cart token from the request header. * * @param \WP_REST_Request $request The REST request instance. * @return string */ protected function get_cart_token( \WP_REST_Request $request ) { return wc_clean( wp_unslash( $request->get_header( 'Cart-Token' ) ?? '' ) ); } /** * The Store API does not require authentication. * * @param \WP_Error|mixed $result Error from another authentication handler, null if we should handle it, or another value if not. * @return \WP_Error|null|bool */ public function check_authentication( $result ) { // Enable Rate Limiting for logged-in users without 'edit posts' capability. if ( ! current_user_can( 'edit_posts' ) ) { $result = $this->apply_rate_limiting( $result ); } // Pass through errors from other authentication methods used before this one. return ! empty( $result ) ? $result : true; } /** * When the login cookies are set, they are not available until the next page reload. For the Store API, specifically * for returning updated nonces, we need this to be available immediately. * * @param string $logged_in_cookie The value for the logged in cookie. */ public function set_logged_in_cookie( $logged_in_cookie ) { if ( ! defined( 'LOGGED_IN_COOKIE' ) ) { return; } $_COOKIE[ LOGGED_IN_COOKIE ] = $logged_in_cookie; } /** * Opt in to rate limiting for the checkout endpoint. * * @param \WP_Error|mixed $result Error from another authentication handler, null if we should handle it, or another value if not. * @return \WP_Error|null|bool */ public function opt_in_checkout_endpoint( $result ) { if ( FeaturesUtil::feature_is_enabled( 'rate_limit_checkout' ) && $this->is_request_to_store_api() && preg_match( '#/wc/store(?:/v\d+)?/checkout#', $GLOBALS['wp']->query_vars['rest_route'] ) && $this->is_only_post_request() ) { add_filter( 'woocommerce_store_api_rate_limit_options', function ( $options ) { $options['enabled'] = true; $options['limit'] = 3; $options['seconds'] = 60; return $options; }, 1, 1 ); } return $result; } /** * Applies Rate Limiting to the request, and passes through any errors from other authentication methods used before this one. * * @param \WP_Error|mixed $result Error from another authentication handler, null if we should handle it, or another value if not. * @return \WP_Error|null|bool */ protected function apply_rate_limiting( $result ) { $rate_limiting_options = RateLimits::get_options(); if ( $rate_limiting_options->enabled ) { $action_id = 'store_api_request_' . self::get_rate_limiting_id( $rate_limiting_options->proxy_support ); $retry = RateLimits::is_exceeded_retry_after( $action_id ); $server = rest_get_server(); $server->send_header( 'RateLimit-Limit', $rate_limiting_options->limit ); if ( false !== $retry ) { $server->send_header( 'RateLimit-Remaining', 0 ); $server->send_header( 'RateLimit-Retry-After', $retry ); $server->send_header( 'RateLimit-Reset', time() + $retry ); /** * Fires when the rate limit is exceeded. * * @param string $ip_address The IP address of the request. * @param string $action_id The grouping identifier to the request. * * @since 8.9.0 * @since 9.8.0 Added $action_id parameter. */ do_action( 'woocommerce_store_api_rate_limit_exceeded', self::get_ip_address( $rate_limiting_options->proxy_support ), $action_id ); return new \WP_Error( 'rate_limit_exceeded', sprintf( 'Too many requests. Please wait %d seconds before trying again.', $retry ), array( 'status' => 400 ) ); } $rate_limit = RateLimits::update_rate_limit( $action_id ); $server->send_header( 'RateLimit-Remaining', $rate_limit->remaining ); $server->send_header( 'RateLimit-Reset', $rate_limit->reset ); } return $result; } /** * Generates the request grouping identifier for the rate limiting. * * @param bool $proxy_support Rate Limiting proxy support. * * @return string */ protected static function get_rate_limiting_id( bool $proxy_support ): string { if ( is_user_logged_in() ) { $id = (string) get_current_user_id(); } else { $id = md5( self::get_ip_address( $proxy_support ) ); } /** * Filters the rate limiting identifier. * * @param string $id The rate limiting identifier. * * @since 9.8.0 */ $id = apply_filters( 'woocommerce_store_api_rate_limit_id', $id ); return sanitize_key( $id ); } /** * Check if is request to the Store API. * * @return bool */ protected function is_request_to_store_api() { if ( empty( $GLOBALS['wp']->query_vars['rest_route'] ) ) { return false; } return 0 === strpos( $GLOBALS['wp']->query_vars['rest_route'], '/wc/store/' ); } /** * Returns true only for POST requests that are NOT overridden to another method * via the X-HTTP-Method-Override header (used by wp.apiFetch for PUT/DELETE). * * @see https://github.com/wordpress/gutenberg/blob/trunk/packages/api-fetch/src/middlewares/http-v1.ts#L21-L43 * * @return bool */ private function is_only_post_request() { // Check that REQUEST_METHOD is POST. if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) { return false; } // Check X-HTTP-Method-Override header if it exists and is not empty - it must also be POST. if ( isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) { $method_override = strtoupper( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) ); if ( '' !== $method_override && 'POST' !== $method_override ) { return false; } } return true; } /** * Get current user IP Address. * * X_REAL_IP and CLIENT_IP are custom implementations designed to facilitate obtaining a user's ip through proxies, load balancers etc. * * _FORWARDED_FOR (XFF) request header is a de-facto standard header for identifying the originating IP address of a client connecting to a web server through a proxy server. * Note for X_FORWARDED_FOR, Proxy servers can send through this header like this: X-Forwarded-For: client1, proxy1, proxy2. * Make sure we always only send through the first IP in the list which should always be the client IP. * Documentation at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For * * Forwarded request header contains information that may be added by reverse proxy servers (load balancers, CDNs, and so on). * Documentation at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded * Full RFC at https://datatracker.ietf.org/doc/html/rfc7239 * * @param boolean $proxy_support Enables/disables proxy support. * * @return string */ protected static function get_ip_address( bool $proxy_support = false ) { if ( ! $proxy_support ) { return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? 'unresolved_ip' ) ) ); } if ( array_key_exists( 'HTTP_X_REAL_IP', $_SERVER ) ) { return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) ) ); } if ( array_key_exists( 'HTTP_CLIENT_IP', $_SERVER ) ) { return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ) ) ); } if ( array_key_exists( 'HTTP_X_FORWARDED_FOR', $_SERVER ) ) { $ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ); if ( is_array( $ips ) && ! empty( $ips ) ) { return self::validate_ip( trim( $ips[0] ) ); } } if ( array_key_exists( 'HTTP_FORWARDED', $_SERVER ) ) { // Using regex instead of explode() for a smaller code footprint. // Expected format: Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43,for="[2001:db8:cafe::17]:4711"... preg_match( '/(?<=for\=)[^;,]*/i', // We catch everything on the first "for" entry, and validate later. sanitize_text_field( wp_unslash( $_SERVER['HTTP_FORWARDED'] ) ), $matches ); if ( strpos( $matches[0] ?? '', '"[' ) !== false ) { // Detect for ipv6, eg "[ipv6]:port". preg_match( '/(?<=\[).*(?=\])/i', // We catch only the ipv6 and overwrite $matches. $matches[0], $matches ); } if ( ! empty( $matches ) ) { return self::validate_ip( trim( $matches[0] ) ); } } return '0.0.0.0'; } /** * Uses filter_var() to validate and return ipv4 and ipv6 addresses * Will return 0.0.0.0 if the ip is not valid. This is done to group and still rate limit invalid ips. * * @param string $ip ipv4 or ipv6 ip string. * * @return string */ protected static function validate_ip( $ip ) { $ip = filter_var( $ip, FILTER_VALIDATE_IP, array( FILTER_FLAG_NO_RES_RANGE, FILTER_FLAG_IPV6 ) ); return $ip ?: '0.0.0.0'; } } Payments/PaymentContext.php 0000777 00000003471 15251730534 0012060 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Payments; /** * PaymentContext class. */ class PaymentContext { /** * Payment method ID. * * @var string */ protected $payment_method = ''; /** * Order object for the order being paid. * * @var \WC_Order */ protected $order; /** * Holds data to send to the payment gateway to support payment. * * @var array Key value pairs. */ protected $payment_data = []; /** * Magic getter for protected properties. * * @param string $name Property name. */ public function __get( $name ) { if ( in_array( $name, [ 'payment_method', 'order', 'payment_data' ], true ) ) { return $this->$name; } return null; } /** * Set the chosen payment method ID context. * * @param string $payment_method Payment method ID. */ public function set_payment_method( $payment_method ) { $this->payment_method = (string) $payment_method; } /** * Retrieve the payment method instance for the current set payment method. * * @return \WC_Payment_Gateway|null An instance of the payment gateway if it exists. */ public function get_payment_method_instance() { $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); if ( ! isset( $available_gateways[ $this->payment_method ] ) ) { return; } return $available_gateways[ $this->payment_method ]; } /** * Set the order context. * * @param \WC_Order $order Order object. */ public function set_order( \WC_Order $order ) { $this->order = $order; } /** * Set payment data context. * * @param array $payment_data Array of key value pairs of data. */ public function set_payment_data( $payment_data = [] ) { $this->payment_data = []; foreach ( $payment_data as $key => $value ) { $this->payment_data[ (string) $key ] = (string) $value; } } } Payments/PaymentResult.php 0000777 00000003730 15251730534 0011710 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Payments; /** * PaymentResult class. */ class PaymentResult { /** * List of valid payment statuses. * * @var array */ protected $valid_statuses = [ 'success', 'failure', 'pending', 'error' ]; /** * Current payment status. * * @var string */ protected $status = ''; /** * Array of details about the payment. * * @var string */ protected $payment_details = []; /** * Redirect URL for checkout. * * @var string */ protected $redirect_url = ''; /** * Constructor. * * @param string $status Sets the payment status for the result. */ public function __construct( $status = '' ) { if ( $status ) { $this->set_status( $status ); } } /** * Magic getter for protected properties. * * @param string $name Property name. */ public function __get( $name ) { if ( in_array( $name, [ 'status', 'payment_details', 'redirect_url' ], true ) ) { return $this->$name; } return null; } /** * Set payment status. * * @throws \Exception When an invalid status is provided. * * @param string $payment_status Status to set. */ public function set_status( $payment_status ) { if ( ! in_array( $payment_status, $this->valid_statuses, true ) ) { throw new \Exception( sprintf( 'Invalid payment status %s. Use one of %s', $payment_status, implode( ', ', $this->valid_statuses ) ) ); } $this->status = $payment_status; } /** * Set payment details. * * @param array $payment_details Array of key value pairs of data. */ public function set_payment_details( $payment_details = [] ) { $this->payment_details = []; foreach ( $payment_details as $key => $value ) { $this->payment_details[ (string) $key ] = (string) $value; } } /** * Set redirect URL. * * @param array $redirect_url URL to redirect the customer to after checkout. */ public function set_redirect_url( $redirect_url = [] ) { $this->redirect_url = esc_url_raw( $redirect_url ); } } deprecated.php 0000777 00000017737 15251730534 0007410 0 ustar 00 <?php /** * Class Aliases for graceful Backwards compatibility. * * This file is autoloaded via composer.json and maps the old namespaces to new namespaces. */ $class_aliases = [ // Old to new namespaces for utils and exceptions. Automattic\WooCommerce\StoreApi\Exceptions\RouteException::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException::class, Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::class => Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi::class, Automattic\WooCommerce\StoreApi\SchemaController::class => Automattic\WooCommerce\Blocks\StoreApi\SchemaController::class, Automattic\WooCommerce\StoreApi\RoutesController::class => Automattic\WooCommerce\Blocks\StoreApi\RoutesController::class, Automattic\WooCommerce\StoreApi\Formatters::class => Automattic\WooCommerce\Blocks\StoreApi\Formatters::class, Automattic\WooCommerce\StoreApi\Payments\PaymentResult::class => Automattic\WooCommerce\Blocks\Payments\PaymentResult::class, Automattic\WooCommerce\StoreApi\Payments\PaymentContext::class => Automattic\WooCommerce\Blocks\Payments\PaymentContext::class, // Old schemas to V1 schemas under new namespace. Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractAddressSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractAddressSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\BillingAddressSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\BillingAddressSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\CartCouponSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartCouponSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\CartExtensionsSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartExtensionsSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\CartFeeSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartFeeSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartItemSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\CartShippingRateSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartShippingRateSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CheckoutSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\ErrorSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ErrorSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\ImageAttachmentSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ImageAttachmentSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\OrderCouponSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\OrderCouponSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\ProductAttributeSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductAttributeSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\ProductCategorySchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductCategorySchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\ProductCollectionDataSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductCollectionDataSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\ProductReviewSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductReviewSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\ShippingAddressSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ShippingAddressSchema::class, Automattic\WooCommerce\StoreApi\Schemas\V1\TermSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\TermSchema::class, // Old routes to V1 routes under new namespace. Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\AbstractCartRoute::class, Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\AbstractRoute::class, Automattic\WooCommerce\StoreApi\Routes\V1\AbstractTermsRoute::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\AbstractTermsRoute::class, Automattic\WooCommerce\StoreApi\Routes\V1\Batch::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\Batch::class, Automattic\WooCommerce\StoreApi\Routes\V1\Cart::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\Cart::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartAddItem::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartAddItem::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartApplyCoupon::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartApplyCoupon::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartCoupons::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartCoupons::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartCouponsByCode::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartCouponsByCode::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartExtensions::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartExtensions::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartItems::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartItems::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartItemsByKey::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartItemsByKey::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartRemoveCoupon::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartRemoveCoupon::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartRemoveItem::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartRemoveItem::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartSelectShippingRate::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartSelectShippingRate::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartUpdateCustomer::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartUpdateCustomer::class, Automattic\WooCommerce\StoreApi\Routes\V1\CartUpdateItem::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartUpdateItem::class, Automattic\WooCommerce\StoreApi\Routes\V1\Checkout::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\Checkout::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductAttributes::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductAttributes::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductAttributesById::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductAttributesById::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductAttributeTerms::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductAttributeTerms::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductCategories::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductCategories::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductCategoriesById::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductCategoriesById::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductCollectionData::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductCollectionData::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductReviews::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductReviews::class, Automattic\WooCommerce\StoreApi\Routes\V1\Products::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\Products::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductsById::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductsById::class, Automattic\WooCommerce\StoreApi\Routes\V1\ProductTags::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductTags::class, ]; foreach ( $class_aliases as $class => $alias ) { if ( ! class_exists( $alias, false ) ) { class_alias( $class, $alias ); } } unset( $class_aliases ); RoutesController.php 0000777 00000015420 15251730534 0010620 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\StoreApi; use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute; use Automattic\WooCommerce\Utilities\FeaturesUtil; /** * RoutesController class. */ class RoutesController { /** * Stores schema_controller. * * @var SchemaController */ protected $schema_controller; /** * Stores routes. * * @var array */ protected $routes = []; /** * Namespace for the API. * * @var string */ private static $api_namespace = 'wc/store'; /** * Constructor. * * @param SchemaController $schema_controller Schema controller class passed to each route. */ public function __construct( SchemaController $schema_controller ) { $this->schema_controller = $schema_controller; $this->routes = [ 'v1' => [ Routes\V1\Batch::IDENTIFIER => Routes\V1\Batch::class, Routes\V1\Cart::IDENTIFIER => Routes\V1\Cart::class, Routes\V1\CartAddItem::IDENTIFIER => Routes\V1\CartAddItem::class, Routes\V1\CartApplyCoupon::IDENTIFIER => Routes\V1\CartApplyCoupon::class, Routes\V1\CartCoupons::IDENTIFIER => Routes\V1\CartCoupons::class, Routes\V1\CartCouponsByCode::IDENTIFIER => Routes\V1\CartCouponsByCode::class, Routes\V1\CartExtensions::IDENTIFIER => Routes\V1\CartExtensions::class, Routes\V1\CartItems::IDENTIFIER => Routes\V1\CartItems::class, Routes\V1\CartItemsByKey::IDENTIFIER => Routes\V1\CartItemsByKey::class, Routes\V1\CartRemoveCoupon::IDENTIFIER => Routes\V1\CartRemoveCoupon::class, Routes\V1\CartRemoveItem::IDENTIFIER => Routes\V1\CartRemoveItem::class, Routes\V1\CartSelectShippingRate::IDENTIFIER => Routes\V1\CartSelectShippingRate::class, Routes\V1\CartUpdateItem::IDENTIFIER => Routes\V1\CartUpdateItem::class, Routes\V1\CartUpdateCustomer::IDENTIFIER => Routes\V1\CartUpdateCustomer::class, Routes\V1\Checkout::IDENTIFIER => Routes\V1\Checkout::class, Routes\V1\CheckoutOrder::IDENTIFIER => Routes\V1\CheckoutOrder::class, Routes\V1\Order::IDENTIFIER => Routes\V1\Order::class, Routes\V1\ProductAttributes::IDENTIFIER => Routes\V1\ProductAttributes::class, Routes\V1\ProductAttributesById::IDENTIFIER => Routes\V1\ProductAttributesById::class, Routes\V1\ProductAttributeTerms::IDENTIFIER => Routes\V1\ProductAttributeTerms::class, Routes\V1\ProductCategories::IDENTIFIER => Routes\V1\ProductCategories::class, Routes\V1\ProductCategoriesById::IDENTIFIER => Routes\V1\ProductCategoriesById::class, Routes\V1\ProductBrands::IDENTIFIER => Routes\V1\ProductBrands::class, Routes\V1\ProductBrandsById::IDENTIFIER => Routes\V1\ProductBrandsById::class, Routes\V1\ProductCollectionData::IDENTIFIER => Routes\V1\ProductCollectionData::class, Routes\V1\ProductReviews::IDENTIFIER => Routes\V1\ProductReviews::class, Routes\V1\ProductTags::IDENTIFIER => Routes\V1\ProductTags::class, Routes\V1\Products::IDENTIFIER => Routes\V1\Products::class, Routes\V1\ProductsById::IDENTIFIER => Routes\V1\ProductsById::class, Routes\V1\ProductsBySlug::IDENTIFIER => Routes\V1\ProductsBySlug::class, ], 'private' => [ // This route should be moved outside of the Store API namespace. Routes\V1\Patterns::IDENTIFIER => Routes\V1\Patterns::class, ], 'agentic' => [ // Agentic Commerce Protocol endpoints. Routes\V1\Agentic\CheckoutSessions::IDENTIFIER => Routes\V1\Agentic\CheckoutSessions::class, Routes\V1\Agentic\CheckoutSessionsUpdate::IDENTIFIER => Routes\V1\Agentic\CheckoutSessionsUpdate::class, Routes\V1\Agentic\CheckoutSessionsComplete::IDENTIFIER => Routes\V1\Agentic\CheckoutSessionsComplete::class, ], ]; } /** * Register all Store API routes. This includes routes under specific version namespaces. */ public function register_all_routes() { $this->register_routes( 'v1', self::$api_namespace ); $this->register_routes( 'v1', self::$api_namespace . '/v1' ); $this->register_routes( 'private', 'wc/private' ); if ( FeaturesUtil::feature_is_enabled( 'agentic_checkout' ) ) { $this->register_routes( 'agentic', 'wc/agentic/v1' ); } } /** * Get a route class instance. * * Each route class is instantized with the SchemaController instance, and its main Schema Type. * * @throws \Exception If the schema does not exist. * @param string $name Name of schema. * @param string $version API Version being requested. * @return AbstractRoute */ public function get( $name, $version = 'v1' ) { $route = $this->routes[ $version ][ $name ] ?? false; if ( ! $route ) { throw new \Exception( "{$name} {$version} route does not exist" ); } return new $route( $this->schema_controller, $this->schema_controller->get( $route::SCHEMA_TYPE, $route::SCHEMA_VERSION ) ); } /** * Get a route path without instantiating the corresponding RoutesController object. * * @throws \Exception If the schema does not exist. * * @param string $version API Version being requested. * @param string $controller Whether to return controller name. If false, returns empty array. Note: * When $controller param is true, the output should not be used directly in front-end code, to prevent class names from leaking. It's not a security issue necessarily, but it's not a good practice. * When $controller param is false, it currently returns and empty array. But it can be modified in future to return include more details about the route info that can be used in frontend. * * @return string[] List of route paths. */ public function get_all_routes( $version = 'v1', $controller = false ) { $routes = array(); foreach ( $this->routes[ $version ] as $key => $route_class ) { if ( ! method_exists( $route_class, 'get_path_regex' ) ) { throw new \Exception( esc_html( "{$route_class} route does not have a get_path_regex method" ) ); } $route_path = '/' . trailingslashit( self::$api_namespace ) . $version . $route_class::get_path_regex(); $routes[ $route_path ] = $controller ? $route_class : array(); } return $routes; } /** * Register defined list of routes with WordPress. * * @param string $version API Version being registered.. * @param string $namespace Overrides the default route namespace. */ protected function register_routes( $version = 'v1', $namespace = 'wc/store/v1' ) { if ( ! isset( $this->routes[ $version ] ) ) { return; } $route_identifiers = array_keys( $this->routes[ $version ] ); foreach ( $route_identifiers as $route ) { $route_instance = $this->get( $route, $version ); $route_instance->set_namespace( $namespace ); register_rest_route( $route_instance->get_namespace(), $route_instance->get_path(), $route_instance->get_args() ); } } } SessionHandler.php 0000777 00000005327 15251730534 0010221 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi; use Automattic\Jetpack\Constants; use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils; use WC_Session; defined( 'ABSPATH' ) || exit; /** * SessionHandler class */ final class SessionHandler extends WC_Session { /** * Token from HTTP headers. * * @var string */ protected $token = ''; /** * Table name for session data. * * @var string Custom session table name */ protected $table = ''; /** * Expiration timestamp. * * @var int */ protected $session_expiration = 0; /** * Constructor for the session class. */ public function __construct() { $this->token = wc_clean( wp_unslash( $_SERVER['HTTP_CART_TOKEN'] ?? '' ) ); $this->table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions'; } /** * Init hooks and session data. */ public function init() { $this->init_session_from_token(); add_action( 'shutdown', array( $this, 'save_data' ), 20 ); } /** * Process the token header to load the correct session. */ protected function init_session_from_token() { $payload = CartTokenUtils::get_cart_token_payload( $this->token ); $this->_customer_id = $payload['user_id']; $this->session_expiration = $payload['exp']; $this->_data = (array) $this->get_session( $this->get_customer_id(), array() ); } /** * Returns the session. * * @param string $customer_id Customer ID. * @param mixed $default_value Default session value. * @return mixed Returns either the session data or the default value. Returns false if WP setup is in progress. */ public function get_session( $customer_id, $default_value = false ) { global $wpdb; // This mimics behaviour from default WC_Session_Handler class. There will be no sessions retrieved while WP setup is due. if ( Constants::is_defined( 'WP_SETUP_CONFIG' ) ) { return $default_value; } $value = $wpdb->get_var( $wpdb->prepare( 'SELECT session_value FROM %i WHERE session_key = %s', $this->table, $customer_id ) ); if ( is_null( $value ) ) { $value = $default_value; } return maybe_unserialize( $value ); } /** * Save data and delete user session. */ public function save_data() { // Dirty if something changed - prevents saving nothing new. if ( $this->_dirty ) { global $wpdb; $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d) ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)', $this->table, $this->get_customer_id(), maybe_serialize( $this->_data ), $this->session_expiration ) ); $this->_dirty = false; } } } Exceptions/PartialOutOfStockException.php 0000777 00000000446 15251730534 0014652 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Exceptions; /** * PartialOutOfStockException class. * * This exception is thrown when an item in a draft order has a quantity greater than what is available in stock. */ class PartialOutOfStockException extends StockAvailabilityException {} Exceptions/NotPurchasableException.php 0000777 00000000400 15251730534 0014175 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Exceptions; /** * NotPurchasableException class. * * This exception is thrown when an item in the cart is not able to be purchased. */ class NotPurchasableException extends StockAvailabilityException {} Exceptions/InvalidStockLevelsInCartException.php 0000777 00000003041 15251730534 0016135 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Exceptions; use WP_Error; /** * InvalidStockLevelsInCartException class. * * This exception is thrown if any items are out of stock after each product on a draft order has been stock checked. */ class InvalidStockLevelsInCartException extends \Exception { /** * Sanitized error code. * * @var string */ public $error_code; /** * Additional error data. * * @var array */ public $additional_data = []; /** * All errors to display to the user. * * @var WP_Error */ public $error; /** * Setup exception. * * @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`. * @param WP_Error $error The WP_Error object containing all errors relating to stock availability. * @param array $additional_data Extra data (key value pairs) to expose in the error response. */ public function __construct( $error_code, $error, $additional_data = [] ) { $this->error_code = $error_code; $this->error = $error; $this->additional_data = array_filter( (array) $additional_data ); parent::__construct( '', 409 ); } /** * Returns the error code. * * @return string */ public function getErrorCode() { return $this->error_code; } /** * Returns the list of messages. * * @return WP_Error */ public function getError() { return $this->error; } /** * Returns additional error data. * * @return array */ public function getAdditionalData() { return $this->additional_data; } } Exceptions/RouteException.php 0000777 00000002371 15251730534 0012372 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Exceptions; /** * RouteException class. */ class RouteException extends \Exception { /** * Sanitized error code. * * @var string */ public $error_code; /** * Additional error data. * * @var array */ public $additional_data = []; /** * Setup exception. * * @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`. * @param string $message User-friendly translated error message, e.g. 'Product ID is invalid'. * @param int $http_status_code Proper HTTP status code to respond with, e.g. 400. * @param array $additional_data Extra data (key value pairs) to expose in the error response. */ public function __construct( $error_code, $message, $http_status_code = 400, $additional_data = [] ) { $this->error_code = $error_code; $this->additional_data = array_filter( (array) $additional_data ); parent::__construct( $message, $http_status_code ); } /** * Returns the error code. * * @return string */ public function getErrorCode() { return $this->error_code; } /** * Returns additional error data. * * @return array */ public function getAdditionalData() { return $this->additional_data; } } Exceptions/TooManyInCartException.php 0000777 00000000436 15251730534 0013763 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Exceptions; /** * TooManyInCartException class. * * This exception is thrown when more than one of a product that can only be purchased individually is in a cart. */ class TooManyInCartException extends StockAvailabilityException {} Exceptions/StockAvailabilityException.php 0000777 00000003044 15251730534 0014710 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Exceptions; /** * StockAvailabilityException class. * * This exception is thrown when more than one of a product that can only be purchased individually is in a cart. */ class StockAvailabilityException extends \Exception { /** * Sanitized error code. * * @var string */ public $error_code; /** * The name of the product that can only be purchased individually. * * @var string */ public $product_name; /** * Additional error data. * * @var array */ public $additional_data = []; /** * Setup exception. * * @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`. * @param string $product_name The name of the product that can only be purchased individually. * @param array $additional_data Extra data (key value pairs) to expose in the error response. */ public function __construct( $error_code, $product_name, $additional_data = [] ) { $this->error_code = $error_code; $this->product_name = $product_name; $this->additional_data = array_filter( (array) $additional_data ); parent::__construct(); } /** * Returns the error code. * * @return string */ public function getErrorCode() { return $this->error_code; } /** * Returns additional error data. * * @return array */ public function getAdditionalData() { return $this->additional_data; } /** * Returns the product name. * * @return string */ public function getProductName() { return $this->product_name; } } Exceptions/InvalidCartException.php 0000777 00000002747 15251730534 0013503 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Exceptions; use WP_Error; /** * InvalidCartException class. * * @internal This exception is thrown if the cart is in an erroneous state. */ class InvalidCartException extends \Exception { /** * Sanitized error code. * * @var string */ public $error_code; /** * Additional error data. * * @var array */ public $additional_data = []; /** * All errors to display to the user. * * @var WP_Error */ public $error; /** * Setup exception. * * @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`. * @param WP_Error $error The WP_Error object containing all errors relating to stock availability. * @param array $additional_data Extra data (key value pairs) to expose in the error response. */ public function __construct( $error_code, WP_Error $error, $additional_data = [] ) { $this->error_code = $error_code; $this->error = $error; $this->additional_data = array_filter( (array) $additional_data ); parent::__construct( '', 409 ); } /** * Returns the error code. * * @return string */ public function getErrorCode() { return $this->error_code; } /** * Returns the list of messages. * * @return WP_Error */ public function getError() { return $this->error; } /** * Returns additional error data. * * @return array */ public function getAdditionalData() { return $this->additional_data; } } Exceptions/OutOfStockException.php 0000777 00000000374 15251730534 0013335 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Exceptions; /** * OutOfStockException class. * * This exception is thrown when an item in a draft order is out of stock completely. */ class OutOfStockException extends StockAvailabilityException {} SchemaController.php 0000777 00000006540 15251730534 0010542 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; /** * SchemaController class. */ class SchemaController { /** * Stores schema class instances. * * @var Schemas\V1\AbstractSchema[] */ protected $schemas = []; /** * Stores Rest Extending instance * * @var ExtendSchema */ private $extend; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. */ public function __construct( ExtendSchema $extend ) { $this->extend = $extend; $this->schemas = [ 'v1' => [ Schemas\V1\BatchSchema::IDENTIFIER => Schemas\V1\BatchSchema::class, Schemas\V1\ErrorSchema::IDENTIFIER => Schemas\V1\ErrorSchema::class, Schemas\V1\ImageAttachmentSchema::IDENTIFIER => Schemas\V1\ImageAttachmentSchema::class, Schemas\V1\TermSchema::IDENTIFIER => Schemas\V1\TermSchema::class, Schemas\V1\BillingAddressSchema::IDENTIFIER => Schemas\V1\BillingAddressSchema::class, Schemas\V1\ShippingAddressSchema::IDENTIFIER => Schemas\V1\ShippingAddressSchema::class, Schemas\V1\CartShippingRateSchema::IDENTIFIER => Schemas\V1\CartShippingRateSchema::class, Schemas\V1\CartCouponSchema::IDENTIFIER => Schemas\V1\CartCouponSchema::class, Schemas\V1\CartFeeSchema::IDENTIFIER => Schemas\V1\CartFeeSchema::class, Schemas\V1\CartItemSchema::IDENTIFIER => Schemas\V1\CartItemSchema::class, Schemas\V1\CartSchema::IDENTIFIER => Schemas\V1\CartSchema::class, Schemas\V1\CartExtensionsSchema::IDENTIFIER => Schemas\V1\CartExtensionsSchema::class, Schemas\V1\CheckoutOrderSchema::IDENTIFIER => Schemas\V1\CheckoutOrderSchema::class, Schemas\V1\CheckoutSchema::IDENTIFIER => Schemas\V1\CheckoutSchema::class, Schemas\V1\OrderItemSchema::IDENTIFIER => Schemas\V1\OrderItemSchema::class, Schemas\V1\OrderCouponSchema::IDENTIFIER => Schemas\V1\OrderCouponSchema::class, Schemas\V1\OrderFeeSchema::IDENTIFIER => Schemas\V1\OrderFeeSchema::class, Schemas\V1\OrderSchema::IDENTIFIER => Schemas\V1\OrderSchema::class, Schemas\V1\ProductSchema::IDENTIFIER => Schemas\V1\ProductSchema::class, Schemas\V1\ProductAttributeSchema::IDENTIFIER => Schemas\V1\ProductAttributeSchema::class, Schemas\V1\ProductCategorySchema::IDENTIFIER => Schemas\V1\ProductCategorySchema::class, Schemas\V1\ProductBrandSchema::IDENTIFIER => Schemas\V1\ProductBrandSchema::class, Schemas\V1\ProductCollectionDataSchema::IDENTIFIER => Schemas\V1\ProductCollectionDataSchema::class, Schemas\V1\ProductReviewSchema::IDENTIFIER => Schemas\V1\ProductReviewSchema::class, Schemas\V1\PatternsSchema::IDENTIFIER => Schemas\V1\PatternsSchema::class, Schemas\V1\Agentic\CheckoutSessionSchema::IDENTIFIER => Schemas\V1\Agentic\CheckoutSessionSchema::class, ], ]; } /** * Get a schema class instance. * * @throws \Exception If the schema does not exist. * * @param string $name Name of schema. * @param int $version API Version being requested. * @return Schemas\V1\AbstractSchema A new instance of the requested schema. */ public function get( $name, $version = 1 ) { $schema = $this->schemas[ "v{$version}" ][ $name ] ?? false; if ( ! $schema ) { throw new \Exception( "{$name} v{$version} schema does not exist" ); } return new $schema( $this->extend, $this ); } } Legacy.php 0000777 00000006131 15251730534 0006476 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi; use Automattic\WooCommerce\StoreApi\Payments\PaymentContext; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler; use Automattic\WooCommerce\Blocks\Package; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * Legacy class. */ class Legacy { /** * Hook into WP lifecycle events. */ public function init() { add_action( 'woocommerce_rest_checkout_process_payment_with_context', array( $this, 'process_legacy_payment' ), 999, 2 ); } /** * Attempt to process a payment for the checkout API if no payment methods support the * woocommerce_rest_checkout_process_payment_with_context action. * * @param PaymentContext $context Holds context for the payment. * @param PaymentResult $result Result of the payment. * * @throws RouteException If the gateway returns an explicit error message. */ public function process_legacy_payment( PaymentContext $context, PaymentResult &$result ) { if ( $result->status ) { return; } // phpcs:ignore WordPress.Security.NonceVerification $post_data = $_POST; // Set constants. wc_maybe_define_constant( 'WOOCOMMERCE_CHECKOUT', true ); // Add the payment data from the API to the POST global. $_POST = $context->payment_data; // Call the process payment method of the chosen gateway. $payment_method_object = $context->get_payment_method_instance(); if ( ! $payment_method_object instanceof \WC_Payment_Gateway ) { return; } $payment_method_object->validate_fields(); // If errors were thrown, we need to abort. NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_payment_error' ); // Process Payment. $gateway_result = $payment_method_object->process_payment( $context->order->get_id() ); // Restore $_POST data. $_POST = $post_data; // If the payment failed with a message, throw an exception. if ( isset( $gateway_result['result'] ) && 'failure' === $gateway_result['result'] ) { if ( isset( $gateway_result['message'] ) ) { throw new RouteException( 'woocommerce_rest_payment_error', esc_html( wp_strip_all_tags( $gateway_result['message'] ) ), 400 ); } else { NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_payment_error' ); } } // Handle result. If status was not returned we consider this invalid and return failure. $result_status = $gateway_result['result'] ?? 'failure'; // These are the same statuses supported by the API and indicate processing status. This is not the same as order status. $valid_status = array( 'success', 'failure', 'pending', 'error' ); $result->set_status( in_array( $result_status, $valid_status, true ) ? $result_status : 'failure' ); // If `process_payment` added notices but didn't set the status to failure, clear them. Notices are not displayed from the API unless status is failure. wc_clear_notices(); // set payment_details from result. $result->set_payment_details( array_merge( $result->payment_details, $gateway_result ) ); $result->set_redirect_url( $gateway_result['redirect'] ?? '' ); } } Routes/V1/Checkout.php 0000777 00000101430 15251730534 0010604 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait; use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException; use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait; use Automattic\WooCommerce\Internal\FraudProtection\BlockedSessionNotice; use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController; use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager; /** * Checkout class. */ class Checkout extends AbstractCartRoute { use DraftOrderTrait; use CheckoutTrait; /** * The route identifier. * * @var string */ const IDENTIFIER = 'checkout'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'checkout'; /** * Holds the current order being processed. * * @var \WC_Order */ private $order = null; /** * 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 '/checkout'; } /** * Checks if a nonce is required for the route. * * @param \WP_REST_Request $request Request. * @return bool */ protected function requires_nonce( \WP_REST_Request $request ) { return ! $this->has_cart_token( $request ); } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ), ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => array_merge( [ 'payment_data' => [ 'description' => __( 'Data to pass through to the payment method when processing payment.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'key' => [ 'type' => 'string', ], 'value' => [ 'type' => [ 'string', 'boolean' ], ], ], ], ], 'customer_password' => [ 'description' => __( 'Customer password for new accounts, if applicable.', 'woocommerce' ), 'type' => 'string', ], ], $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ) ), ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => array_merge( [ 'additional_fields' => [ 'description' => __( 'Additional fields related to the order.', 'woocommerce' ), 'type' => 'object', ], 'payment_method' => [ 'description' => __( 'Selected payment method for the order.', 'woocommerce' ), 'type' => 'string', ], 'order_notes' => [ 'description' => __( 'Order notes.', 'woocommerce' ), 'type' => 'string', ], ], $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE ) ), ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Get the route response based on the type of request. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function get_response( \WP_REST_Request $request ) { $this->load_cart_session( $request ); $response = null; $nonce_check = $this->requires_nonce( $request ) ? $this->check_nonce( $request ) : null; if ( is_wp_error( $nonce_check ) ) { $response = $nonce_check; } // Block early if session is blocked by fraud protection. if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() && wc_get_container()->get( SessionClearanceManager::class )->is_session_blocked() ) { $response = $this->get_route_error_response( 'woocommerce_rest_checkout_error', wc_get_container()->get( BlockedSessionNotice::class )->get_message_plaintext( 'checkout' ), 403 ); } if ( ! $response ) { try { $response = $this->get_response_by_request_method( $request ); } catch ( InvalidCartException $error ) { $response = $this->get_route_error_response_from_object( $error->getError(), $error->getCode(), $error->getAdditionalData() ); } catch ( RouteException $error ) { $response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() ); } catch ( \Exception $error ) { $response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 ); } } if ( is_wp_error( $response ) ) { $response = $this->error_to_response( $response ); // If we encountered an exception, free up stock and release held coupons. if ( $this->order ) { wc_release_stock_for_order( $this->order ); wc_release_coupons_for_order( $this->order ); } if ( $request->get_method() === \WP_REST_Server::CREATABLE ) { // Step logs the exception. If nothing abnormal occurred during the place order POST request, flow the log is removed. wc_log_order_step( '[Store API #FAIL] Placing Order failed', array( 'status' => $response->get_status(), 'data' => $response->get_data(), ), true ); } } return $this->add_response_headers( $response ); } /** * Convert the cart into a new draft order, or update an existing draft order, and return an updated cart response. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $this->create_or_update_draft_order( $request ); return $this->prepare_item_for_response( (object) [ 'order' => $this->order, 'payment_result' => new PaymentResult(), ], $request ); } /** * Validation callback for the checkout route. * * This runs after individual field validation_callbacks have been called. * * @param \WP_REST_Request $request Request object. * @return true|\WP_Error */ public function validate_callback( $request ) { $validate_contexts = [ 'shipping_address' => [ 'group' => 'shipping', 'location' => 'address', 'param' => 'shipping_address', ], 'billing_address' => [ 'group' => 'billing', 'location' => 'address', 'param' => 'billing_address', ], 'contact' => [ 'group' => 'other', 'location' => 'contact', 'param' => 'additional_fields', ], 'order' => [ 'group' => 'other', 'location' => 'order', 'param' => 'additional_fields', ], ]; if ( ! WC()->cart->needs_shipping() ) { unset( $validate_contexts['shipping_address'] ); } $invalid_groups = []; $invalid_details = []; $is_partial = in_array( $request->get_method(), [ 'PUT', 'PATCH' ], true ); foreach ( $validate_contexts as $context => $context_data ) { $errors = new \WP_Error(); $document_object = $this->get_document_object_from_rest_request( $request ); $document_object->set_context( $context ); $additional_fields = $this->additional_fields_controller->get_contextual_fields_for_location( $context_data['location'], $document_object ); // These values are used to validate custom rules and generate the document object. $field_values = (array) $request->get_param( $context_data['param'] ) ?? []; foreach ( $additional_fields as $field_key => $field ) { // Skip values that were not posted if the request is partial or the field is not required. if ( ! isset( $field_values[ $field_key ] ) && ( $is_partial || true !== $field['required'] ) ) { continue; } // Clean the field value to trim whitespace. $field_value = wc_clean( wp_unslash( $field_values[ $field_key ] ?? '' ) ); if ( empty( $field_value ) ) { if ( true === $field['required'] ) { /* translators: %s: is the field label */ $error_message = sprintf( __( '%s is required', 'woocommerce' ), $field['label'] ); if ( 'shipping_address' === $context ) { /* translators: %s: is the field error message */ $error_message = sprintf( __( 'There was a problem with the provided shipping address: %s', 'woocommerce' ), $error_message ); } elseif ( 'billing_address' === $context ) { /* translators: %s: is the field error message */ $error_message = sprintf( __( 'There was a problem with the provided billing address: %s', 'woocommerce' ), $error_message ); } $errors->add( 'woocommerce_required_checkout_field', $error_message, [ 'key' => $field_key ] ); } continue; } $valid_check = $this->additional_fields_controller->validate_field( $field, $field_value ); if ( is_wp_error( $valid_check ) && $valid_check->has_errors() ) { foreach ( $valid_check->get_error_codes() as $code ) { $valid_check->add_data( array( 'location' => $context_data['location'], 'key' => $field_key, ), $code ); } $errors->merge_from( $valid_check ); continue; } } // Validate all fields for this location (this runs custom validation callbacks). $valid_location_check = $this->additional_fields_controller->validate_fields_for_location( $field_values, $context_data['location'], $context_data['group'] ); if ( is_wp_error( $valid_location_check ) && $valid_location_check->has_errors() ) { foreach ( $valid_location_check->get_error_codes() as $code ) { $valid_location_check->add_data( array( 'location' => $context_data['location'], ), $code ); } $errors->merge_from( $valid_location_check ); } if ( $errors->has_errors() ) { $invalid_groups[ $context_data['param'] ] = $errors->get_error_message(); $invalid_details[ $context_data['param'] ] = rest_convert_error_to_response( $errors )->get_data(); } } if ( $invalid_groups ) { return new \WP_Error( 'rest_invalid_param', /* translators: %s: List of invalid parameters. */ esc_html( sprintf( __( 'Invalid parameter(s): %s', 'woocommerce' ), implode( ', ', array_keys( $invalid_groups ) ) ) ), array( 'status' => 400, 'params' => $invalid_groups, 'details' => $invalid_details, ) ); } return true; } /** * Get route response for PUT requests. * * @param \WP_REST_Request $request Request object. * @throws RouteException On error. * @return \WP_REST_Response|\WP_Error */ protected function get_route_update_response( \WP_REST_Request $request ) { $validation_callback = $this->validate_callback( $request ); if ( is_wp_error( $validation_callback ) ) { return $validation_callback; } /** * Create (or update) Draft Order and process request data. */ $this->create_or_update_draft_order( $request ); /** * Persist additional fields, order notes and payment method for order. */ $this->update_order_from_request( $request ); if ( $request->get_param( '__experimental_calc_totals' ) ) { /** * Before triggering validation, ensure totals are current and in turn, things such as shipping costs are present. * This is so plugins that validate other cart data (e.g. conditional shipping and payments) can access this data. */ $this->cart_controller->calculate_totals(); /** * Validate that the cart is not empty. */ $this->cart_controller->validate_cart_not_empty(); /** * Validate items and fix violations before the order is processed. */ $this->cart_controller->validate_cart(); } $this->order->save(); return $this->prepare_item_for_response( (object) [ 'order' => wc_get_order( $this->order ), 'cart' => $this->cart_controller->get_cart_instance(), ], $request ); } /** * Process an order. * * 1. Obtain Draft Order * 2. Process Request * 3. Process Customer * 4. Validate Order * 5. Process Payment * * @throws RouteException On error. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response|\WP_Error */ protected function get_route_post_response( \WP_REST_Request $request ) { wc_log_order_step( '[Store API #1] Place Order flow initiated', null, false, true ); $validation_callback = $this->validate_callback( $request ); if ( is_wp_error( $validation_callback ) ) { return $validation_callback; } /** * Ensure required permissions based on store settings are valid to place the order. */ $this->validate_user_can_place_order(); /** * Before triggering validation, ensure totals are current and in turn, things such as shipping costs are present. * This is so plugins that validate other cart data (e.g. conditional shipping and payments) can access this data. */ $this->cart_controller->calculate_totals(); /** * Validate that the cart is not empty. */ $this->cart_controller->validate_cart_not_empty(); wc_log_order_step( '[Store API #2] Cart validated' ); /** * Validate items and fix violations before the order is processed. */ $this->cart_controller->validate_cart(); /** * Persist customer session data from the request first so that OrderController::update_addresses_from_cart * uses the up-to-date customer address. */ $this->update_customer_from_request( $request ); wc_log_order_step( '[Store API #3] Updated customer data from request' ); /** * Create (or update) Draft Order and process request data. */ $this->create_or_update_draft_order( $request ); wc_log_order_step( '[Store API #4] Created/Updated draft order', array( 'order_object' => $this->order ) ); $this->update_order_from_request( $request ); wc_log_order_step( '[Store API #5] Updated order with posted data', array( 'order_object' => $this->order ) ); $this->process_customer( $request ); wc_log_order_step( '[Store API #6] Created and/or persisted customer data from order', array( 'order_object' => $this->order ) ); /** * Validate updated order before payment is attempted. */ $this->order_controller->validate_order_before_payment( $this->order ); wc_log_order_step( '[Store API #7] Validated order data', array( 'order_object' => $this->order ) ); /** * Hold coupons for the order as soon as the draft order is created. */ try { // $this->order->get_billing_email() is already validated by validate_order_before_payment() $this->order->hold_applied_coupons( $this->order->get_billing_email() ); } catch ( \Exception $e ) { // Turn the Exception into a RouteException for the API. throw new RouteException( 'woocommerce_rest_coupon_reserve_failed', esc_html( $e->getMessage() ), 400 ); } /** * Reserve stock for the order. * * In the shortcode based checkout, when POSTing the checkout form the order would be created and fire the * `woocommerce_checkout_order_created` action. This in turn would trigger the `wc_reserve_stock_for_order` * function so that stock would be held pending payment. * * Via the block based checkout and Store API we already have a draft order, but when POSTing to the /checkout * endpoint we do the same; reserve stock for the order to allow time to process payment. * * Note, stock is only "held" while the order has the status wc-checkout-draft or pending. Stock is freed when * the order changes status, or there is an exception. * * @see ReserveStock::get_query_for_reserved_stock() * * @since 9.2 Stock is no longer held for all draft orders, nor on non-POST requests. See https://github.com/woocommerce/woocommerce/issues/44231 * @since 9.2 Uses wc_reserve_stock_for_order() instead of using the ReserveStock class directly. */ try { wc_reserve_stock_for_order( $this->order ); } catch ( ReserveStockException $e ) { throw new RouteException( esc_html( $e->getErrorCode() ), esc_html( $e->getMessage() ), esc_html( $e->getCode() ) ); } wc_log_order_step( '[Store API #8] Reserved stock for order', array( 'order_object' => $this->order ) ); wc_do_deprecated_action( '__experimental_woocommerce_blocks_checkout_order_processed', array( $this->order, ), '6.3.0', 'woocommerce_store_api_checkout_order_processed', 'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_order_processed instead.' ); wc_do_deprecated_action( 'woocommerce_blocks_checkout_order_processed', array( $this->order, ), '7.2.0', 'woocommerce_store_api_checkout_order_processed', 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_order_processed instead.' ); // Set the order status to 'pending' as an initial step. // This allows the order to proceed towards completion. The hook // 'woocommerce_store_api_checkout_order_processed' (fired below) can be used // to set a custom status *after* this point. // If payment isn't needed, the custom status is kept. If payment is needed, // the payment gateway's statuses take precedence. $this->order->update_status( 'pending' ); /** * Fires before an order is processed by the Checkout Block/Store API. * * This hook informs extensions that $order has completed processing and is ready for payment. * * This is similar to existing core hook woocommerce_checkout_order_processed. We're using a new action: * - To keep the interface focused (only pass $order, not passing request data). * - This also explicitly indicates these orders are from checkout block/StoreAPI. * * @since 7.2.0 * * @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3238 * @example See docs/examples/checkout-order-processed.md * @param \WC_Order $order Order object. */ do_action( 'woocommerce_store_api_checkout_order_processed', $this->order ); /** * Process the payment and return the results. */ $payment_result = new PaymentResult(); if ( $this->order->needs_payment() ) { $this->process_payment( $request, $payment_result ); } else { $this->process_without_payment( $request, $payment_result ); } wc_log_order_step( '[Store API #9] Order processed', array( 'order_object' => $this->order, 'processed_with_payment' => $this->order->needs_payment() ? 'yes' : 'no', 'payment_status' => $payment_result->status, ), true ); return $this->prepare_item_for_response( (object) [ 'order' => wc_get_order( $this->order ), 'payment_result' => $payment_result, ], $request ); } /** * Get route response when something went wrong. * * @param string $error_code String based error code. * @param string $error_message User facing error message. * @param int $http_status_code HTTP status. Defaults to 500. * @param array $additional_data Extra data (key value pairs) to expose in the error response. * @return \WP_Error WP Error object. */ protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) { $error_from_message = new \WP_Error( $error_code, $error_message ); // 409 is when there was a conflict, so we return the cart so the client can resolve it. if ( 409 === $http_status_code ) { return $this->add_data_to_error_object( $error_from_message, $additional_data, $http_status_code, true ); } return $this->add_data_to_error_object( $error_from_message, $additional_data, $http_status_code ); } /** * Get route response when something went wrong. * * @param \WP_Error $error_object User facing error message. * @param int $http_status_code HTTP status. Defaults to 500. * @param array $additional_data Extra data (key value pairs) to expose in the error response. * @return \WP_Error WP Error object. */ protected function get_route_error_response_from_object( $error_object, $http_status_code = 500, $additional_data = [] ) { // 409 is when there was a conflict, so we return the cart so the client can resolve it. if ( 409 === $http_status_code ) { return $this->add_data_to_error_object( $error_object, $additional_data, $http_status_code, true ); } return $this->add_data_to_error_object( $error_object, $additional_data, $http_status_code ); } /** * Adds additional data to the \WP_Error object. * * @param \WP_Error $error The error object to add the cart to. * @param array $data The data to add to the error object. * @param int $http_status_code The HTTP status code this error should return. * @param bool $include_cart Whether the cart should be included in the error data. * @returns \WP_Error The \WP_Error with the cart added. */ private function add_data_to_error_object( $error, $data, $http_status_code, bool $include_cart = false ) { $data = array_merge( $data, [ 'status' => $http_status_code ] ); if ( $include_cart ) { $data = array_merge( $data, [ 'cart' => $this->cart_schema->get_item_response( $this->cart_controller->get_cart_for_response() ) ] ); } $error->add_data( $data ); return $error; } /** * Create or update a draft order based on the cart. * * @param \WP_REST_Request $request Full details about the request. * @throws RouteException On error. */ private function create_or_update_draft_order( \WP_REST_Request $request ) { $this->order = $this->get_draft_order(); if ( ! $this->order ) { $this->order = $this->order_controller->create_order_from_cart(); wc_log_order_step( '[Store API #4::create_or_update_draft_order] Created order from cart', array( 'order_object' => $this->order ) ); } else { $this->order_controller->update_order_from_cart( $this->order, true ); wc_log_order_step( '[Store API #4::create_or_update_draft_order] Updated order from cart', array( 'order_object' => $this->order ) ); } wc_do_deprecated_action( '__experimental_woocommerce_blocks_checkout_update_order_meta', array( $this->order, ), '6.3.0', 'woocommerce_store_api_checkout_update_order_meta', 'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_meta instead.' ); wc_do_deprecated_action( 'woocommerce_blocks_checkout_update_order_meta', array( $this->order, ), '7.2.0', 'woocommerce_store_api_checkout_update_order_meta', 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_meta instead.' ); /** * Fires when the Checkout Block/Store API updates an order's meta data. * * This hook gives extensions the chance to add or update meta data on the $order. * Throwing an exception from a callback attached to this action will make the Checkout Block render in a warning state, effectively preventing checkout. * * This is similar to existing core hook woocommerce_checkout_update_order_meta. * We're using a new action: * - To keep the interface focused (only pass $order, not passing request data). * - This also explicitly indicates these orders are from checkout block/StoreAPI. * * @since 7.2.0 * * @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3686 * * @param \WC_Order $order Order object. */ do_action( 'woocommerce_store_api_checkout_update_order_meta', $this->order ); // Confirm order is valid before proceeding further. if ( ! $this->order instanceof \WC_Order ) { throw new RouteException( 'woocommerce_rest_checkout_missing_order', esc_html__( 'Unable to create order', 'woocommerce' ), 500 ); } // Store order ID to session. $this->set_draft_order_id( $this->order->get_id() ); wc_log_order_step( '[Store API #4::create_or_update_draft_order] Set order draft id', array( 'order_object' => $this->order ) ); } /** * Updates a customer address field. * * @param \WC_Customer $customer The customer to update. * @param string $key The key of the field to update. * @param mixed $value The value to update the field to. * @param string $address_type The type of address to update (billing|shipping). */ private function update_customer_address_field( $customer, $key, $value, $address_type ) { $callback = "set_{$address_type}_{$key}"; if ( is_callable( [ $customer, $callback ] ) ) { $customer->$callback( $value ); return; } if ( $this->additional_fields_controller->is_field( $key ) ) { $this->additional_fields_controller->persist_field_for_customer( $key, $value, $customer, $address_type ); } } /** * Updates the current customer session using data from the request (e.g. address data). * * Address session data is synced to the order itself later on by OrderController::update_order_from_cart() * * @param \WP_REST_Request $request Full details about the request. */ private function update_customer_from_request( \WP_REST_Request $request ) { $customer = WC()->customer; $additional_field_contexts = [ 'shipping_address' => [ 'group' => 'shipping', 'location' => 'address', 'param' => 'shipping_address', ], 'billing_address' => [ 'group' => 'billing', 'location' => 'address', 'param' => 'billing_address', ], 'contact' => [ 'group' => 'other', 'location' => 'contact', 'param' => 'additional_fields', ], ]; foreach ( $additional_field_contexts as $context => $context_data ) { $document_object = $this->get_document_object_from_rest_request( $request ); $document_object->set_context( $context ); $additional_fields = $this->additional_fields_controller->get_contextual_fields_for_location( $context_data['location'], $document_object ); if ( 'shipping_address' === $context_data['param'] ) { $field_values = (array) $request['shipping_address'] ?? ( $request['billing_address'] ?? [] ); if ( ! WC()->cart->needs_shipping() ) { $field_values = $request['billing_address'] ?? []; } } else { $field_values = (array) $request[ $context_data['param'] ] ?? []; } if ( 'address' === $context_data['location'] ) { $persist_keys = array_merge( $this->additional_fields_controller->get_address_fields_keys(), [ 'email' ], array_keys( $additional_fields ) ); } else { $persist_keys = array_keys( $additional_fields ); } foreach ( $field_values as $key => $value ) { if ( in_array( $key, $persist_keys, true ) ) { $this->update_customer_address_field( $customer, $key, $value, $context_data['group'] ); } } wc_log_order_step( '[Store API #3::update_customer_from_request] Persisted ' . $context . ' fields' ); } /** * Fires when the Checkout Block/Store API updates a customer from the API request data. * * @since 8.2.0 * * @param \WC_Customer $customer Customer object. * @param \WP_REST_Request $request Full details about the request. */ do_action( 'woocommerce_store_api_checkout_update_customer_from_request', $customer, $request ); $customer->save(); } /** * Gets the chosen payment method from the request. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WC_Payment_Gateway|null */ private function get_request_payment_method( \WP_REST_Request $request ) { $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); $request_payment_method = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) ); // For PUT requests, the order never requires payment, only POST does. $requires_payment_method = $this->order->needs_payment() && 'POST' === $request->get_method(); if ( empty( $request_payment_method ) ) { if ( $requires_payment_method ) { throw new RouteException( 'woocommerce_rest_checkout_missing_payment_method', esc_html__( 'No payment method provided.', 'woocommerce' ), 400 ); } return null; } if ( ! isset( $available_gateways[ $request_payment_method ] ) ) { $all_payment_gateways = WC()->payment_gateways->payment_gateways(); $gateway_title = isset( $all_payment_gateways[ $request_payment_method ] ) ? $all_payment_gateways[ $request_payment_method ]->get_title() : $request_payment_method; throw new RouteException( 'woocommerce_rest_checkout_payment_method_disabled', sprintf( // Translators: %s Payment method ID. esc_html__( '%s is not available for this order—please choose a different payment method', 'woocommerce' ), esc_html( $gateway_title ) ), 400 ); } return $available_gateways[ $request_payment_method ]; } /** * Order processing relating to customer account. * * Creates a customer account as needed (based on request & store settings) and updates the order with the new customer ID. * Updates the order with user details (e.g. address). * * @throws RouteException API error object with error details. * @param \WP_REST_Request $request Request object. */ private function process_customer( \WP_REST_Request $request ) { if ( $this->should_create_customer_account( $request ) ) { $customer_id = wc_create_new_customer( $request['billing_address']['email'], '', $request['customer_password'], [ 'first_name' => $request['billing_address']['first_name'], 'last_name' => $request['billing_address']['last_name'], 'source' => 'store-api', ] ); if ( is_wp_error( $customer_id ) ) { throw new RouteException( esc_html( $customer_id->get_error_code() ), esc_html( $customer_id->get_error_message() ), 400 ); } // Associate customer with the order. $this->order->set_customer_id( $customer_id ); $this->order->save(); // Set the customer auth cookie. wc_set_customer_auth_cookie( $customer_id ); wc_log_order_step( '[Store API #6::process_customer] Created new customer', array( 'customer_id' => $customer_id ) ); } // Persist customer address data to account. $this->order_controller->sync_customer_data_with_order( $this->order ); wc_log_order_step( '[Store API #6::process_customer] Synced customer data from order', array( 'customer_id' => $this->order->get_customer_id() ) ); } /** * Check request options and store (shop) config to determine if a user account should be created as part of order * processing. * * @param \WP_REST_Request $request The current request object being handled. * @return boolean True if a new user account should be created. */ private function should_create_customer_account( \WP_REST_Request $request ) { if ( is_user_logged_in() ) { return false; } // Return false if registration is not enabled for the store. if ( false === filter_var( WC()->checkout()->is_registration_enabled(), FILTER_VALIDATE_BOOLEAN ) ) { return false; } // Return true if the store requires an account for all purchases. Note - checkbox is not displayed to shopper in this case. if ( true === filter_var( WC()->checkout()->is_registration_required(), FILTER_VALIDATE_BOOLEAN ) ) { return true; } // Create an account if requested via the endpoint. if ( true === filter_var( $request['create_account'], FILTER_VALIDATE_BOOLEAN ) ) { // User has requested an account as part of checkout processing. return true; } return false; } /** * This validates if the order can be placed regarding settings in WooCommerce > Settings > Accounts & Privacy * If registration during checkout is disabled, guest checkout is disabled and the user is not logged in, prevent checkout. * * @throws RouteException If user cannot place order. */ private function validate_user_can_place_order() { if ( // "woocommerce_enable_signup_and_login_from_checkout" === no. false === filter_var( WC()->checkout()->is_registration_enabled(), FILTER_VALIDATE_BOOLEAN ) && // "woocommerce_enable_guest_checkout" === no. true === filter_var( WC()->checkout()->is_registration_required(), FILTER_VALIDATE_BOOLEAN ) && ! is_user_logged_in() ) { throw new RouteException( 'woocommerce_rest_guest_checkout_disabled', esc_html( /** * Filter to customize the checkout message when a user must be logged in. * * @since 9.4.3 * * @param string $message Message to display when a user must be logged in to check out. */ apply_filters( 'woocommerce_checkout_must_be_logged_in_message', __( 'You must be logged in to checkout.', 'woocommerce' ) ) ), 403 ); } } } Routes/V1/ProductCategoriesById.php 0000777 00000003750 15251730534 0013243 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * ProductCategoriesById class. */ class ProductCategoriesById extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-categories-by-id'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product-category'; /** * 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 '/products/categories/(?P<id>[\d]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', ), ), [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => array( 'context' => $this->get_context_param( array( 'default' => 'view', ) ), ), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a single item. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $object = get_term( (int) $request['id'], 'product_cat' ); if ( ! $object || 0 === $object->id ) { throw new RouteException( 'woocommerce_rest_category_invalid_id', __( 'Invalid category ID.', 'woocommerce' ), 404 ); } $data = $this->prepare_item_for_response( $object, $request ); return rest_ensure_response( $data ); } } Routes/V1/CartAddItem.php 0000777 00000007716 15251730534 0011174 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartAddItem class. */ class CartAddItem extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-add-item'; /** * 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 '/cart/add-item'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'id' => [ 'description' => __( 'The cart item product or variation ID.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'sanitize_callback' => 'absint', ], 'quantity' => [ 'description' => __( 'Quantity of this item to add to the cart.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'arg_options' => [ 'sanitize_callback' => 'wc_stock_amount', ], ], 'variation' => [ 'description' => __( 'Chosen attributes (for variations).', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'attribute' => [ 'description' => __( 'Variation attribute name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'value' => [ 'description' => __( 'Variation attribute value.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], ], ], ], ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Handle the request and return a valid response for this endpoint. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { // Do not allow key to be specified during creation. if ( ! empty( $request['key'] ) ) { throw new RouteException( 'woocommerce_rest_cart_item_exists', esc_html__( 'Cannot create an existing cart item.', 'woocommerce' ), 400 ); } /** * Filters cart item data sent via the API before it is passed to the cart controller. * * This hook filters cart items. It allows the request data to be changed, for example, quantity, or * supplemental cart item data, before it is passed into CartController::add_to_cart and stored to session. * * CartController::add_to_cart only expects the keys id, quantity, variation, and cart_item_data, so other values * may be ignored. CartController::add_to_cart (and core) do already have a filter hook called * woocommerce_add_cart_item, but this does not have access to the original Store API request like this hook does. * * @since 8.8.0 * * @param array $add_to_cart_data An array of cart item data. * @return array */ $add_to_cart_data = apply_filters( 'woocommerce_store_api_add_to_cart_data', array( 'id' => $request['id'], 'quantity' => $request['quantity'], 'variation' => $request['variation'], 'cart_item_data' => [], ), $request ); $this->cart_controller->add_to_cart( $add_to_cart_data ); $response = rest_ensure_response( $this->schema->get_item_response( $this->cart_controller->get_cart_for_response() ) ); $response->set_status( 201 ); return $response; } } Routes/V1/ProductTags.php 0000777 00000002361 15251730534 0011301 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; /** * ProductTags class. */ class ProductTags extends AbstractTermsRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-tags'; /** * 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 '/products/tags'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->get_collection_params(), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a collection of terms. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { return $this->get_terms_response( 'product_tag', $request ); } } Routes/V1/CartCouponsByCode.php 0000777 00000005250 15251730534 0012370 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartCouponsByCode class. */ class CartCouponsByCode extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-coupons-by-code'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'cart-coupon'; /** * 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 '/cart/coupons/(?P<code>[\w-]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => [ 'code' => [ 'description' => __( 'Unique identifier for the coupon within the cart.', 'woocommerce' ), 'type' => 'string', ], ], [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ), ], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Get a single cart coupon. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { if ( ! $this->cart_controller->has_coupon( $request['code'] ) ) { throw new RouteException( 'woocommerce_rest_cart_coupon_invalid_code', esc_html__( 'Coupon does not exist in the cart.', 'woocommerce' ), 404 ); } return $this->prepare_item_for_response( $request['code'], $request ); } /** * Delete a single cart coupon. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_delete_response( \WP_REST_Request $request ) { if ( ! $this->cart_controller->has_coupon( $request['code'] ) ) { throw new RouteException( 'woocommerce_rest_cart_coupon_invalid_code', esc_html__( 'Coupon does not exist in the cart.', 'woocommerce' ), 404 ); } $cart = $this->cart_controller->get_cart_instance(); $cart->remove_coupon( $request['code'] ); return new \WP_REST_Response( null, 204 ); } } Routes/V1/ProductCategories.php 0000777 00000002550 15251730534 0012470 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; /** * ProductCategories class. */ class ProductCategories extends AbstractTermsRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-categories'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product-category'; /** * 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 '/products/categories'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->get_collection_params(), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a collection of terms. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { return $this->get_terms_response( 'product_cat', $request ); } } Routes/V1/CartItemsByKey.php 0000777 00000010114 15251730534 0011674 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartItemsByKey class. */ class CartItemsByKey extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-items-by-key'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'cart-item'; /** * 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 '/cart/items/(?P<key>[\w-]{32})'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => [ 'key' => [ 'description' => __( 'Unique identifier for the item within the cart.', 'woocommerce' ), 'type' => 'string', ], ], [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ), ], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'get_response' ), 'permission_callback' => '__return_true', 'args' => $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE ), ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Get a single cart items. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $cart_item = $this->cart_controller->get_cart_item( $request['key'] ); if ( empty( $cart_item ) ) { throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woocommerce' ), 409 ); } $data = $this->prepare_item_for_response( $cart_item, $request ); $response = rest_ensure_response( $data ); return $response; } /** * Update a single cart item. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_update_response( \WP_REST_Request $request ) { $cart = $this->cart_controller->get_cart_instance(); if ( isset( $request['quantity'] ) ) { $this->cart_controller->set_cart_item_quantity( $request['key'], $request['quantity'] ); } return rest_ensure_response( $this->prepare_item_for_response( $this->cart_controller->get_cart_item( $request['key'] ), $request ) ); } /** * Delete a single cart item. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_delete_response( \WP_REST_Request $request ) { $cart = $this->cart_controller->get_cart_instance(); $cart_item = $this->cart_controller->get_cart_item( $request['key'] ); if ( empty( $cart_item ) ) { throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woocommerce' ), 409 ); } $cart->remove_cart_item( $request['key'] ); return new \WP_REST_Response( null, 204 ); } /** * Prepare links for the request. * * @param array $cart_item Object to prepare. * @param \WP_REST_Request $request Request object. * @return array */ protected function prepare_links( $cart_item, $request ) { $base = $this->get_namespace() . $this->get_path(); $links = array( 'self' => array( 'href' => rest_url( trailingslashit( $base ) . $cart_item['key'] ), ), 'collection' => array( 'href' => rest_url( $base ), ), ); return $links; } } Routes/V1/Patterns.php 0000777 00000005557 15251730534 0010654 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\Blocks\BlockPatterns; use Automattic\WooCommerce\Blocks\Package; use Automattic\WooCommerce\Blocks\Patterns\PTKClient; use Automattic\WooCommerce\Blocks\Patterns\PTKPatternsStore; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Exception; use WP_Error; use WP_REST_Request; use WP_REST_Response; /** * Patterns class. */ class Patterns extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'patterns'; /** * The schema item identifier. * * @var string */ const SCHEMA_TYPE = 'patterns'; /** * 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 '/patterns'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => function () { return is_user_logged_in(); }, ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => function () { return is_user_logged_in(); }, ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Fetch a single pattern from the PTK to ensure the API is available. * * @param WP_REST_Request $request Request object. * * @return WP_Error|\WP_HTTP_Response|WP_REST_Response * @throws RouteException If the patterns cannot be fetched. */ protected function get_route_response( WP_REST_Request $request ) { $ptk_client = Package::container()->get( PTKClient::class ); $response = $ptk_client->fetch_patterns( array( 'per_page' => 1, ) ); if ( is_wp_error( $response ) ) { throw new RouteException( wp_kses( $response->get_error_message(), array() ), wp_kses( $response->get_error_code(), array() ) ); } return rest_ensure_response( array( 'success' => true, ) ); } /** * Fetch the patterns from the PTK and update the transient. * * @param WP_REST_Request $request Request object. * * @return WP_REST_Response * @throws Exception If the patterns cannot be fetched. */ protected function get_route_post_response( WP_REST_Request $request ) { $ptk_patterns_store = Package::container()->get( PTKPatternsStore::class ); $patterns = $ptk_patterns_store->fetch_patterns(); $block_patterns = Package::container()->get( BlockPatterns::class ); $block_patterns->register_ptk_patterns( $patterns ); return rest_ensure_response( array( 'success' => true, ) ); } } Routes/V1/ProductsBySlug.php 0000777 00000005326 15251730534 0011777 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * ProductsBySlug class. */ class ProductsBySlug extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'products-by-slug'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product'; /** * 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 '/products/(?P<slug>[\S]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => array( 'slug' => array( 'description' => __( 'Slug of the resource.', 'woocommerce' ), 'type' => 'string', ), ), [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => array( 'context' => $this->get_context_param( array( 'default' => 'view', ) ), ), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a single item. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $slug = sanitize_title( $request['slug'] ); $object = $this->get_product_by_slug( $slug ); if ( ! $object ) { $object = $this->get_product_variation_by_slug( $slug ); } if ( ! $object || 0 === $object->get_id() ) { throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 ); } return rest_ensure_response( $this->schema->get_item_response( $object ) ); } /** * Get a product by slug. * * @param string $slug The slug of the product. */ public function get_product_by_slug( $slug ) { return wc_get_product( get_page_by_path( $slug, OBJECT, 'product' ) ); } /** * Get a product variation by slug. * * @param string $slug The slug of the product variation. */ private function get_product_variation_by_slug( $slug ) { global $wpdb; $result = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_name, post_parent, post_type FROM $wpdb->posts WHERE post_name = %s AND post_type = 'product_variation'", $slug ) ); if ( ! $result ) { return null; } return wc_get_product( $result[0]->ID ); } } Routes/V1/Cart.php 0000777 00000002533 15251730534 0007734 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; /** * Cart class. */ class Cart extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart'; /** * 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 '/cart'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ), ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Handle the request and return a valid response for this endpoint. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { return rest_ensure_response( $this->schema->get_item_response( $this->cart_controller->get_cart_for_response() ) ); } } Routes/V1/AI/Products.php 0000777 00000000402 15251730534 0011130 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\StoreApi\Routes\V1\AI; /** * Products class. * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Products {} Routes/V1/AI/Middleware.php 0000777 00000000506 15251730534 0011407 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\StoreApi\Routes\V1\AI; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * Middleware class. * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Middleware {} Routes/V1/ProductReviews.php 0000777 00000015366 15251730534 0012040 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use WP_Comment_Query; use Automattic\WooCommerce\StoreApi\Utilities\Pagination; /** * ProductReviews class. */ class ProductReviews extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-reviews'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product-review'; /** * 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 '/products/reviews'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->get_collection_params(), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a collection of reviews. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $prepared_args = array( 'type' => 'review', 'status' => 'approve', 'no_found_rows' => false, 'offset' => $request['offset'], 'order' => $request['order'], 'number' => $request['per_page'], 'post__in' => $request['product_id'], ); /** * Map category id to list of product ids. */ if ( ! empty( $request['category_id'] ) ) { $category_ids = $request['category_id']; $child_ids = []; foreach ( $category_ids as $category_id ) { $child_ids = array_merge( $child_ids, get_term_children( $category_id, 'product_cat' ) ); } $category_ids = array_unique( array_merge( $category_ids, $child_ids ) ); $product_ids = get_objects_in_term( $category_ids, 'product_cat' ); $prepared_args['post__in'] = isset( $prepared_args['post__in'] ) ? array_merge( $prepared_args['post__in'], $product_ids ) : $product_ids; } if ( 'rating' === $request['orderby'] ) { $prepared_args['meta_query'] = array( // phpcs:ignore 'relation' => 'OR', array( 'key' => 'rating', 'compare' => 'EXISTS', ), array( 'key' => 'rating', 'compare' => 'NOT EXISTS', ), ); } $prepared_args['orderby'] = $this->normalize_query_param( $request['orderby'] ); if ( empty( $request['offset'] ) ) { $prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 ); } $query = new WP_Comment_Query(); $query_result = $query->query( $prepared_args ); $response_objects = array(); foreach ( $query_result as $review ) { $data = $this->prepare_item_for_response( $review, $request ); $response_objects[] = $this->prepare_response_for_collection( $data ); } $total_reviews = (int) $query->found_comments; $max_pages = (int) $query->max_num_pages; if ( $total_reviews < 1 ) { // Out-of-bounds, run the query again without LIMIT for total count. unset( $prepared_args['number'], $prepared_args['offset'] ); $query = new WP_Comment_Query(); $prepared_args['count'] = true; $total_reviews = $query->query( $prepared_args ); $max_pages = $request['per_page'] ? ceil( $total_reviews / $request['per_page'] ) : 1; } $response = rest_ensure_response( $response_objects ); $response = ( new Pagination() )->add_headers( $response, $request, $total_reviews, $max_pages ); return $response; } /** * Prepends internal property prefix to query parameters to match our response fields. * * @param string $query_param Query parameter. * @return string */ protected function normalize_query_param( $query_param ) { $prefix = 'comment_'; switch ( $query_param ) { case 'id': $normalized = $prefix . 'ID'; break; case 'product': $normalized = $prefix . 'post_ID'; break; case 'rating': $normalized = 'meta_value_num'; break; default: $normalized = $prefix . $query_param; break; } return $normalized; } /** * Get the query params for collections of products. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param(); $params['context']['default'] = 'view'; $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['per_page'] = array( 'description' => __( 'Maximum number of items to be returned in result set. Defaults to no limit if left blank.', 'woocommerce' ), 'type' => 'integer', 'default' => 10, 'minimum' => 0, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['offset'] = array( 'description' => __( 'Offset the result set by a specific number of items.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), 'type' => 'string', 'default' => 'desc', 'enum' => array( 'asc', 'desc' ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby'] = array( 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), 'type' => 'string', 'default' => 'date', 'enum' => array( 'date', 'date_gmt', 'id', 'rating', 'product', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['category_id'] = array( 'description' => __( 'Limit result set to reviews from specific category IDs.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_id'] = array( 'description' => __( 'Limit result set to reviews from specific product IDs.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } Routes/V1/Order.php 0000777 00000004363 15251730534 0010121 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; use Automattic\WooCommerce\StoreApi\Utilities\OrderController; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Utilities\OrderAuthorizationTrait; /** * Order class. */ class Order extends AbstractRoute { use OrderAuthorizationTrait; /** * The route identifier. * * @var string */ const IDENTIFIER = 'order'; /** * The schema item identifier. * * @var string */ const SCHEMA_TYPE = 'order'; /** * Order controller class instance. * * @var OrderController */ protected $order_controller; /** * Constructor. * * @param SchemaController $schema_controller Schema Controller instance. * @param AbstractSchema $schema Schema class for this route. */ public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) { parent::__construct( $schema_controller, $schema ); $this->order_controller = new OrderController(); } /** * 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 '/order/(?P<id>[\d]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => [ $this, 'is_authorized' ], 'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ), ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Handle the request and return a valid response for this endpoint. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $order_id = absint( $request['id'] ); return rest_ensure_response( $this->schema->get_item_response( wc_get_order( $order_id ) ) ); } } Routes/V1/CartItems.php 0000777 00000007503 15251730534 0010740 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartItems class. */ class CartItems extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-items'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'cart-item'; /** * 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 '/cart/items'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ), ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_response' ), 'permission_callback' => '__return_true', 'args' => $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ), ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Get a collection of cart items. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $cart_items = $this->cart_controller->get_cart_items(); $items = []; foreach ( $cart_items as $cart_item ) { $data = $this->prepare_item_for_response( $cart_item, $request ); $items[] = $this->prepare_response_for_collection( $data ); } $response = rest_ensure_response( $items ); return $response; } /** * Creates one item from the collection. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { // Do not allow key to be specified during creation. if ( ! empty( $request['key'] ) ) { throw new RouteException( 'woocommerce_rest_cart_item_exists', __( 'Cannot create an existing cart item.', 'woocommerce' ), 400 ); } $result = $this->cart_controller->add_to_cart( [ 'id' => $request['id'], 'quantity' => $request['quantity'], 'variation' => $request['variation'], ] ); $response = rest_ensure_response( $this->prepare_item_for_response( $this->cart_controller->get_cart_item( $result ), $request ) ); $response->set_status( 201 ); return $response; } /** * Deletes all items in the cart. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_delete_response( \WP_REST_Request $request ) { $this->cart_controller->empty_cart(); return new \WP_REST_Response( [], 200 ); } /** * Prepare links for the request. * * @param array $cart_item Object to prepare. * @param \WP_REST_Request $request Request object. * @return array */ protected function prepare_links( $cart_item, $request ) { $base = $this->get_namespace() . $this->get_path(); $links = array( 'self' => array( 'href' => rest_url( trailingslashit( $base ) . $cart_item['key'] ), ), 'collection' => array( 'href' => rest_url( $base ), ), ); return $links; } } Routes/V1/CheckoutOrder.php 0000777 00000017643 15251730534 0011614 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\StoreApi\Exceptions\InvalidStockLevelsInCartException; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Utilities\OrderAuthorizationTrait; use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait; /** * CheckoutOrder class. */ class CheckoutOrder extends AbstractCartRoute { use OrderAuthorizationTrait; use CheckoutTrait; /** * The route identifier. * * @var string */ const IDENTIFIER = 'checkout-order'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'checkout-order'; /** * Holds the current order being processed. * * @var \WC_Order */ private $order = null; /** * 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 '/checkout/(?P<id>[\d]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => [ $this, 'is_authorized' ], 'args' => array_merge( [ 'payment_data' => [ 'description' => __( 'Data to pass through to the payment method when processing payment.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'key' => [ 'type' => 'string', ], 'value' => [ 'type' => [ 'string', 'boolean' ], ], ], ], ], ], $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ) ), ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Process an order. * * 1. Process Request * 2. Process Customer * 3. Validate Order * 4. Process Payment * * @throws RouteException On error. * @throws InvalidStockLevelsInCartException On error. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { $order_id = absint( $request['id'] ); $this->order = wc_get_order( $order_id ); if ( ! $this->order || ! $this->order->needs_payment() ) { return new \WP_Error( 'invalid_order_update_status', __( 'This order cannot be paid for.', 'woocommerce' ) ); } /** * Process request data. * * Note: Customer data is persisted from the request first so that OrderController::update_addresses_from_cart * uses the up to date customer address. */ $this->update_billing_address( $request ); $this->update_order_from_request( $request ); /** * Process customer data. * * Update order with customer details, and sign up a user account as necessary. */ $this->process_customer( $request ); /** * Validate order. * * This logic ensures the order is valid before payment is attempted. */ $this->order_controller->validate_existing_order_before_payment( $this->order ); /** * Fires before an order is processed by the Checkout Block/Store API. * * This hook informs extensions that $order has completed processing and is ready for payment. * * This is similar to existing core hook woocommerce_checkout_order_processed. We're using a new action: * - To keep the interface focused (only pass $order, not passing request data). * - This also explicitly indicates these orders are from checkout block/StoreAPI. * * @since 7.2.0 * * @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3238 * @example See docs/examples/checkout-order-processed.md * @param \WC_Order $order Order object. */ do_action( 'woocommerce_store_api_checkout_order_processed', $this->order ); /** * Process the payment and return the results. */ $payment_result = new PaymentResult(); if ( $this->order->needs_payment() ) { $this->process_payment( $request, $payment_result ); } else { $this->process_without_payment( $request, $payment_result ); } return $this->prepare_item_for_response( (object) [ 'order' => wc_get_order( $this->order ), 'payment_result' => $payment_result, ], $request ); } /** * Since this endpoint only operates on existing orders, we don't need to do updates based on * the cart data. * * @param \WP_REST_Request $request Request object. */ protected function cart_updated( \WP_REST_Request $request ) {} /** * Updates the current customer session using data from the request (e.g. address data). * * Address session data is synced to the order itself later on by OrderController::update_order_from_cart() * * @param \WP_REST_Request $request Full details about the request. */ private function update_billing_address( \WP_REST_Request $request ) { $customer = wc()->customer; $billing = $request['billing_address']; $shipping = $request['shipping_address']; // Billing address is a required field. foreach ( $billing as $key => $value ) { if ( is_callable( [ $customer, "set_billing_$key" ] ) ) { $customer->{"set_billing_$key"}( $value ); } } // If shipping address (optional field) was not provided, set it to the given billing address (required field). $shipping_address_values = $shipping ?? $billing; foreach ( $shipping_address_values as $key => $value ) { if ( is_callable( [ $customer, "set_shipping_$key" ] ) ) { $customer->{"set_shipping_$key"}( $value ); } elseif ( 'phone' === $key ) { $customer->update_meta_data( 'shipping_phone', $value ); } } /** * Fires when the Checkout Block/Store API updates a customer from the API request data. * * @since 8.2.0 * * @param \WC_Customer $customer Customer object. * @param \WP_REST_Request $request Full details about the request. */ do_action( 'woocommerce_store_api_checkout_update_customer_from_request', $customer, $request ); $customer->save(); $this->order->set_billing_address( $billing ); $this->order->set_shipping_address( $shipping ); $this->order->save(); $this->order->calculate_totals(); } /** * Gets the chosen payment method from the request. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WC_Payment_Gateway|null */ private function get_request_payment_method( \WP_REST_Request $request ) { $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); $request_payment_method = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) ); $requires_payment_method = $this->order->needs_payment(); if ( empty( $request_payment_method ) ) { if ( $requires_payment_method ) { throw new RouteException( 'woocommerce_rest_checkout_missing_payment_method', __( 'No payment method provided.', 'woocommerce' ), 400 ); } return null; } if ( ! isset( $available_gateways[ $request_payment_method ] ) ) { throw new RouteException( 'woocommerce_rest_checkout_payment_method_disabled', sprintf( // Translators: %s Payment method ID. __( 'The %s payment gateway is not available.', 'woocommerce' ), esc_html( $request_payment_method ) ), 400 ); } return $available_gateways[ $request_payment_method ]; } /** * Updates the order with user details (e.g. address). * * @throws RouteException API error object with error details. * @param \WP_REST_Request $request Request object. */ private function process_customer( \WP_REST_Request $request ) { $this->order_controller->sync_customer_data_with_order( $this->order ); } } Routes/V1/Batch.php 0000777 00000007300 15251730534 0010061 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Routes\RouteInterface; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use WP_REST_Request; use WP_REST_Response; /** * Batch Route class. */ class Batch extends AbstractRoute implements RouteInterface { /** * The route identifier. * * @var string */ const IDENTIFIER = 'batch'; /** * The schema item identifier. * * @var string */ const SCHEMA_TYPE = 'batch'; /** * 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 '/batch'; } /** * Get arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return array( 'callback' => [ $this, 'get_response' ], 'methods' => 'POST', 'permission_callback' => '__return_true', 'args' => array( 'validation' => array( 'type' => 'string', 'enum' => array( 'require-all-validate', 'normal' ), 'default' => 'normal', ), 'requests' => array( 'required' => true, 'type' => 'array', 'maxItems' => 25, 'items' => array( 'type' => 'object', 'properties' => array( 'method' => array( 'type' => 'string', /** * Filters the allowed methods for store API batch requests. * * @since 9.8.0 * * @param string[] $methods Allowed methods. */ 'enum' => apply_filters( '__experimental_woocommerce_store_api_batch_request_methods', array( 'POST', 'PUT', 'PATCH', 'DELETE' ) ), 'default' => 'POST', ), 'path' => array( 'type' => 'string', 'required' => true, ), 'body' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => true, ), 'headers' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => array( 'type' => array( 'string', 'array' ), 'items' => array( 'type' => 'string', ), ), ), ), ), ), ), ); } /** * Get the route response. * * @see WP_REST_Server::serve_batch_request_v1 * https://developer.wordpress.org/reference/classes/wp_rest_server/serve_batch_request_v1/ * * @throws RouteException On error. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function get_response( WP_REST_Request $request ) { try { foreach ( $request['requests'] as $args ) { if ( ! stristr( $args['path'], 'wc/store' ) ) { throw new RouteException( 'woocommerce_rest_invalid_path', __( 'Invalid path provided.', 'woocommerce' ), 400 ); } } $response = rest_get_server()->serve_batch_request_v1( $request ); } catch ( RouteException $error ) { $response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() ); } catch ( \Exception $error ) { $response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 ); } if ( is_wp_error( $response ) ) { $response = $this->error_to_response( $response ); } $nonce = wp_create_nonce( 'wc_store_api' ); $response->header( 'Nonce', $nonce ); $response->header( 'Nonce-Timestamp', time() ); $response->header( 'User-ID', get_current_user_id() ); return $response; } } Routes/V1/CartRemoveItem.php 0000777 00000004374 15251730534 0011736 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartRemoveItem class. */ class CartRemoveItem extends AbstractCartRoute { use DraftOrderTrait; /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-remove-item'; /** * 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 '/cart/remove-item'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'key' => [ 'description' => __( 'Unique identifier (key) for the cart item.', 'woocommerce' ), 'type' => 'string', ], ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Handle the request and return a valid response for this endpoint. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { $cart = $this->cart_controller->get_cart_instance(); $cart_item = $this->cart_controller->get_cart_item( $request['key'] ); if ( empty( $cart_item ) ) { throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item no longer exists or is invalid.', 'woocommerce' ), 409 ); } $cart->remove_cart_item( $request['key'] ); $this->maybe_release_stock(); return rest_ensure_response( $this->schema->get_item_response( $cart ) ); } /** * If there is a draft order, releases stock. * * @return void */ protected function maybe_release_stock() { $draft_order_id = $this->get_draft_order_id(); if ( ! $draft_order_id ) { return; } wc_release_stock_for_order( $draft_order_id ); } } Routes/V1/ProductsById.php 0000777 00000003630 15251730534 0011415 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * ProductsById class. */ class ProductsById extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'products-by-id'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product'; /** * 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 '/products/(?P<id>[\d]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', ), ), [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => array( 'context' => $this->get_context_param( array( 'default' => 'view', ) ), ), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a single item. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $object = wc_get_product( (int) $request['id'] ); if ( ! $object || 0 === $object->get_id() ) { throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 ); } return rest_ensure_response( $this->schema->get_item_response( $object ) ); } } Routes/V1/CartUpdateItem.php 0000777 00000003554 15251730534 0011722 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; /** * CartUpdateItem class. */ class CartUpdateItem extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-update-item'; /** * 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 '/cart/update-item'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'key' => [ 'description' => __( 'Unique identifier (key) for the cart item to update.', 'woocommerce' ), 'type' => 'string', ], 'quantity' => [ 'description' => __( 'New quantity of the item in the cart.', 'woocommerce' ), 'type' => 'number', 'arg_options' => [ 'sanitize_callback' => 'wc_stock_amount', ], ], ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Handle the request and return a valid response for this endpoint. * . * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { $cart = $this->cart_controller->get_cart_instance(); if ( isset( $request['quantity'] ) ) { $this->cart_controller->set_cart_item_quantity( $request['key'], $request['quantity'] ); } return rest_ensure_response( $this->schema->get_item_response( $cart ) ); } } Routes/V1/Products.php 0000777 00000037020 15251730534 0010645 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\Enums\ProductType; use Automattic\WooCommerce\Enums\CatalogVisibility; use Automattic\WooCommerce\StoreApi\Utilities\Pagination; use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery; /** * Products class. */ class Products extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'products'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product'; /** * 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 '/products'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->get_collection_params(), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a collection of posts and add the post title filter option to \WP_Query. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $response = new \WP_REST_Response(); $product_query = new ProductQuery(); // Only get objects during GET requests. if ( \WP_REST_Server::READABLE === $request->get_method() ) { $query_results = $product_query->get_objects( $request ); $response_objects = []; foreach ( $query_results['objects'] as $object ) { $data = rest_ensure_response( $this->schema->get_item_response( $object ) ); $response_objects[] = $this->prepare_response_for_collection( $data ); } $response->set_data( $response_objects ); } else { $query_results = $product_query->get_results( $request ); } $response = ( new Pagination() )->add_headers( $response, $request, $query_results['total'], $query_results['pages'] ); $last_modified = $product_query->get_last_modified(); if ( $last_modified ) { $response->header( 'Last-Modified', $last_modified ); } return $response; } /** * Prepare links for the request. * * @param \WC_Product $item Product object. * @param \WP_REST_Request $request Request object. * @return array */ protected function prepare_links( $item, $request ) { $links = array( 'self' => array( 'href' => rest_url( $this->get_namespace() . $this->get_path() . '/' . $item->get_id() ), ), 'collection' => array( 'href' => rest_url( $this->get_namespace() . $this->get_path() ), ), ); if ( $item->get_parent_id() ) { $links['up'] = array( 'href' => rest_url( $this->get_namespace() . $this->get_path() . '/' . $item->get_parent_id() ), ); } return $links; } /** * Get the query params for collections of products. * * @return array */ public function get_collection_params() { $params = []; $params['context'] = $this->get_context_param(); $params['context']['default'] = 'view'; $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['per_page'] = array( 'description' => __( 'Maximum number of items to be returned in result set. Defaults to no limit if left blank.', 'woocommerce' ), 'type' => 'integer', 'default' => 10, 'minimum' => 0, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['search'] = array( 'description' => __( 'Limit results to those matching a string.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ); $params['slug'] = array( 'description' => __( 'Limit result set to products with specific slug(s). Use commas to separate.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ); $params['after'] = array( 'description' => __( 'Limit response to resources created after a given ISO8601 compliant date.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['before'] = array( 'description' => __( 'Limit response to resources created before a given ISO8601 compliant date.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['date_column'] = array( 'description' => __( 'When limiting response using after/before, which date column to compare against.', 'woocommerce' ), 'type' => 'string', 'default' => 'date', 'enum' => array( 'date', 'date_gmt', 'modified', 'modified_gmt', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['exclude'] = array( 'description' => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => [], 'sanitize_callback' => 'wp_parse_id_list', ); $params['include'] = array( 'description' => __( 'Limit result set to specific ids.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => [], 'sanitize_callback' => 'wp_parse_id_list', ); $params['offset'] = array( 'description' => __( 'Offset the result set by a specific number of items.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), 'type' => 'string', 'default' => 'desc', 'enum' => array( 'asc', 'desc' ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby'] = array( 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), 'type' => 'string', 'default' => 'date', 'enum' => array( 'date', 'modified', 'id', 'include', 'title', 'slug', 'price', 'popularity', 'rating', 'menu_order', 'comment_count', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['parent'] = array( 'description' => __( 'Limit result set to those of particular parent IDs.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => [], 'sanitize_callback' => 'wp_parse_id_list', ); $params['parent_exclude'] = array( 'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'sanitize_callback' => 'wp_parse_id_list', 'default' => [], ); $params['type'] = array( 'description' => __( 'Limit result set to products assigned a specific type.', 'woocommerce' ), 'type' => 'string', 'enum' => array_merge( array_keys( wc_get_product_types() ), [ ProductType::VARIATION ] ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); $params['sku'] = array( 'description' => __( 'Limit result set to products with specific SKU(s). Use commas to separate.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ); $params['featured'] = array( 'description' => __( 'Limit result set to featured products.', 'woocommerce' ), 'type' => 'boolean', 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); $params['category'] = array( 'description' => __( 'Limit result set to products assigned a set of category IDs or slugs, separated by commas.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'wp_parse_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['category_operator'] = array( 'description' => __( 'Operator to compare product category terms.', 'woocommerce' ), 'type' => 'string', 'enum' => [ 'in', 'not_in', 'and' ], 'default' => 'in', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); $params['brand'] = array( 'description' => __( 'Limit result set to products assigned a set of brand IDs or slugs, separated by commas.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'wp_parse_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['brand_operator'] = array( 'description' => __( 'Operator to compare product brand terms.', 'woocommerce' ), 'type' => 'string', 'enum' => [ 'in', 'not_in', 'and' ], 'default' => 'in', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); // If the $_REQUEST contains a taxonomy query, add it to the params and sanitize it. foreach ( $_REQUEST as $param => $value ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! is_string( $param ) ) { continue; } if ( str_starts_with( $param, '_unstable_tax_' ) && ! str_ends_with( $param, '_operator' ) ) { $params[ $param ] = array( 'description' => __( 'Limit result set to products assigned a set of taxonomies IDs or slugs, separated by commas.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'wp_parse_list', 'validate_callback' => 'rest_validate_request_arg', ); } if ( str_starts_with( $param, '_unstable_tax_' ) && str_ends_with( $param, '_operator' ) ) { $params[ $param ] = array( 'description' => __( 'Operator to compare product taxonomies terms.', 'woocommerce' ), 'type' => 'string', 'enum' => [ 'in', 'not_in', 'and' ], 'default' => 'in', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); } } $params['tag'] = array( 'description' => __( 'Limit result set to products assigned a set of tag IDs or slugs, separated by commas.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'wp_parse_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['tag_operator'] = array( 'description' => __( 'Operator to compare product tags.', 'woocommerce' ), 'type' => 'string', 'enum' => [ 'in', 'not_in', 'and' ], 'default' => 'in', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); $params['on_sale'] = array( 'description' => __( 'Limit result set to products on sale.', 'woocommerce' ), 'type' => 'boolean', 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); $params['min_price'] = array( 'description' => __( 'Limit result set to products based on a minimum price, provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ); $params['max_price'] = array( 'description' => __( 'Limit result set to products based on a maximum price, provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ); $params['stock_status'] = array( 'description' => __( 'Limit result set to products with specified stock status.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'string', 'enum' => array_keys( wc_get_product_stock_status_options() ), 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), 'default' => [], ); $params['attributes'] = array( 'description' => __( 'Limit result set to products with selected global attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'attribute' => array( 'description' => __( 'Attribute taxonomy name.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'wc_sanitize_taxonomy_name', ), 'term_id' => array( 'description' => __( 'List of attribute term IDs.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'integer', ], 'sanitize_callback' => 'wp_parse_id_list', ), 'slug' => array( 'description' => __( 'List of attribute slug(s). If a term ID is provided, this will be ignored.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'string', ], 'sanitize_callback' => 'wp_parse_slug_list', ), 'operator' => array( 'description' => __( 'Operator to compare product attribute terms.', 'woocommerce' ), 'type' => 'string', 'enum' => [ 'in', 'not_in', 'and' ], ), ), ), 'default' => [], ); $params['attribute_relation'] = array( 'description' => __( 'The logical relationship between attributes when filtering across multiple at once.', 'woocommerce' ), 'type' => 'string', 'enum' => [ 'in', 'and' ], 'default' => 'and', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); $params['catalog_visibility'] = array( 'description' => __( 'Determines if hidden or visible catalog products are shown.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'any', CatalogVisibility::VISIBLE, CatalogVisibility::CATALOG, CatalogVisibility::SEARCH, CatalogVisibility::HIDDEN ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); $params['rating'] = array( 'description' => __( 'Limit result set to products with a certain average rating.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', 'enum' => range( 1, 5 ), ), 'default' => [], 'sanitize_callback' => 'wp_parse_id_list', ); return $params; } } Routes/V1/Agentic/Error.php 0000777 00000007273 15251730534 0011514 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\ErrorType; use WP_REST_Response; /** * Error class. * * Represents an error object as defined in the Agentic Commerce Protocol. * This class handles API-level errors with type, code, message, and optional param. */ class Error { /** * The error type. * * @var string */ private $type; /** * Implementation-defined error code. * * @var string */ private $code; /** * Human-readable error message. * * @var string */ private $message; /** * RFC 9535 JSONPath to the problematic parameter (optional). * * @var string|null */ private $param; /** * Constructor. * * @param string $type Error type from ErrorType enum. * @param string $code Implementation-defined error code. * @param string $message Human-readable error message. * @param string|null $param RFC 9535 JSONPath (optional). */ private function __construct( $type, $code, $message, $param = null ) { $this->type = $type; $this->code = $code; $this->message = $message; $this->param = $param; } /** * Create an invalid request error. * * @param string $code Implementation-defined error code. * @param string $message Human-readable error message. * @param string|null $param RFC 9535 JSONPath (optional). * @return Error */ public static function invalid_request( $code, $message, $param = null ) { return new self( ErrorType::INVALID_REQUEST, $code, $message, $param ); } /** * Create a request not idempotent error. * * @param string $code Implementation-defined error code. * @param string $message Human-readable error message. * @param string|null $param RFC 9535 JSONPath (optional). * @return Error */ public static function request_not_idempotent( $code, $message, $param = null ) { return new self( ErrorType::REQUEST_NOT_IDEMPOTENT, $code, $message, $param ); } /** * Create a processing error. * * @param string $code Implementation-defined error code. * @param string $message Human-readable error message. * @param string|null $param RFC 9535 JSONPath (optional). * @return Error */ public static function processing_error( $code, $message, $param = null ) { return new self( ErrorType::PROCESSING_ERROR, $code, $message, $param ); } /** * Create a service unavailable error. * * @param string $code Implementation-defined error code. * @param string $message Human-readable error message. * @param string|null $param RFC 9535 JSONPath (optional). * @return Error */ public static function service_unavailable( $code, $message, $param = null ) { return new self( ErrorType::SERVICE_UNAVAILABLE, $code, $message, $param ); } /** * Convert the error to a WP_REST_Response. * * @return WP_REST_Response WordPress REST API response object */ public function to_rest_response() { $data = array( 'type' => $this->type, 'code' => $this->code, 'message' => $this->message, ); if ( null !== $this->param ) { $data['param'] = $this->param; } $status_code = $this->get_http_status_code(); return new WP_REST_Response( $data, $status_code ); } /** * Determine HTTP status code based on error type. * * @return int HTTP status code */ private function get_http_status_code() { switch ( $this->type ) { case ErrorType::INVALID_REQUEST: return 400; case ErrorType::REQUEST_NOT_IDEMPOTENT: return 409; case ErrorType::PROCESSING_ERROR: return 500; case ErrorType::SERVICE_UNAVAILABLE: return 503; default: return 500; } } } Routes/V1/Agentic/AgenticCheckoutSession.php 0000777 00000004245 15251730534 0015023 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages\Messages; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\SessionKey; use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils; use WC_Cart; /** * AgenticCheckoutSession class. * * Wrapper for all things, associated with an agentic checkout session. * This class manages the cart and error handling for agentic checkout processes. */ final class AgenticCheckoutSession { /** * The WooCommerce cart instance. * * @var WC_Cart */ private $cart; /** * Error messages handler for the checkout session. * * @var Messages */ private $messages; /** * The checkout session ID. * * @var string */ private $id; /** * Constructor. * * @param WC_Cart $cart The WooCommerce cart instance. */ public function __construct( WC_Cart $cart ) { $this->cart = $cart; $this->messages = new Messages(); $this->id = $this->get_or_set_checkout_session_id(); } /** * Gets the cart instance. * * @return WC_Cart The WooCommerce cart instance. */ public function get_cart(): WC_Cart { return $this->cart; } /** * Gets the messages collection. * * @return Messages The messages handler instance. */ public function get_messages(): Messages { return $this->messages; } /** * Gets the checkout session ID. * * @return string The checkout session ID. */ public function get_id(): string { return $this->id; } /** * Get the checkout session ID. If it does not exist, generate a cart token for it and save to the current session. * * @return string Checkout Session ID stored in the current session. */ private function get_or_set_checkout_session_id(): string { $wc_session = WC()->session; if ( null === $wc_session ) { return ''; } $session_id = $wc_session->get( SessionKey::AGENTIC_CHECKOUT_SESSION_ID ); if ( null === $session_id ) { $session_id = CartTokenUtils::get_cart_token( (string) $wc_session->get_customer_id() ); $wc_session->set( SessionKey::AGENTIC_CHECKOUT_SESSION_ID, $session_id ); } return $session_id; } } Routes/V1/Agentic/Enums/SessionKey.php 0000777 00000001460 15251730534 0013576 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums; /** * Session keys used in Agentic Checkout. */ class SessionKey { /** * Chosen shipping methods. This is not specific to Agentic Checkout. */ const CHOSEN_SHIPPING_METHODS = 'chosen_shipping_methods'; /** * Agentic session ID stored in WC session. */ const AGENTIC_CHECKOUT_SESSION_ID = 'agentic_checkout_session_id'; /** * Completed order ID. */ const AGENTIC_CHECKOUT_COMPLETED_ORDER_ID = 'agentic_checkout_completed_order_id'; /** * Whether payment is in progress. */ const AGENTIC_CHECKOUT_PAYMENT_IN_PROGRESS = 'agentic_checkout_payment_in_progress'; /** * Provider ID that authenticated the request. */ const AGENTIC_CHECKOUT_PROVIDER_ID = 'agentic_checkout_provider_id'; } Routes/V1/Agentic/Enums/OrderMetaKey.php 0000777 00000000644 15251730534 0014040 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums; /** * Order meta keys used in Agentic Checkout. */ class OrderMetaKey { /** * Agentic checkout session ID for this order. */ const AGENTIC_CHECKOUT_SESSION_ID = '_agentic_checkout_session_id'; /** * Meta key for canceled checkout order. */ const AGENTIC_CHECKOUT_CANCELED = '_agentic_checkout_canceled'; } Routes/V1/Agentic/Messages/Messages.php 0000777 00000002055 15251730534 0013732 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages; /** * Class Messages * * Manages error & info messages for the agentic checkout process. */ class Messages { /** * Array of messages. * * @var Message[] */ private $messages = array(); /** * Add a message. * * @param Message $message The message to add. * @return void */ public function add( Message $message ): void { $this->messages[] = $message; } /** * Check if there are any error messages. * * @return bool True if there are error messages, false otherwise. */ public function has_errors(): bool { foreach ( $this->messages as $message ) { if ( $message->is_error() ) { return true; } } return false; } /** * Get all error messages, formatted as per the ACP spec. * * @return array that is ready for the response. */ public function get_formatted_messages(): array { return array_map( function ( Message $message ) { return $message->to_array(); }, $this->messages ); } } Routes/V1/Agentic/Messages/MessageInfo.php 0000777 00000002371 15251730534 0014364 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\MessageType; /** * MessageInfo class. * * Represents an info message object as defined in the Agentic Commerce Protocol. */ class MessageInfo extends Message { /** * The error type (always 'error' for message errors). * * @var string */ private $type = MessageType::INFO; /** * Constructor. * * @param string $content Error content/message. * @param string|null $param RFC 9535 JSONPath (optional). */ public function __construct( $content, $param = null ) { $this->content = $content; $this->param = $param; } /** * Check if the message is an error. * * @return bool True if the message is an error, false otherwise. */ public function is_error(): bool { return false; } /** * Convert the error to an array. * * @return array A message for the `messages` array of the response. */ public function to_array(): array { $data = array( 'type' => $this->type, 'content_type' => $this->content_type, 'content' => $this->content, ); if ( null !== $this->param ) { $data['param'] = $this->param; } return $data; } } Routes/V1/Agentic/Messages/MessageError.php 0000777 00000007053 15251730534 0014564 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\ErrorCode; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\MessageType; /** * MessageError class. * * Represents a message error object as defined in the Agentic Commerce Protocol. * This class handles message-level errors with type, code, content_type, content, and optional param. */ class MessageError extends Message { /** * The error type (always 'error' for message errors). * * @var string */ private $type = MessageType::ERROR; /** * Error code from ErrorCode enum. * * @var string */ private $code; /** * Constructor. * * @param string $code Error code from ErrorCode enum. * @param string $content Error content/message. * @param string|null $param RFC 9535 JSONPath (optional). */ public function __construct( string $code, string $content, ?string $param = null ) { $this->code = $code; $this->content = $content; $this->param = $param; } /** * Create a missing field error. * * @param string $content Error content/message. * @param string|null $param RFC 9535 JSONPath (optional). * @return MessageError */ public static function missing( $content, $param = null ) { return new self( ErrorCode::MISSING, $content, $param ); } /** * Create an invalid field error. * * @param string $content Error content/message. * @param string|null $param RFC 9535 JSONPath (optional). * @return MessageError */ public static function invalid( $content, $param = null ) { return new self( ErrorCode::INVALID, $content, $param ); } /** * Create an out of stock error. * * @param string $content Error content/message. * @param string|null $param RFC 9535 JSONPath (optional). * @return MessageError */ public static function out_of_stock( $content, $param = null ) { return new self( ErrorCode::OUT_OF_STOCK, $content, $param ); } /** * Create a payment declined error. * * @param string $content Error content/message. * @param string|null $param RFC 9535 JSONPath (optional). * @return MessageError */ public static function payment_declined( $content, $param = null ) { return new self( ErrorCode::PAYMENT_DECLINED, $content, $param ); } /** * Create a requires sign in error. * * @param string $content Error content/message. * @param string|null $param RFC 9535 JSONPath (optional). * @return MessageError */ public static function requires_sign_in( $content, $param = null ) { return new self( ErrorCode::REQUIRES_SIGN_IN, $content, $param ); } /** * Create a requires 3DS error. * * @param string $content Error content/message. * @param string|null $param RFC 9535 JSONPath (optional). * @return MessageError */ public static function requires_3ds( $content, $param = null ) { return new self( ErrorCode::REQUIRES_3DS, $content, $param ); } /** * Check if the message is an error. * * @return bool True if the message is an error, false otherwise. */ public function is_error(): bool { return true; } /** * Convert the error to an array. * * @return array A message for the `messages` array of the response. */ public function to_array(): array { $data = array( 'type' => $this->type, 'code' => $this->code, 'content_type' => $this->content_type, 'content' => $this->content, ); if ( null !== $this->param ) { $data['param'] = $this->param; } return $data; } } Routes/V1/Agentic/Messages/Message.php 0000777 00000002147 15251730534 0013551 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\MessageContentType; /** * Base class for error and info messages. */ abstract class Message { /** * Content type for the error message. * * Defaults to plain, but could also be markdown. * * @var string */ protected $content_type = MessageContentType::PLAIN; /** * Error content/message. * * @var string */ protected $content; /** * RFC 9535 JSONPath to the problematic parameter (optional). * * @var string|null */ protected $param; /** * Check if the message is an error. * * @return bool True if the message is an error, false otherwise. */ abstract public function is_error(): bool; /** * Convert the message to an array. * * @return array A message for the `messages` array of the response. */ abstract public function to_array(): array; /** * Use markdown content type for the content of the error. */ public function use_markdown() { $this->content_type = MessageContentType::MARKDOWN; } } Routes/V1/Agentic/CheckoutSessionsComplete.php 0000777 00000031445 15251730534 0015406 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic; use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\OrderMetaKey; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\SessionKey; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\ErrorCode; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\CheckoutSessionStatus; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Error; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\Agentic\CheckoutSessionSchema; use Automattic\WooCommerce\StoreApi\Utilities\CartController; use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils; use Automattic\WooCommerce\StoreApi\Utilities\OrderController; use Automattic\WooCommerce\StoreApi\Utilities\AgenticCheckoutUtils; use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CheckoutSessionsComplete class. * * Handles the Agentic Checkout API checkout sessions complete endpoint. * This endpoint allows AI agents to complete checkout sessions with payment. */ class CheckoutSessionsComplete extends AbstractCartRoute { use CheckoutTrait; /** * The route identifier. * * @var string */ const IDENTIFIER = 'agentic-checkout-sessions-complete'; /** * The route's schema type. * * @var string */ const SCHEMA_TYPE = CheckoutSessionSchema::IDENTIFIER; /** * Order controller for managing orders. * * @var OrderController */ protected $order_controller; /** * Cart controller for managing cart operations. * * @var CartController */ protected $cart_controller; /** * The order object for the current request. * * @var \WC_Order|null */ protected $order; /** * Constructor. * * @param SchemaController $schema_controller Schema Controller instance. * @param AbstractSchema $schema Schema class instance. */ public function __construct( $schema_controller, $schema ) { parent::__construct( $schema_controller, $schema ); $this->order_controller = new OrderController(); $this->cart_controller = new CartController(); } /** * Get the path of this REST route. * * @return string */ public function get_path() { return self::get_path_regex(); } /** * Get the path regex for this REST route. * * @return string */ public static function get_path_regex() { return '/checkout_sessions/(?P<checkout_session_id>[a-zA-Z0-9._-]+)/complete'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => [ 'checkout_session_id' => [ 'description' => __( 'The checkout session ID (Cart-Token JWT).', 'woocommerce' ), 'type' => 'string', ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => [ $this, 'is_authorized' ], 'args' => $this->get_complete_params(), ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get the parameters for completing a checkout session. * * @return array Parameters array. */ protected function get_complete_params() { $shared_params = AgenticCheckoutUtils::get_shared_params(); return [ 'buyer' => $shared_params['buyer'], 'payment_data' => [ 'description' => __( 'Payment data including token and provider.', 'woocommerce' ), 'type' => 'object', 'properties' => [ 'token' => [ 'description' => __( 'Payment token from the payment provider.', 'woocommerce' ), 'type' => 'string', ], 'provider' => [ 'description' => __( 'Payment provider identifier.', 'woocommerce' ), 'type' => 'string', 'enum' => [ 'stripe' ], ], 'billing_address' => $shared_params['fulfillment_address'], ], 'required' => [ 'token', 'provider' ], ], ]; } /** * Check if the request is authorized. * * Checks feature enablement and cart token validity. * * @param \WP_REST_Request $request Request object. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public function is_authorized( \WP_REST_Request $request ) { // Check if feature is enabled using helper. $auth_check = AgenticCheckoutUtils::is_authorized( $request ); if ( is_wp_error( $auth_check ) ) { return $auth_check; } // Additional check for cart token validity. if ( ! $this->has_cart_token( $request ) ) { return new \WP_Error( 'woocommerce_rest_invalid_checkout_session', __( 'Invalid or expired checkout session ID.', 'woocommerce' ), array( 'status' => 404 ) ); } return true; } /** * Use the checkout_session_id as Cart-Token, and set the respective values to HTTP header and request. * * @param \WP_REST_Request $request Request object. * @return bool|null */ protected function has_cart_token( \WP_REST_Request $request ) { $session_id = $request->get_param( 'checkout_session_id' ); if ( is_null( $this->has_cart_token ) ) { $this->has_cart_token = CartTokenUtils::validate_cart_token( $session_id ); } // This allows the session will be loaded later without any further intervention. if ( true === $this->has_cart_token ) { $request->set_header( 'Cart-Token', $session_id ); $_SERVER['HTTP_CART_TOKEN'] = $session_id; } return $this->has_cart_token; } /** * Check if a nonce is required for the route. * * @param \WP_REST_Request $request Request object. * @return bool False, Bearer token auth used instead. */ protected function requires_nonce( \WP_REST_Request $request ) { // Should use `is_authorized` to validate Bearer token authentication. return false; } /** * Handle the request and return a valid response for this endpoint. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error */ protected function get_route_post_response( \WP_REST_Request $request ) { $checkout_session = new AgenticCheckoutSession( $this->cart_controller->get_cart_instance() ); AgenticCheckoutUtils::validate( $checkout_session ); /** * Verify checkout session is ready for payment. */ $current_status = AgenticCheckoutUtils::calculate_status( $checkout_session ); if ( CheckoutSessionStatus::READY_FOR_PAYMENT !== $current_status ) { $message = sprintf( /* translators: %s: current session status */ __( 'Checkout session is not ready for payment. Current status: %s', 'woocommerce' ), $current_status ); return Error::invalid_request( ErrorCode::INVALID, $message )->to_rest_response(); } /** * Set buyer data if exists. */ $buyer = $request->get_param( 'buyer' ); if ( null !== $buyer ) { AgenticCheckoutUtils::set_buyer_data( $buyer, WC()->customer ); } /** * Set billing address from payment_data if provided. */ $payment_data = $request->get_param( 'payment_data' ); if ( isset( $payment_data['billing_address'] ) ) { AgenticCheckoutUtils::set_billing_address( $payment_data['billing_address'], WC()->customer ); } try { /** * Before triggering validation, ensure totals are current and in turn, things such as shipping costs are present. * This is so plugins that validate other cart data (e.g. conditional shipping and payments) can access this data. */ $this->cart_controller->calculate_totals(); /** * Validate that the cart is not empty. */ $this->cart_controller->validate_cart_not_empty(); /** * Validate items and fix violations before the order is processed. */ $this->cart_controller->validate_cart(); } catch ( \Exception $e ) { $message = wp_specialchars_decode( $e->getMessage(), ENT_QUOTES ); return Error::processing_error( ErrorCode::INVALID, $message )->to_rest_response(); } /** * Similar to Checkout::create_or_update_draft_order. * Can move this to CheckoutTrait to share between Checkout.php and this controller. */ $this->order = $this->get_draft_order(); if ( ! $this->order ) { $this->order = $this->order_controller->create_order_from_cart(); } else { $this->order_controller->update_order_from_cart( $this->order, true ); } /** * Stores the checkout session ID to the order meta. */ $this->order->update_meta_data( OrderMetaKey::AGENTIC_CHECKOUT_SESSION_ID, $request->get_param( 'checkout_session_id' ) ); $this->order->save_meta_data(); /** * Validate updated order before payment is attempted. */ try { $this->order_controller->validate_order_before_payment( $this->order ); } catch ( \Exception $e ) { $message = wp_specialchars_decode( $e->getMessage(), ENT_QUOTES ); return Error::invalid_request( ErrorCode::INVALID, $message )->to_rest_response(); } try { wc_reserve_stock_for_order( $this->order ); } catch ( \Exception $e ) { $message = wp_specialchars_decode( $e->getMessage(), ENT_QUOTES ); return Error::invalid_request( ErrorCode::INVALID, $message )->to_rest_response(); } // Set the order status to 'pending' as an initial step. $this->order->update_status( 'pending' ); /** * Process payment (reuse CheckoutTrait). */ $payment_result = new PaymentResult(); try { /** * Set IN_PROGRESS status to prevent concurrent payment attempts. * Save this status right away so that any concurrent request will not be able to access the payment process. */ WC()->session->set( SessionKey::AGENTIC_CHECKOUT_PAYMENT_IN_PROGRESS, true ); WC()->session->save_data(); $this->process_payment( $request, $payment_result ); } catch ( \Exception $e ) { $message = wp_specialchars_decode( $e->getMessage(), ENT_QUOTES ); return Error::processing_error( ErrorCode::INVALID, $message )->to_rest_response(); } finally { /** * Clear IN_PROGRESS status after payment attempt. * Do not save session here as it will be done after the shutdown. */ WC()->session->set( SessionKey::AGENTIC_CHECKOUT_PAYMENT_IN_PROGRESS, false ); } /** * If payment failed, return error. */ if ( 'failure' === $payment_result->status || 'error' === $payment_result->status ) { // Clear IN_PROGRESS status to allow retry. $message = $payment_result->message ?? __( 'Payment was declined.', 'woocommerce' ); $message = wp_specialchars_decode( $message, ENT_QUOTES ); return Error::processing_error( ErrorCode::PAYMENT_DECLINED, $message )->to_rest_response(); } /** * Store the completed order ID into the session. This will prevent new orders in this session. */ WC()->session->set( SessionKey::AGENTIC_CHECKOUT_COMPLETED_ORDER_ID, $this->order->get_id() ); /** * Build response from canonical cart schema. */ $response_data = $this->schema->get_item_response( $checkout_session ); $response = rest_ensure_response( $response_data ); return AgenticCheckoutUtils::add_protocol_headers( $response, $request ); } /** * Gets and formats payment request data for CheckoutTrait. * * Transforms agentic payment_data format to Store API format. * * @param \WP_REST_Request $request Request object. * @return array */ private function get_request_payment_data( \WP_REST_Request $request ) { $payment_data = []; $agentic_data = $request->get_param( 'payment_data' ); if ( ! $agentic_data ) { return $payment_data; } // Transform agentic format to Store API payment_data format. if ( isset( $agentic_data['token'] ) ) { $payment_data['wc-agentic_commerce-token'] = wc_clean( $agentic_data['token'] ); } if ( isset( $agentic_data['provider'] ) ) { $payment_data['wc-agentic_commerce-provider'] = wc_clean( $agentic_data['provider'] ); } return $payment_data; } /** * Gets the chosen payment method (gateway) ID for CheckoutTrait. * * @param \WP_REST_Request $request Request object. * @return string * @throws RouteException If no payment gateway is available. */ private function get_request_payment_method_id( \WP_REST_Request $request ) { $available_gateways = WC()->payment_gateways()->get_available_payment_gateways(); if ( empty( $available_gateways ) ) { throw new RouteException( 'woocommerce_checkout_session_no_payment_gateway_available', esc_html__( 'No payment gateway available.', 'woocommerce' ), 400 ); } // Look for gateway with agentic_commerce capability. $gateway = AgenticCheckoutUtils::get_agentic_commerce_gateway( $available_gateways ); if ( null === $gateway ) { throw new RouteException( 'woocommerce_checkout_session_no_agentic_payment_gateway_available', esc_html__( 'No agentic-supported payment gateway available.', 'woocommerce' ), 400 ); } return $gateway->id; } } Routes/V1/Agentic/CheckoutSessions.php 0000777 00000012073 15251730534 0013711 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic; use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Error; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\Agentic\CheckoutSessionSchema; use Automattic\WooCommerce\StoreApi\Utilities\CartController; use Automattic\WooCommerce\StoreApi\Utilities\OrderController; use Automattic\WooCommerce\StoreApi\Utilities\AgenticCheckoutUtils; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\AgenticCheckoutSession; /** * CheckoutSessions class. * * Handles the Agentic Checkout API checkout sessions endpoint. * This endpoint allows AI agents to create and manage checkout sessions. */ class CheckoutSessions extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'agentic-checkout-sessions'; /** * The route's schema type. * * @var string */ const SCHEMA_TYPE = CheckoutSessionSchema::IDENTIFIER; /** * Cart controller for managing cart operations. * * @var CartController */ protected $cart_controller; /** * Constructor. * * @param SchemaController $schema_controller Schema Controller instance. * @param AbstractSchema $schema Schema class instance. */ public function __construct( $schema_controller, $schema ) { parent::__construct( $schema_controller, $schema ); $this->order_controller = new OrderController(); $this->cart_controller = new CartController(); } /** * Get the path of this REST route. * * @return string */ public function get_path() { return $this->get_path_regex(); } /** * Get the path regex for this REST route. * * @return string */ public static function get_path_regex() { return '/checkout_sessions'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => [ $this, 'is_authorized' ], 'args' => $this->get_create_params(), ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get the parameters for creating a checkout session. * * @return array Parameters array. */ protected function get_create_params() { $params = AgenticCheckoutUtils::get_shared_params(); $params['items'] = array_merge( $params['items'], [ 'required' => true, 'minItems' => 1, ] ); return $params; } /** * Check if the request is authorized. * * Delegates to the AgenticCheckoutUtils helper. * * @param \WP_REST_Request $request Request object. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public function is_authorized( \WP_REST_Request $request ) { return AgenticCheckoutUtils::is_authorized( $request ); } /** * Check if a nonce is required for the route. * * @param \WP_REST_Request $request Request object. * @return bool False, Bearer token auth used instead. */ protected function requires_nonce( \WP_REST_Request $request ) { // Should use `is_authorized` to validate Bearer token authentication. return false; } /** * Handle the request and return a valid response for this endpoint. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { $checkout_session = new AgenticCheckoutSession( $this->cart_controller->get_cart_instance() ); // Clear existing cart to start fresh for POST requests. $this->cart_controller->empty_cart(); // Add items to cart. $items = $request->get_param( 'items' ); $error = AgenticCheckoutUtils::add_items_to_cart( $items, $this->cart_controller, $checkout_session->get_messages() ); // Halt for critical errors. if ( $error instanceof Error ) { return $error->to_rest_response(); } // Set buyer information. $buyer = $request->get_param( 'buyer' ); if ( $buyer ) { AgenticCheckoutUtils::set_buyer_data( $buyer, WC()->customer ); } // Set fulfillment address. $address = $request->get_param( 'fulfillment_address' ); if ( $address ) { AgenticCheckoutUtils::set_fulfillment_address( $address, WC()->customer ); } else { // Clear address when not provided (POST creates fresh session). AgenticCheckoutUtils::clear_fulfillment_address( WC()->customer ); } // Calculate totals. try { $this->cart_controller->calculate_totals(); } catch ( \Exception $e ) { $message = wp_specialchars_decode( $e->getMessage(), ENT_QUOTES ); return Error::processing_error( 'totals_calculation_error', $message )->to_rest_response(); } // Build response from canonical cart schema. $response = $this->schema->get_item_response( $checkout_session ); // Add protocol headers. return AgenticCheckoutUtils::add_protocol_headers( rest_ensure_response( $response ), $request ); } } Routes/V1/Agentic/CheckoutSessionsUpdate.php 0000777 00000017020 15251730534 0015051 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1\Agentic; use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\SessionKey; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\CheckoutSessionStatus; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\ErrorCode; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Error; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\Agentic\CheckoutSessionSchema; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\AgenticCheckoutSession; use Automattic\WooCommerce\StoreApi\Utilities\CartController; use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils; use Automattic\WooCommerce\StoreApi\Utilities\OrderController; use Automattic\WooCommerce\StoreApi\Utilities\AgenticCheckoutUtils; /** * CheckoutSessionsUpdate class. * * Handles the Agentic Checkout API checkout sessions update endpoint. * This endpoint allows AI agents to update existing checkout sessions. */ class CheckoutSessionsUpdate extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'agentic-checkout-sessions-update'; /** * The route's schema type. * * @var string */ const SCHEMA_TYPE = CheckoutSessionSchema::IDENTIFIER; /** * Cart controller for managing cart operations. * * @var CartController */ protected $cart_controller; /** * Constructor. * * @param SchemaController $schema_controller Schema Controller instance. * @param AbstractSchema $schema Schema class instance. */ public function __construct( $schema_controller, $schema ) { parent::__construct( $schema_controller, $schema ); $this->order_controller = new OrderController(); $this->cart_controller = new CartController(); } /** * Get the path of this REST route. * * @return string */ public function get_path() { return self::get_path_regex(); } /** * Get the path regex for this REST route. * * @return string */ public static function get_path_regex() { return '/checkout_sessions/(?P<checkout_session_id>[a-zA-Z0-9._-]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => [ 'checkout_session_id' => [ 'description' => __( 'The checkout session ID (Cart-Token JWT).', 'woocommerce' ), 'type' => 'string', ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => [ $this, 'is_authorized' ], 'args' => $this->get_update_params(), ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get the parameters for updating a checkout session. * * @return array Parameters array. */ protected function get_update_params() { $params = AgenticCheckoutUtils::get_shared_params(); $params['fulfillment_option_id'] = [ 'description' => __( 'Selected fulfillment option ID.', 'woocommerce' ), 'type' => 'string', ]; return $params; } /** * Check if the request is authorized. * * Checks feature enablement and cart token validity. * * @param \WP_REST_Request $request Request object. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public function is_authorized( \WP_REST_Request $request ) { // Check if feature is enabled using helper. $auth_check = AgenticCheckoutUtils::is_authorized( $request ); if ( is_wp_error( $auth_check ) ) { return $auth_check; } // Additional check for cart token validity. if ( ! $this->has_cart_token( $request ) ) { return new \WP_Error( 'woocommerce_rest_invalid_checkout_session', __( 'Invalid or expired checkout session ID.', 'woocommerce' ), array( 'status' => 404 ) ); } return true; } /** * Use the checkout_session_id as Cart-Token, and set the respective values to HTTP header and request. * * @param \WP_REST_Request $request Request object. * @return bool|null */ protected function has_cart_token( \WP_REST_Request $request ) { $session_id = $request->get_param( 'checkout_session_id' ); if ( is_null( $this->has_cart_token ) ) { $this->has_cart_token = CartTokenUtils::validate_cart_token( $session_id ); } // This allows the session will be loaded later without any further intervention. if ( true === $this->has_cart_token ) { $request->set_header( 'Cart-Token', $session_id ); $_SERVER['HTTP_CART_TOKEN'] = $session_id; } return $this->has_cart_token; } /** * Handle the request and return a valid response for this endpoint. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error */ protected function get_route_post_response( \WP_REST_Request $request ) { $cart = $this->cart_controller->get_cart_instance(); $checkout_session = new AgenticCheckoutSession( $cart ); $current_status = AgenticCheckoutUtils::calculate_status( $checkout_session ); if ( ! in_array( $current_status, CheckoutSessionStatus::ALLOWED_STATUSES_FOR_UPDATE, true ) ) { $allowed_statuses = implode( ', ', CheckoutSessionStatus::ALLOWED_STATUSES_FOR_UPDATE ); $message = sprintf( /* translators: 1: current session status, 2: allowed statuses */ __( 'Checkout session cannot be updated. Current status: %1$s. Allowed statuses: %2$s', 'woocommerce' ), $current_status, $allowed_statuses ); return Error::invalid_request( ErrorCode::INVALID, $message )->to_rest_response(); } // Update items if provided. $items = $request->get_param( 'items' ); if ( null !== $items ) { // Clear existing cart items and replace with new ones. $this->cart_controller->empty_cart(); $error = AgenticCheckoutUtils::add_items_to_cart( $items, $this->cart_controller, $checkout_session->get_messages() ); if ( $error instanceof Error ) { return $error->to_rest_response(); } } // Update buyer information if provided. $buyer = $request->get_param( 'buyer' ); if ( null !== $buyer ) { AgenticCheckoutUtils::set_buyer_data( $buyer, WC()->customer ); } // Update fulfillment address if provided. $address = $request->get_param( 'fulfillment_address' ); if ( null !== $address ) { AgenticCheckoutUtils::set_fulfillment_address( $address, WC()->customer ); } // Update selected shipping method if provided. $fulfillment_option_id = $request->get_param( 'fulfillment_option_id' ); if ( null !== $fulfillment_option_id ) { $option_id = wc_clean( (string) $fulfillment_option_id ); $packages = WC()->shipping()->get_packages(); foreach ( $packages as $package ) { foreach ( (array) ( $package['rates'] ?? array() ) as $rate ) { if ( $rate->get_id() === $option_id ) { WC()->session->set( SessionKey::CHOSEN_SHIPPING_METHODS, array( $option_id ) ); break 2; } } } } // Calculate totals after all updates. try { $this->cart_controller->calculate_totals(); } catch ( \Exception $e ) { $message = wp_specialchars_decode( $e->getMessage(), ENT_QUOTES ); return Error::processing_error( 'totals_calculation_error', $message )->to_rest_response(); } // Build response from canonical cart schema. $response = $this->schema->get_item_response( $checkout_session ); // Add protocol headers. return AgenticCheckoutUtils::add_protocol_headers( rest_ensure_response( $response ), $request ); } } Routes/V1/ProductAttributeTerms.php 0000777 00000004267 15251730534 0013370 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * ProductAttributeTerms class. */ class ProductAttributeTerms extends AbstractTermsRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-attribute-terms'; /** * 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 '/products/attributes/(?P<attribute_id>[\d]+)/terms'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => array( 'attribute_id' => array( 'description' => __( 'Unique identifier for the attribute.', 'woocommerce' ), 'type' => 'integer', ), ), [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->get_collection_params(), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get the query params for collections of attributes. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'][] = 'menu_order'; $params['orderby']['enum'][] = 'name_num'; $params['orderby']['enum'][] = 'id'; return $params; } /** * Get a collection of attribute terms. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $attribute = wc_get_attribute( $request['attribute_id'] ); if ( ! $attribute || ! taxonomy_exists( $attribute->slug ) ) { throw new RouteException( 'woocommerce_rest_taxonomy_invalid', __( 'Attribute does not exist.', 'woocommerce' ), 404 ); } return $this->get_terms_response( $attribute->slug, $request ); } } Routes/V1/AbstractTermsRoute.php 0000777 00000012166 15251730534 0012643 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Utilities\Pagination; use WP_Term_Query; /** * AbstractTermsRoute class. */ abstract class AbstractTermsRoute extends AbstractRoute { /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'term'; /** * Get the query params for collections of attributes. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param(); $params['context']['default'] = 'view'; $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['per_page'] = array( 'description' => __( 'Maximum number of items to be returned in result set. Defaults to no limit if left blank.', 'woocommerce' ), 'type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['search'] = array( 'description' => __( 'Limit results to those matching a string.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ); $params['exclude'] = array( 'description' => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['include'] = array( 'description' => __( 'Limit result set to specific ids.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['order'] = array( 'description' => __( 'Sort ascending or descending.', 'woocommerce' ), 'type' => 'string', 'default' => 'asc', 'enum' => array( 'asc', 'desc' ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby'] = array( 'description' => __( 'Sort by term property.', 'woocommerce' ), 'type' => 'string', 'default' => 'name', 'enum' => array( 'name', 'slug', 'count', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['hide_empty'] = array( 'description' => __( 'If true, empty terms will not be returned.', 'woocommerce' ), 'type' => 'boolean', 'default' => true, ); $params['parent'] = array( 'description' => __( 'Limit results to terms with a specific parent (hierarchical taxonomies only).', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get terms matching passed in args. * * @param string $taxonomy Taxonomy to get terms from. * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ protected function get_terms_response( $taxonomy, $request ) { $page = (int) $request['page']; $per_page = $request['per_page'] ? (int) $request['per_page'] : 0; $prepared_args = array( 'taxonomy' => $taxonomy, 'exclude' => $request['exclude'], 'include' => $request['include'], 'order' => $request['order'], 'orderby' => $request['orderby'], 'hide_empty' => (bool) $request['hide_empty'], 'number' => $per_page, 'offset' => $per_page > 0 ? ( $page - 1 ) * $per_page : 0, 'search' => $request['search'], ); if ( isset( $request['parent'] ) && is_taxonomy_hierarchical( $taxonomy ) ) { $prepared_args['parent'] = (int) $request['parent']; } $term_query = new WP_Term_Query(); $objects = $term_query->query( $prepared_args ); $return = []; foreach ( $objects as $object ) { $data = $this->prepare_item_for_response( $object, $request ); $return[] = $this->prepare_response_for_collection( $data ); } $response = rest_ensure_response( $return ); // See if pagination is needed before calculating. if ( $per_page > 0 && ( count( $objects ) === $per_page || $page > 1 ) ) { $term_count = $this->get_term_count( $taxonomy, $prepared_args ); $response = ( new Pagination() )->add_headers( $response, $request, $term_count, ceil( $term_count / $per_page ) ); } return $response; } /** * Get count of terms for current query. * * @param string $taxonomy Taxonomy to get terms from. * @param array $args Array of args to pass to wp_count_terms. * @return int */ protected function get_term_count( $taxonomy, $args ) { $count_args = $args; unset( $count_args['number'], $count_args['offset'] ); return (int) wp_count_terms( $taxonomy, $count_args ); } } Routes/V1/CartApplyCoupon.php 0000777 00000004103 15251730534 0012121 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartApplyCoupon class. */ class CartApplyCoupon extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-apply-coupon'; /** * 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 '/cart/apply-coupon'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'code' => [ 'description' => __( 'Unique identifier for the coupon within the cart.', 'woocommerce' ), 'type' => 'string', ], ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Handle the request and return a valid response for this endpoint. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { if ( ! wc_coupons_enabled() ) { throw new RouteException( 'woocommerce_rest_cart_coupon_disabled', esc_html__( 'Coupons are disabled.', 'woocommerce' ), 404 ); } $coupon_code = wc_format_coupon_code( wp_unslash( $request['code'] ) ); try { $this->cart_controller->apply_coupon( $coupon_code ); } catch ( \WC_REST_Exception $e ) { throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } return rest_ensure_response( $this->schema->get_item_response( $this->cart_controller->get_cart_for_response() ) ); } } Routes/V1/ProductBrands.php 0000777 00000002561 15251730534 0011616 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1; /** * ProductBrands class. */ class ProductBrands extends AbstractTermsRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-brands'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product-brand'; /** * 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 '/products/brands'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->get_collection_params(), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a collection of terms. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { return $this->get_terms_response( 'product_brand', $request ); } } Routes/V1/ProductAttributesById.php 0000777 00000004002 15251730534 0013273 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * ProductAttributesById class. */ class ProductAttributesById extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-attributes-by-id'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product-attribute'; /** * 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 '/products/attributes/(?P<id>[\d]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', ), ), [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => array( 'context' => $this->get_context_param( array( 'default' => 'view', ) ), ), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a single item. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $object = wc_get_attribute( (int) $request['id'] ); if ( ! $object || 0 === $object->id ) { throw new RouteException( 'woocommerce_rest_attribute_invalid_id', __( 'Invalid attribute ID.', 'woocommerce' ), 404 ); } $data = $this->prepare_item_for_response( $object, $request ); $response = rest_ensure_response( $data ); return $response; } } Routes/V1/CartRemoveCoupon.php 0000777 00000005006 15251730534 0012274 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartRemoveCoupon class. */ class CartRemoveCoupon extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-remove-coupon'; /** * 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 '/cart/remove-coupon'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'code' => [ 'description' => __( 'Unique identifier for the coupon within the cart.', 'woocommerce' ), 'type' => 'string', ], ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Handle the request and return a valid response for this endpoint. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { if ( ! wc_coupons_enabled() ) { throw new RouteException( 'woocommerce_rest_cart_coupon_disabled', esc_html__( 'Coupons are disabled.', 'woocommerce' ), 404 ); } $cart = $this->cart_controller->get_cart_instance(); $coupon_code = wc_format_coupon_code( $request['code'] ); $coupon = new \WC_Coupon( $coupon_code ); $discounts = new \WC_Discounts( $cart ); if ( ! wc_is_same_coupon( $coupon->get_code(), $coupon_code ) || is_wp_error( $discounts->is_coupon_valid( $coupon ) ) ) { throw new RouteException( 'woocommerce_rest_cart_coupon_error', esc_html__( 'Invalid coupon code.', 'woocommerce' ), 400 ); } if ( ! $this->cart_controller->has_coupon( $coupon_code ) ) { throw new RouteException( 'woocommerce_rest_cart_coupon_invalid_code', esc_html__( 'Coupon cannot be removed because it is not already applied to the cart.', 'woocommerce' ), 409 ); } $cart = $this->cart_controller->get_cart_instance(); $cart->remove_coupon( $coupon_code ); return rest_ensure_response( $this->schema->get_item_response( $cart ) ); } } Routes/V1/CartExtensions.php 0000777 00000003665 15251730534 0012023 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartExtensions class. */ class CartExtensions extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-extensions'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'cart-extensions'; /** * 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 '/cart/extensions'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'namespace' => [ 'description' => __( 'Extension\'s name - this will be used to ensure the data in the request is routed appropriately.', 'woocommerce' ), 'type' => 'string', ], 'data' => [ 'description' => __( 'Additional data to pass to the extension', 'woocommerce' ), 'type' => 'object', ], ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Handle the request and return a valid response for this endpoint. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { try { return $this->schema->get_item_response( $request ); } catch ( \WC_REST_Exception $e ) { throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() ); } } } Routes/V1/CartCoupons.php 0000777 00000007656 15251730534 0011316 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartCoupons class. */ class CartCoupons extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-coupons'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'cart-coupon'; /** * 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 '/cart/coupons'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ), ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ), ], [ 'methods' => \WP_REST_Server::DELETABLE, 'permission_callback' => '__return_true', 'callback' => [ $this, 'get_response' ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], 'allow_batch' => [ 'v1' => true ], ]; } /** * Get a collection of cart coupons. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $cart_coupons = $this->cart_controller->get_cart_coupons(); $items = []; foreach ( $cart_coupons as $coupon_code ) { $response = rest_ensure_response( $this->schema->get_item_response( $coupon_code ) ); $response->add_links( $this->prepare_links( $coupon_code, $request ) ); $response = $this->prepare_response_for_collection( $response ); $items[] = $response; } $response = rest_ensure_response( $items ); return $response; } /** * Add a coupon to the cart and return the result. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { if ( ! wc_coupons_enabled() ) { throw new RouteException( 'woocommerce_rest_cart_coupon_disabled', __( 'Coupons are disabled.', 'woocommerce' ), 404 ); } try { $this->cart_controller->apply_coupon( $request['code'] ); } catch ( \WC_REST_Exception $e ) { throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() ); } $response = $this->prepare_item_for_response( $request['code'], $request ); $response->set_status( 201 ); return $response; } /** * Deletes all coupons in the cart. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_delete_response( \WP_REST_Request $request ) { $cart = $this->cart_controller->get_cart_instance(); $cart->remove_coupons(); $cart->calculate_totals(); return new \WP_REST_Response( [], 200 ); } /** * Prepare links for the request. * * @param string $coupon_code Coupon code. * @param \WP_REST_Request $request Request object. * @return array */ protected function prepare_links( $coupon_code, $request ) { $base = $this->get_namespace() . $this->get_path(); $links = array( 'self' => array( 'href' => rest_url( trailingslashit( $base ) . $coupon_code ), ), 'collection' => array( 'href' => rest_url( $base ), ), ); return $links; } } Routes/V1/CartUpdateCustomer.php 0000777 00000024453 15251730534 0012626 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\Internal\FraudProtection\CheckoutEventTracker; use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController; use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait; /** * CartUpdateCustomer class. * * Updates the customer billing and shipping addresses, recalculates the cart totals, and returns an updated cart. */ class CartUpdateCustomer extends AbstractCartRoute { use DraftOrderTrait; /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-update-customer'; /** * 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 '/cart/update-customer'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_response' ), 'permission_callback' => '__return_true', 'args' => array( 'billing_address' => array( 'description' => __( 'Billing address.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'properties' => $this->schema->billing_address_schema->get_properties(), 'sanitize_callback' => null, ), 'shipping_address' => array( 'description' => __( 'Shipping address.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'properties' => $this->schema->shipping_address_schema->get_properties(), 'sanitize_callback' => null, ), ), ), 'schema' => array( $this->schema, 'get_public_item_schema' ), 'allow_batch' => array( 'v1' => true ), ); } /** * Validate address params now they are populated. * * @param \WP_REST_Request $request Request object. * @param array $billing Billing address. * @param array $shipping Shipping address. * @return \WP_Error|true */ protected function validate_address_params( $request, $billing, $shipping ) { $posted_billing = isset( $request['billing_address'] ); $posted_shipping = isset( $request['shipping_address'] ); $invalid_params = array(); $invalid_details = array(); if ( $posted_billing ) { $billing_validation_check = $this->schema->billing_address_schema->validate_callback( $billing, $request, 'billing_address' ); if ( false === $billing_validation_check ) { $invalid_params['billing_address'] = __( 'Invalid parameter.', 'woocommerce' ); } elseif ( is_wp_error( $billing_validation_check ) ) { $invalid_params['billing_address'] = implode( ' ', $billing_validation_check->get_error_messages() ); $invalid_details['billing_address'] = \rest_convert_error_to_response( $billing_validation_check )->get_data(); } } if ( $posted_shipping ) { $shipping_validation_check = $this->schema->shipping_address_schema->validate_callback( $shipping, $request, 'shipping_address' ); if ( false === $shipping_validation_check ) { $invalid_params['shipping_address'] = __( 'Invalid parameter.', 'woocommerce' ); } elseif ( is_wp_error( $shipping_validation_check ) ) { $invalid_params['shipping_address'] = implode( ' ', $shipping_validation_check->get_error_messages() ); $invalid_details['shipping_address'] = \rest_convert_error_to_response( $shipping_validation_check )->get_data(); } } if ( $invalid_params ) { return new \WP_Error( 'rest_invalid_param', /* translators: %s: List of invalid parameters. */ sprintf( __( 'Invalid parameter(s): %s', 'woocommerce' ), implode( ', ', array_keys( $invalid_params ) ) ), array( 'status' => 400, 'params' => $invalid_params, 'details' => $invalid_details, ) ); } return true; } /** * Handle the request and return a valid response for this endpoint. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { $cart = $this->cart_controller->get_cart_instance(); $customer = wc()->customer; // Get data from request object and merge with customer object. $billing = wp_parse_args( $request['billing_address'] ?? array(), $this->get_customer_billing_address( $customer ) ); $shipping = wp_parse_args( $request['shipping_address'] ?? array(), $this->get_customer_shipping_address( $customer ) ); // If the cart does not need shipping, shipping address is forced to match billing address unless defined. if ( ! $cart->needs_shipping() && ! isset( $request['shipping_address'] ) ) { $shipping = $billing; } // Run validation and sanitization now that the cart and customer data is loaded. $billing = $this->schema->billing_address_schema->sanitize_callback( $billing, $request, 'billing_address' ); $shipping = $this->schema->shipping_address_schema->sanitize_callback( $shipping, $request, 'shipping_address' ); // Validate data now everything is clean.. $validation_check = $this->validate_address_params( $request, $billing, $shipping ); if ( is_wp_error( $validation_check ) ) { return rest_ensure_response( $validation_check ); } $customer->set_props( array( 'billing_first_name' => $billing['first_name'] ?? null, 'billing_last_name' => $billing['last_name'] ?? null, 'billing_company' => $billing['company'] ?? null, 'billing_address_1' => $billing['address_1'] ?? null, 'billing_address_2' => $billing['address_2'] ?? null, 'billing_city' => $billing['city'] ?? null, 'billing_state' => $billing['state'] ?? null, 'billing_postcode' => $billing['postcode'] ?? null, 'billing_country' => $billing['country'] ?? null, 'billing_phone' => $billing['phone'] ?? null, 'billing_email' => $billing['email'] ?? null, 'shipping_first_name' => $shipping['first_name'] ?? null, 'shipping_last_name' => $shipping['last_name'] ?? null, 'shipping_company' => $shipping['company'] ?? null, 'shipping_address_1' => $shipping['address_1'] ?? null, 'shipping_address_2' => $shipping['address_2'] ?? null, 'shipping_city' => $shipping['city'] ?? null, 'shipping_state' => $shipping['state'] ?? null, 'shipping_postcode' => $shipping['postcode'] ?? null, 'shipping_country' => $shipping['country'] ?? null, 'shipping_phone' => $shipping['phone'] ?? null, ) ); // We want to only get additional fields passed, since core ones are already saved. $core_fields = array_keys( $this->additional_fields_controller->get_core_fields() ); $additional_shipping_values = array_diff_key( $shipping, array_flip( $core_fields ) ); $additional_billing_values = array_diff_key( $billing, array_flip( $core_fields ) ); // We save them one by one, and we add the group prefix. foreach ( $additional_shipping_values as $key => $value ) { $this->additional_fields_controller->persist_field_for_customer( $key, $value, $customer, 'shipping' ); } foreach ( $additional_billing_values as $key => $value ) { $this->additional_fields_controller->persist_field_for_customer( $key, $value, $customer, 'billing' ); } wc_do_deprecated_action( 'woocommerce_blocks_cart_update_customer_from_request', array( $customer, $request, ), '7.2.0', 'woocommerce_store_api_cart_update_customer_from_request', 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_cart_update_customer_from_request instead.' ); /** * Fires when the Checkout Block/Store API updates a customer from the API request data. * * @since 7.2.0 * * @param \WC_Customer $customer Customer object. * @param \WP_REST_Request $request Full details about the request. */ do_action( 'woocommerce_store_api_cart_update_customer_from_request', $customer, $request ); $customer->save(); $container = wc_get_container(); if ( $container->get( FraudProtectionController::class )->feature_is_enabled() ) { $container->get( CheckoutEventTracker::class )->track_blocks_checkout_update(); } $this->cart_controller->calculate_totals(); return rest_ensure_response( $this->schema->get_item_response( $cart ) ); } /** * Get full customer billing address. * * @param \WC_Customer $customer Customer object. * @return array */ protected function get_customer_billing_address( \WC_Customer $customer ) { $additional_fields = $this->additional_fields_controller->get_all_fields_from_object( $customer, 'billing' ); return array_merge( array( 'first_name' => $customer->get_billing_first_name(), 'last_name' => $customer->get_billing_last_name(), 'company' => $customer->get_billing_company(), 'address_1' => $customer->get_billing_address_1(), 'address_2' => $customer->get_billing_address_2(), 'city' => $customer->get_billing_city(), 'state' => $customer->get_billing_state(), 'postcode' => $customer->get_billing_postcode(), 'country' => $customer->get_billing_country(), 'phone' => $customer->get_billing_phone(), 'email' => $customer->get_billing_email(), ), $additional_fields ); } /** * Get full customer shipping address. * * @param \WC_Customer $customer Customer object. * @return array */ protected function get_customer_shipping_address( \WC_Customer $customer ) { $additional_fields = $this->additional_fields_controller->get_all_fields_from_object( $customer, 'shipping' ); return array_merge( array( 'first_name' => $customer->get_shipping_first_name(), 'last_name' => $customer->get_shipping_last_name(), 'company' => $customer->get_shipping_company(), 'address_1' => $customer->get_shipping_address_1(), 'address_2' => $customer->get_shipping_address_2(), 'city' => $customer->get_shipping_city(), 'state' => $customer->get_shipping_state(), 'postcode' => $customer->get_shipping_postcode(), 'country' => $customer->get_shipping_country(), 'phone' => $customer->get_shipping_phone(), ), $additional_fields ); } } Routes/V1/CartSelectShippingRate.php 0000777 00000007152 15251730534 0013414 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * CartSelectShippingRate class. */ class CartSelectShippingRate extends AbstractCartRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'cart-select-shipping-rate'; /** * 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 '/cart/select-shipping-rate'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_response' ), 'permission_callback' => '__return_true', 'args' => array( 'package_id' => array( 'description' => __( 'The ID of the package being shipped. Leave blank to apply to all packages.', 'woocommerce' ), 'type' => array( 'integer', 'string', 'null' ), 'required' => false, ), 'rate_id' => array( 'description' => __( 'The chosen rate ID for the package.', 'woocommerce' ), 'type' => 'string', 'required' => true, ), ), ), 'schema' => array( $this->schema, 'get_public_item_schema' ), 'allow_batch' => array( 'v1' => true ), ); } /** * Handle the request and return a valid response for this endpoint. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { if ( ! wc_shipping_enabled() ) { throw new RouteException( 'woocommerce_rest_shipping_disabled', esc_html__( 'Shipping is disabled.', 'woocommerce' ), 404 ); } if ( ! isset( $request['rate_id'] ) ) { throw new RouteException( 'woocommerce_rest_cart_missing_rate_id', esc_html__( 'Invalid Rate ID.', 'woocommerce' ), 400 ); } $cart = $this->cart_controller->get_cart_instance(); $package_id = isset( $request['package_id'] ) ? sanitize_text_field( wp_unslash( $request['package_id'] ) ) : null; $rate_id = sanitize_text_field( wp_unslash( $request['rate_id'] ) ); try { if ( ! is_null( $package_id ) ) { $this->cart_controller->select_shipping_rate( $package_id, $rate_id ); } else { foreach ( $this->cart_controller->get_shipping_packages() as $package ) { $this->cart_controller->select_shipping_rate( $package['package_id'], $rate_id ); } } } catch ( \WC_Rest_Exception $e ) { throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** * Fires an action after a shipping method has been chosen for package(s) via the Store API. * * This allows extensions to perform addition actions after a shipping method has been chosen, but before the * cart totals are recalculated. * * @since 9.0.0 * * @param string|null $package_id The sanitized ID of the package being updated. Null if all packages are being updated. * @param string $rate_id The sanitized chosen rate ID for the package. * @param \WP_REST_Request $request Full details about the request. */ do_action( 'woocommerce_store_api_cart_select_shipping_rate', $package_id, $rate_id, $request ); $cart->calculate_totals(); return rest_ensure_response( $this->cart_schema->get_item_response( $cart ) ); } } Routes/V1/AbstractRoute.php 0000777 00000023260 15251730534 0011625 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Routes\RouteInterface; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; use WP_Error; /** * AbstractRoute class. */ abstract class AbstractRoute implements RouteInterface { /** * Schema class instance. * * @var AbstractSchema */ protected $schema; /** * Route namespace. * * @var string */ protected $namespace = 'wc/store/v1'; /** * Schema Controller instance. * * @var SchemaController */ protected $schema_controller; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = ''; /** * The routes schema version. * * @var integer */ const SCHEMA_VERSION = 1; /** * Constructor. * * @param SchemaController $schema_controller Schema Controller instance. * @param AbstractSchema $schema Schema class for this route. */ public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) { $this->schema_controller = $schema_controller; $this->schema = $schema; } /** * Get the namespace for this route. * * @return string */ public function get_namespace() { return $this->namespace; } /** * Set the namespace for this route. * * @param string $namespace Given namespace. */ public function set_namespace( $namespace ) { $this->namespace = $namespace; } /** * Get item schema properties. * * @return array */ public function get_item_schema() { return $this->schema->get_item_schema(); } /** * Get the route response based on the type of request. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function get_response( \WP_REST_Request $request ) { $response = null; try { $response = $this->get_response_by_request_method( $request ); } catch ( RouteException $error ) { $response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() ); } catch ( InvalidCartException $error ) { $response = $this->get_route_error_response_from_object( $error->getError(), $error->getCode(), $error->getAdditionalData() ); } catch ( \Exception $error ) { $response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 ); } return is_wp_error( $response ) ? $this->error_to_response( $response ) : $response; } /** * Get the route response based on the type of request. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_response_by_request_method( \WP_REST_Request $request ) { switch ( $request->get_method() ) { case 'POST': return $this->get_route_post_response( $request ); case 'PUT': case 'PATCH': return $this->get_route_update_response( $request ); case 'DELETE': return $this->get_route_delete_response( $request ); } return $this->get_route_response( $request ); } /** * Converts an error to a response object. Based on \WP_REST_Server. * * @param \WP_Error $error WP_Error instance. * @return \WP_REST_Response List of associative arrays with code and message keys. */ protected function error_to_response( $error ) { $error_data = $error->get_error_data(); $status = isset( $error_data, $error_data['status'] ) ? $error_data['status'] : 500; $errors = []; foreach ( (array) $error->errors as $code => $messages ) { foreach ( (array) $messages as $message ) { $errors[] = array( 'code' => $code, 'message' => $message, 'data' => $error->get_error_data( $code ), ); } } $data = array_shift( $errors ); if ( count( $errors ) ) { $data['additional_errors'] = $errors; } return new \WP_REST_Response( $data, $status ); } /** * Get route response for GET requests. * * When implemented, should return a \WP_REST_Response. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { return $this->get_route_error_response( 'woocommerce_rest_invalid_endpoint', __( 'Method not implemented', 'woocommerce' ), 404 ); } /** * Get route response for POST requests. * * When implemented, should return a \WP_REST_Response. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_post_response( \WP_REST_Request $request ) { return $this->get_route_error_response( 'woocommerce_rest_invalid_endpoint', __( 'Method not implemented', 'woocommerce' ), 404 ); } /** * Get route response for PUT requests. * * When implemented, should return a \WP_REST_Response. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_update_response( \WP_REST_Request $request ) { return $this->get_route_error_response( 'woocommerce_rest_invalid_endpoint', __( 'Method not implemented', 'woocommerce' ), 404 ); } /** * Get route response for DELETE requests. * * When implemented, should return a \WP_REST_Response. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_delete_response( \WP_REST_Request $request ) { return $this->get_route_error_response( 'woocommerce_rest_invalid_endpoint', __( 'Method not implemented', 'woocommerce' ), 404 ); } /** * Get route response when something went wrong. * * @param string $error_code String based error code. * @param string $error_message User facing error message. * @param int $http_status_code HTTP status. Defaults to 500. * @param array $additional_data Extra data (key value pairs) to expose in the error response. * @return \WP_Error WP Error object. */ protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) { return new \WP_Error( $error_code, $error_message, array_merge( $additional_data, [ 'status' => $http_status_code ] ) ); } /** * Get route response when something went wrong and the supplied error is a WP_Error. This currently only happens * when an item in the cart is out of stock, partially out of stock, can only be bought individually, or when the * item is not purchasable. * * @param WP_Error $error_object The WP_Error object containing the error. * @param int $http_status_code HTTP status. Defaults to 500. * @param array $additional_data Extra data (key value pairs) to expose in the error response. * @return WP_Error WP Error object. */ protected function get_route_error_response_from_object( $error_object, $http_status_code = 500, $additional_data = [] ) { $error_object->add_data( array_merge( $additional_data, [ 'status' => $http_status_code ] ) ); return $error_object; } /** * Prepare a single item for response. * * @param mixed $item Item to format to schema. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, \WP_REST_Request $request ) { $response = rest_ensure_response( $this->schema->get_item_response( $item ) ); $response->add_links( $this->prepare_links( $item, $request ) ); return $response; } /** * Retrieves the context param. * * Ensures consistent descriptions between endpoints, and populates enum from schema. * * @param array $args Optional. Additional arguments for context parameter. Default empty array. * @return array Context parameter details. */ protected function get_context_param( $args = array() ) { $param_details = array( 'description' => __( 'Scope under which the request is made; determines fields present in response.', 'woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); $schema = $this->get_item_schema(); if ( empty( $schema['properties'] ) ) { return array_merge( $param_details, $args ); } $contexts = array(); foreach ( $schema['properties'] as $attributes ) { if ( ! empty( $attributes['context'] ) ) { $contexts = array_merge( $contexts, $attributes['context'] ); } } if ( ! empty( $contexts ) ) { $param_details['enum'] = array_unique( $contexts ); rsort( $param_details['enum'] ); } return array_merge( $param_details, $args ); } /** * Prepares a response for insertion into a collection. * * @param \WP_REST_Response $response Response object. * @return array|mixed Response data, ready for insertion into collection data. */ protected function prepare_response_for_collection( \WP_REST_Response $response ) { $data = (array) $response->get_data(); $server = rest_get_server(); $links = $server::get_compact_response_links( $response ); if ( ! empty( $links ) ) { $data['_links'] = $links; } return $data; } /** * Prepare links for the request. * * @param mixed $item Item to prepare. * @param \WP_REST_Request $request Request object. * @return array */ protected function prepare_links( $item, $request ) { return []; } /** * Retrieves the query params for the collections. * * @return array Query parameters for the collection. */ public function get_collection_params() { return array( 'context' => $this->get_context_param(), ); } } Routes/V1/AbstractCartRoute.php 0000777 00000022761 15251730534 0012444 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\Blocks\Package; use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema; use Automattic\WooCommerce\StoreApi\SessionHandler; use Automattic\WooCommerce\StoreApi\Utilities\CartController; use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait; use Automattic\WooCommerce\StoreApi\Utilities\OrderController; use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils; /** * Abstract Cart Route */ abstract class AbstractCartRoute extends AbstractRoute { use DraftOrderTrait; /** * The route's schema. * * @var string */ const SCHEMA_TYPE = 'cart'; /** * Schema class instance. * * @var CartSchema */ protected $schema; /** * Schema class for the cart. * * @var CartSchema */ protected $cart_schema; /** * Schema class for the cart item. * * @var CartItemSchema */ protected $cart_item_schema; /** * Cart controller class instance. * * @var CartController */ protected $cart_controller; /** * Order controller class instance. * * @var OrderController */ protected $order_controller; /** * Additional fields controller class instance. * * @var CheckoutFields */ protected $additional_fields_controller; /** * True when this route has been requested with a valid cart token. * * @var bool|null */ protected $has_cart_token = null; /** * Constructor. * * @param SchemaController $schema_controller Schema Controller instance. * @param AbstractSchema $schema Schema class for this route. */ public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) { parent::__construct( $schema_controller, $schema ); $this->cart_schema = $this->schema_controller->get( CartSchema::IDENTIFIER ); $this->cart_item_schema = $this->schema_controller->get( CartItemSchema::IDENTIFIER ); $this->cart_controller = new CartController(); $this->additional_fields_controller = Package::container()->get( CheckoutFields::class ); $this->order_controller = new OrderController(); } /** * Are we updating data or getting data? * * @param \WP_REST_Request $request Request object. * @return boolean */ protected function is_update_request( \WP_REST_Request $request ) { return in_array( $request->get_method(), [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true ); } /** * Get the route response based on the type of request. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function get_response( \WP_REST_Request $request ) { $this->load_cart_session( $request ); $response = null; $nonce_check = $this->requires_nonce( $request ) ? $this->check_nonce( $request ) : null; if ( is_wp_error( $nonce_check ) ) { $response = $nonce_check; } if ( ! $response ) { try { $response = $this->get_response_by_request_method( $request ); } catch ( RouteException $error ) { $response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() ); } catch ( \Exception $error ) { $response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 ); } } // For update requests, this will recalculate cart totals and sync draft orders with the current cart. if ( $this->is_update_request( $request ) ) { $this->cart_updated( $request ); } // Format error responses. if ( is_wp_error( $response ) ) { $response = $this->error_to_response( $response ); } return $this->add_response_headers( rest_ensure_response( $response ) ); } /** * Add nonce headers to a response object. * * @param \WP_REST_Response $response The response object. * * @return \WP_REST_Response */ protected function add_response_headers( \WP_REST_Response $response ) { $nonce = wp_create_nonce( 'wc_store_api' ); $response->header( 'Nonce', $nonce ); $response->header( 'Nonce-Timestamp', time() ); $response->header( 'User-ID', get_current_user_id() ); $response->header( 'Cart-Token', $this->get_cart_token() ); $response->header( 'Cart-Hash', WC()->cart->get_cart_hash() ); return $response; } /** * Load the cart session before handling responses. * * @param \WP_REST_Request $request Request object. */ protected function load_cart_session( \WP_REST_Request $request ) { if ( $this->has_cart_token( $request ) ) { // Overrides the core session class. add_filter( 'woocommerce_session_handler', function () { return SessionHandler::class; } ); } $this->cart_controller->load_cart(); $this->cart_controller->normalize_cart(); } /** * Generates a cart token for the response headers. * * Current namespace is used as the token Issuer. * * * * @return string */ protected function get_cart_token() { // Ensure cart is loaded. $this->cart_controller->load_cart(); if ( ! wc()->session ) { return null; } return CartTokenUtils::get_cart_token( (string) wc()->session->get_customer_id() ); } /** * Checks if the request has a valid cart token. * * @param \WP_REST_Request $request Request object. * @return bool */ protected function has_cart_token( \WP_REST_Request $request ) { if ( is_null( $this->has_cart_token ) ) { $this->has_cart_token = CartTokenUtils::validate_cart_token( $request->get_header( 'Cart-Token' ) ?? '' ); } return $this->has_cart_token; } /** * Checks if a nonce is required for the route. * * @param \WP_REST_Request $request Request. * * @return bool */ protected function requires_nonce( \WP_REST_Request $request ) { return $this->is_update_request( $request ) && ! $this->has_cart_token( $request ); } /** * Triggered after an update to cart data. Re-calculates totals and updates draft orders (if they already exist) to * keep all data in sync. * * @param \WP_REST_Request $request Request object. */ protected function cart_updated( \WP_REST_Request $request ) { $draft_order = $this->get_draft_order(); if ( $draft_order ) { // This does not trigger a recalculation of the cart--endpoints should have already done so before returning // the cart response. $this->order_controller->update_order_from_cart( $draft_order, false ); wc_do_deprecated_action( 'woocommerce_blocks_cart_update_order_from_request', array( $draft_order, $request, ), '7.2.0', 'woocommerce_store_api_cart_update_order_from_request', 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_cart_update_order_from_request instead.' ); /** * Fires when the order is synced with cart data from a cart route. * * @since 7.2.0 * * @param \WC_Order $draft_order Order object. * @param \WC_Customer $customer Customer object. * @param \WP_REST_Request $request Full details about the request. */ do_action( 'woocommerce_store_api_cart_update_order_from_request', $draft_order, $request ); } } /** * For non-GET endpoints, require and validate a nonce to prevent CSRF attacks. * * Nonces will mismatch if the logged in session cookie is different! If using a client to test, set this cookie * to match the logged in cookie in your browser. * * @param \WP_REST_Request $request Request object. * * @return \WP_Error|boolean */ protected function check_nonce( \WP_REST_Request $request ) { $nonce = null; if ( $request->get_header( 'Nonce' ) ) { $nonce = $request->get_header( 'Nonce' ); } /** * Filters the Store API nonce check. * * This can be used to disable the nonce check when testing API endpoints via a REST API client. * * @since 4.5.0 * * @param boolean $disable_nonce_check If true, nonce checks will be disabled. * * @return boolean */ if ( apply_filters( 'woocommerce_store_api_disable_nonce_check', false ) ) { return true; } if ( null === $nonce ) { return $this->get_route_error_response( 'woocommerce_rest_missing_nonce', __( 'Missing the Nonce header. This endpoint requires a valid nonce.', 'woocommerce' ), 401 ); } if ( ! wp_verify_nonce( $nonce, 'wc_store_api' ) ) { return $this->get_route_error_response( 'woocommerce_rest_invalid_nonce', __( 'Nonce is invalid.', 'woocommerce' ), 403 ); } return true; } /** * Get route response when something went wrong. * * @param string $error_code String based error code. * @param string $error_message User facing error message. * @param int $http_status_code HTTP status. Defaults to 500. * @param array $additional_data Extra data (key value pairs) to expose in the error response. * * @return \WP_Error WP Error object. */ protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) { $additional_data['status'] = $http_status_code; // If there was a conflict, return the cart so the client can resolve it. if ( 409 === $http_status_code ) { $additional_data['cart'] = $this->cart_schema->get_item_response( $this->cart_controller->get_cart_for_response() ); } return new \WP_Error( $error_code, $error_message, $additional_data ); } } Routes/V1/ProductBrandsById.php 0000777 00000004614 15251730534 0012367 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * ProductBrandsById class. */ class ProductBrandsById extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-brands-by-id'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product-brand'; /** * 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 '/products/brands/(?P<identifier>[\w-]+)'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ 'args' => array( 'identifier' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'string', ), ), [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => array( 'context' => $this->get_context_param( array( 'default' => 'view', ) ), ), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a single item. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { if ( isset( $request['identifier'] ) && is_numeric( $request['identifier'] ) ) { $object = get_term( (int) $request['identifier'], 'product_brand' ); } else { $object = get_term_by( 'slug', $request['identifier'], 'product_brand' ); } if ( ! $object ) { if ( isset( $request['identifier'] ) && is_numeric( $request['identifier'] ) ) { throw new RouteException( 'woocommerce_rest_brand_invalid_id', esc_html__( 'Invalid brand ID.', 'woocommerce' ), 404 ); } else { throw new RouteException( 'woocommerce_rest_brand_invalid_slug', esc_html__( 'Invalid brand slug.', 'woocommerce' ), 404 ); } } $data = $this->prepare_item_for_response( $object, $request ); return rest_ensure_response( $data ); } } Routes/V1/ProductAttributes.php 0000777 00000003141 15251730534 0012526 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; /** * ProductAttributes class. */ class ProductAttributes extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-attributes'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product-attribute'; /** * 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 '/products/attributes'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->get_collection_params(), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a collection of attributes. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $ids = wc_get_attribute_taxonomy_ids(); $return = []; foreach ( $ids as $id ) { $object = wc_get_attribute( $id ); $data = $this->prepare_item_for_response( $object, $request ); $return[] = $this->prepare_response_for_collection( $data ); } return rest_ensure_response( $return ); } } Routes/V1/ProductCollectionData.php 0000777 00000016212 15251730534 0013270 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes\V1; use Automattic\WooCommerce\StoreApi\Utilities\ProductQueryFilters; /** * ProductCollectionData route. * Get aggregate data from a collection of products. * * Supports the same parameters as /products, but returns a different response. */ class ProductCollectionData extends AbstractRoute { /** * The route identifier. * * @var string */ const IDENTIFIER = 'product-collection-data'; /** * The routes schema. * * @var string */ const SCHEMA_TYPE = 'product-collection-data'; /** * 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 '/products/collection-data'; } /** * Get method arguments for this REST route. * * @return array An array of endpoints. */ public function get_args() { return [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_response' ], 'permission_callback' => '__return_true', 'args' => $this->get_collection_params(), 'allow_batch' => [ 'v1' => true ], ], 'schema' => [ $this->schema, 'get_public_item_schema' ], ]; } /** * Get a collection of posts and add the post title filter option to \WP_Query. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $data = [ 'min_price' => null, 'max_price' => null, 'attribute_counts' => null, 'stock_status_counts' => null, 'rating_counts' => null, 'taxonomy_counts' => null, ]; $filters = new ProductQueryFilters(); if ( ! empty( $request['calculate_price_range'] ) ) { $filter_request = clone $request; $filter_request->set_param( 'min_price', null ); $filter_request->set_param( 'max_price', null ); $price_results = $filters->get_filtered_price( $filter_request ); $data['min_price'] = $price_results->min_price; $data['max_price'] = $price_results->max_price; } if ( ! empty( $request['calculate_stock_status_counts'] ) ) { $filter_request = clone $request; $counts = $filters->get_stock_status_counts( $filter_request ); $data['stock_status_counts'] = []; foreach ( $counts as $key => $value ) { $data['stock_status_counts'][] = (object) [ 'status' => $key, 'count' => $value, ]; } } if ( ! empty( $request['calculate_attribute_counts'] ) ) { $taxonomy__or_queries = []; $taxonomy__and_queries = []; foreach ( $request['calculate_attribute_counts'] as $attributes_to_count ) { if ( ! empty( $attributes_to_count['taxonomy'] ) ) { if ( empty( $attributes_to_count['query_type'] ) || 'or' === $attributes_to_count['query_type'] ) { $taxonomy__or_queries[] = $attributes_to_count['taxonomy']; } else { $taxonomy__and_queries[] = $attributes_to_count['taxonomy']; } } } $data['attribute_counts'] = []; // Or type queries need special handling because the attribute, if set, needs removing from the query first otherwise counts would not be correct. if ( $taxonomy__or_queries ) { foreach ( $taxonomy__or_queries as $taxonomy ) { $filter_request = clone $request; $filter_attributes = $filter_request->get_param( 'attributes' ); if ( ! empty( $filter_attributes ) ) { $filter_attributes = array_filter( $filter_attributes, function ( $query ) use ( $taxonomy ) { return $query['attribute'] !== $taxonomy; } ); } $filter_request->set_param( 'attributes', $filter_attributes ); $counts = $filters->get_attribute_counts( $filter_request, [ $taxonomy ] ); foreach ( $counts as $key => $value ) { $data['attribute_counts'][] = (object) [ 'term' => $key, 'count' => $value, ]; } } } if ( $taxonomy__and_queries ) { $counts = $filters->get_attribute_counts( $request, $taxonomy__and_queries ); foreach ( $counts as $key => $value ) { $data['attribute_counts'][] = (object) [ 'term' => $key, 'count' => $value, ]; } } } if ( ! empty( $request['calculate_rating_counts'] ) ) { $filter_request = clone $request; $counts = $filters->get_rating_counts( $filter_request ); $data['rating_counts'] = []; foreach ( $counts as $key => $value ) { $data['rating_counts'][] = (object) [ 'rating' => $key, 'count' => $value, ]; } } if ( ! empty( $request['calculate_taxonomy_counts'] ) ) { $taxonomies = $request['calculate_taxonomy_counts']; $data['taxonomy_counts'] = []; if ( $taxonomies ) { $counts = $filters->get_taxonomy_counts( $request, $taxonomies ); foreach ( $counts as $key => $value ) { $data['taxonomy_counts'][] = (object) [ 'term' => $key, 'count' => $value, ]; } } } return rest_ensure_response( $this->schema->get_item_response( $data ) ); } /** * Get the query params for collections of products. * * @return array */ public function get_collection_params() { $params = ( new Products( $this->schema_controller, $this->schema ) )->get_collection_params(); $params['calculate_price_range'] = [ 'description' => __( 'If true, calculates the minimum and maximum product prices for the collection.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, ]; $params['calculate_stock_status_counts'] = [ 'description' => __( 'If true, calculates stock counts for products in the collection.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, ]; $params['calculate_attribute_counts'] = [ 'description' => __( 'If requested, calculates attribute term counts for products in the collection.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'taxonomy' => [ 'description' => __( 'Taxonomy name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'query_type' => [ 'description' => __( 'Filter condition being performed which may affect counts. Valid values include "and" and "or".', 'woocommerce' ), 'type' => 'string', 'enum' => [ 'and', 'or' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], 'default' => [], ]; $params['calculate_rating_counts'] = [ 'description' => __( 'If true, calculates rating counts for products in the collection.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, ]; $params['calculate_taxonomy_counts'] = [ 'description' => __( 'If requested, calculates taxonomy term counts for products in the collection.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'string', 'description' => __( 'Taxonomy name.', 'woocommerce' ), ], 'default' => [], ]; return $params; } } Routes/RouteInterface.php 0000777 00000000520 15251730534 0011466 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Routes; /** * RouteInterface. */ interface RouteInterface { /** * Get the path of this REST route. * * @return string */ public function get_path(); /** * Get arguments for this REST route. * * @return array An array of endpoints. */ public function get_args(); } functions.php 0000777 00000005316 15251730534 0007306 0 ustar 00 <?php /** * Helper functions for interacting with the Store API. * * This file is autoloaded via composer.json. */ use Automattic\WooCommerce\StoreApi\StoreApi; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) { /** * Register endpoint data under a specified namespace. * * @see Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::register_endpoint_data() * * @param array $args Args to pass to register_endpoint_data. * @returns boolean|\WP_Error True on success, WP_Error on fail. */ function woocommerce_store_api_register_endpoint_data( $args ) { try { $extend = StoreApi::container()->get( ExtendSchema::class ); $extend->register_endpoint_data( $args ); } catch ( \Exception $error ) { return new \WP_Error( 'error', $error->getMessage() ); } return true; } } if ( ! function_exists( 'woocommerce_store_api_register_update_callback' ) ) { /** * Add callback functions that can be executed by the cart/extensions endpoint. * * @see Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::register_update_callback() * * @param array $args Args to pass to register_update_callback. * @returns boolean|\WP_Error True on success, WP_Error on fail. */ function woocommerce_store_api_register_update_callback( $args ) { try { $extend = StoreApi::container()->get( ExtendSchema::class ); $extend->register_update_callback( $args ); } catch ( \Exception $error ) { return new \WP_Error( 'error', $error->getMessage() ); } return true; } } if ( ! function_exists( 'woocommerce_store_api_register_payment_requirements' ) ) { /** * Registers and validates payment requirements callbacks. * * @see Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::register_payment_requirements() * * @param array $args Args to pass to register_payment_requirements. * @returns boolean|\WP_Error True on success, WP_Error on fail. */ function woocommerce_store_api_register_payment_requirements( $args ) { try { $extend = StoreApi::container()->get( ExtendSchema::class ); $extend->register_payment_requirements( $args ); } catch ( \Exception $error ) { return new \WP_Error( 'error', $error->getMessage() ); } return true; } } if ( ! function_exists( 'woocommerce_store_api_get_formatter' ) ) { /** * Returns a formatter instance. * * @see Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::get_formatter() * * @param string $name Formatter name. * @return Automattic\WooCommerce\StoreApi\Formatters\FormatterInterface */ function woocommerce_store_api_get_formatter( $name ) { return StoreApi::container()->get( ExtendSchema::class )->get_formatter( $name ); } } Schemas/ExtendSchema.php 0000777 00000025333 15251730534 0011232 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas; use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema; use Automattic\WooCommerce\StoreApi\Formatters; /** * Provides utility functions to extend Store API schemas. * * Note there are also helpers that map to these methods. * * @see woocommerce_store_api_register_endpoint_data() * @see woocommerce_store_api_register_update_callback() * @see woocommerce_store_api_register_payment_requirements() * @see woocommerce_store_api_get_formatter() */ final class ExtendSchema { /** * List of Store API schema that is allowed to be extended by extensions. * * @var string[] */ private $endpoints = [ CartItemSchema::IDENTIFIER, CartSchema::IDENTIFIER, CheckoutSchema::IDENTIFIER, ProductSchema::IDENTIFIER, ]; /** * Holds the formatters class instance. * * @var Formatters */ private $formatters; /** * Data to be extended * * @var array */ private $extend_data = []; /** * Data to be extended * * @var array */ private $callback_methods = []; /** * Array of payment requirements * * @var array */ private $payment_requirements = []; /** * Constructor * * @param Formatters $formatters An instance of the formatters class. */ public function __construct( Formatters $formatters ) { $this->formatters = $formatters; } /** * Register endpoint data under a specified namespace * * @param array $args { * An array of elements that make up a post to update or insert. * * @type string $endpoint Required. The endpoint to extend. * @type string $namespace Required. Plugin namespace. * @type callable $schema_callback Callback executed to add schema data. * @type callable $data_callback Callback executed to add endpoint data. * @type string $schema_type The type of data, object or array. * } * * @throws \Exception On failure to register. */ public function register_endpoint_data( $args ) { $args = wp_parse_args( $args, [ 'endpoint' => '', 'namespace' => '', 'schema_callback' => null, 'data_callback' => null, 'schema_type' => ARRAY_A, ] ); if ( ! is_string( $args['namespace'] ) || empty( $args['namespace'] ) ) { $this->throw_exception( 'You must provide a plugin namespace when extending a Store REST endpoint.' ); } if ( ! in_array( $args['endpoint'], $this->endpoints, true ) ) { $this->throw_exception( sprintf( 'You must provide a valid Store REST endpoint to extend, valid endpoints are: %1$s. You provided %2$s.', implode( ', ', $this->endpoints ), $args['endpoint'] ) ); } if ( ! is_null( $args['schema_callback'] ) && ! is_callable( $args['schema_callback'] ) ) { $this->throw_exception( '$schema_callback must be a callable function.' ); } if ( ! is_null( $args['data_callback'] ) && ! is_callable( $args['data_callback'] ) ) { $this->throw_exception( '$data_callback must be a callable function.' ); } if ( ! in_array( $args['schema_type'], [ ARRAY_N, ARRAY_A ], true ) ) { $this->throw_exception( sprintf( 'Data type must be either ARRAY_N for a numeric array or ARRAY_A for an object like array. You provided %1$s.', $args['schema_type'] ) ); } $this->extend_data[ $args['endpoint'] ][ $args['namespace'] ] = [ 'schema_callback' => $args['schema_callback'], 'data_callback' => $args['data_callback'], 'schema_type' => $args['schema_type'], ]; } /** * Add callback functions that can be executed by the cart/extensions endpoint. * * @param array $args { * An array of elements that make up the callback configuration. * * @type string $namespace Required. Plugin namespace. * @type callable $callback Required. The function/callable to execute. * } * * @throws \Exception On failure to register. */ public function register_update_callback( $args ) { $args = wp_parse_args( $args, [ 'namespace' => '', 'callback' => null, ] ); if ( ! is_string( $args['namespace'] ) || empty( $args['namespace'] ) ) { throw new \Exception( 'You must provide a plugin namespace when extending a Store REST endpoint.' ); } if ( ! is_callable( $args['callback'] ) ) { throw new \Exception( 'There is no valid callback supplied to register_update_callback.' ); } $this->callback_methods[ $args['namespace'] ] = $args; } /** * Registers and validates payment requirements callbacks. * * @param array $args { * Array of registration data. * * @type callable $data_callback Required. Callback executed to add payment requirements data. * } * * @throws \Exception On failure to register. */ public function register_payment_requirements( $args ) { if ( empty( $args['data_callback'] ) || ! is_callable( $args['data_callback'] ) ) { $this->throw_exception( '$data_callback must be a callable function.' ); } $this->payment_requirements[] = $args['data_callback']; } /** * Returns a formatter instance. * * @param string $name Formatter name. * @return FormatterInterface */ public function get_formatter( $name ) { return $this->formatters->$name; } /** * Get callback for a specific endpoint and namespace. * * @param string $namespace The namespace to get callbacks for. * * @return callable The callback registered by the extension. * @throws \Exception When callback is not callable or parameters are incorrect. */ public function get_update_callback( $namespace ) { if ( ! is_string( $namespace ) ) { throw new \Exception( 'You must provide a plugin namespace when extending a Store REST endpoint.' ); } if ( ! array_key_exists( $namespace, $this->callback_methods ) ) { throw new \Exception( sprintf( 'There is no such namespace registered: %1$s.', $namespace ) ); } if ( ! array_key_exists( 'callback', $this->callback_methods[ $namespace ] ) || ! is_callable( $this->callback_methods[ $namespace ]['callback'] ) ) { throw new \Exception( sprintf( 'There is no valid callback registered for: %1$s.', $namespace ) ); } return $this->callback_methods[ $namespace ]['callback']; } /** * Returns the registered endpoint data * * @param string $endpoint A valid identifier. * @param array $passed_args Passed arguments from the Schema class. * @return object Returns an casted object with registered endpoint data. * @throws \Exception If a registered callback throws an error, or silently logs it. */ public function get_endpoint_data( $endpoint, array $passed_args = [] ) { $registered_data = []; if ( isset( $this->extend_data[ $endpoint ] ) ) { foreach ( $this->extend_data[ $endpoint ] as $namespace => $callbacks ) { if ( is_null( $callbacks['data_callback'] ) ) { continue; } try { $data = $callbacks['data_callback']( ...$passed_args ); if ( ! is_array( $data ) ) { $data = []; throw new \Exception( '$data_callback must return an array.' ); } } catch ( \Throwable $e ) { $this->throw_exception( $e ); } $registered_data[ $namespace ] = $data; } } return (object) $registered_data; } /** * Returns the registered endpoint schema * * @param string $endpoint A valid identifier. * @param array $passed_args Passed arguments from the Schema class. * @return object Returns an array with registered schema data. * @throws \Exception If a registered callback throws an error, or silently logs it. */ public function get_endpoint_schema( $endpoint, array $passed_args = [] ) { $registered_schema = []; if ( isset( $this->extend_data[ $endpoint ] ) ) { foreach ( $this->extend_data[ $endpoint ] as $namespace => $callbacks ) { if ( is_null( $callbacks['schema_callback'] ) ) { continue; } try { $schema = $callbacks['schema_callback']( ...$passed_args ); if ( ! is_array( $schema ) ) { $schema = []; throw new \Exception( '$schema_callback must return an array.' ); } } catch ( \Throwable $e ) { $this->throw_exception( $e ); } $registered_schema[ $namespace ] = $this->format_extensions_properties( $namespace, $schema, $callbacks['schema_type'] ); } } return (object) $registered_schema; } /** * Returns the additional payment requirements for the cart which are required to make payments. Values listed here * are compared against each Payment Gateways "supports" flag. * * @param array $requirements list of requirements that should be added to the collected requirements. * @return array Returns a list of payment requirements. * @throws \Exception If a registered callback throws an error, or silently logs it. */ public function get_payment_requirements( array $requirements = [ 'products' ] ) { if ( ! empty( $this->payment_requirements ) ) { foreach ( $this->payment_requirements as $callback ) { try { $data = $callback(); if ( ! is_array( $data ) ) { throw new \Exception( '$data_callback must return an array.' ); } $requirements = array_unique( array_merge( $requirements, $data ) ); } catch ( \Throwable $e ) { $this->throw_exception( $e ); } } } return $requirements; } /** * Throws error and/or silently logs it. * * @param string|\Throwable $exception_or_error Error message or \Exception. * @throws \Exception An error to throw if we have debug enabled and user is admin. */ private function throw_exception( $exception_or_error ) { $exception = is_string( $exception_or_error ) ? new \Exception( $exception_or_error ) : $exception_or_error; wc_caught_exception( $exception ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG && current_user_can( 'manage_woocommerce' ) ) { throw $exception; } } /** * Format schema for an extension. * * @param string $namespace Error message or \Exception. * @param array $schema An error to throw if we have debug enabled and user is admin. * @param string $schema_type How should data be shaped. * @return array Formatted schema. */ private function format_extensions_properties( $namespace, $schema, $schema_type ) { if ( ARRAY_N === $schema_type ) { return [ /* translators: %s: extension namespace */ 'description' => sprintf( __( 'Extension data registered by %s', 'woocommerce' ), $namespace ), 'type' => [ 'array', 'null' ], 'context' => [ 'view', 'edit' ], 'items' => $schema, ]; } return [ /* translators: %s: extension namespace */ 'description' => sprintf( __( 'Extension data registered by %s', 'woocommerce' ), $namespace ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'properties' => $schema, ]; } } Schemas/V1/CartItemSchema.php 0000777 00000017066 15251730534 0012005 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Utilities\ProductItemTrait; use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits; /** * CartItemSchema class. */ class CartItemSchema extends ItemSchema { use ProductItemTrait; /** * The schema item name. * * @var string */ protected $title = 'cart_item'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'cart-item'; /** * Convert a WooCommerce cart item to an object suitable for the response. * * @param array $cart_item Cart item array. * @return array */ public function get_item_response( $cart_item ) { $product = $cart_item['data'] ?? false; if ( ! $product instanceof \WC_Product ) { return []; } /** * Filter the product permalink. * * This is a hook taken from the legacy cart/mini-cart templates that allows the permalink to be changed for a * product. This is specific to the cart endpoint. * * @since 9.9.0 * * @param string $product_permalink Product permalink. * @param array $cart_item Cart item array. * @param string $cart_item_key Cart item key. */ $product_permalink = apply_filters( 'woocommerce_cart_item_permalink', $product->get_permalink(), $cart_item, $cart_item['key'] ); return [ 'key' => $cart_item['key'], 'id' => $product->get_id(), 'type' => $product->get_type(), 'quantity' => wc_stock_amount( $cart_item['quantity'] ), 'quantity_limits' => (object) ( new QuantityLimits() )->get_cart_item_quantity_limits( $cart_item ), 'name' => $this->prepare_html_response( $product->get_title() ), 'short_description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) ), 'description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) ), 'sku' => $this->prepare_html_response( $product->get_sku() ), 'low_stock_remaining' => $this->get_low_stock_remaining( $product ), 'backorders_allowed' => (bool) $product->backorders_allowed(), 'show_backorder_badge' => (bool) $product->backorders_require_notification() && $product->is_on_backorder( $cart_item['quantity'] ), 'sold_individually' => $product->is_sold_individually(), 'permalink' => $product_permalink, 'images' => $this->get_cart_images( $product, $cart_item, $cart_item['key'] ), 'variation' => $this->format_variation_data( $cart_item['variation'], $product ), 'item_data' => $this->get_item_data( $cart_item ), 'prices' => (object) $this->prepare_product_price_response( $product, get_option( 'woocommerce_tax_display_cart' ) ), 'totals' => (object) $this->prepare_currency_response( [ 'line_subtotal' => $this->prepare_money_response( $cart_item['line_subtotal'], wc_get_price_decimals() ), 'line_subtotal_tax' => $this->prepare_money_response( $cart_item['line_subtotal_tax'], wc_get_price_decimals() ), 'line_total' => $this->prepare_money_response( $cart_item['line_total'], wc_get_price_decimals() ), 'line_total_tax' => $this->prepare_money_response( $cart_item['line_tax'], wc_get_price_decimals() ), ] ), 'catalog_visibility' => $product->get_catalog_visibility(), self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER, $cart_item ), ]; } /** * Get list of product images for the cart item. * * @param \WC_Product $product Product instance. * @param array $cart_item Cart item array. * @param string $cart_item_key Cart item key. * @return array */ protected function get_cart_images( \WC_Product $product, array $cart_item, string $cart_item_key ) { $product_images = $this->get_images( $product ); /** * Filter the cart product images. * * This hook allows the cart item images to be changed. This is specific to the cart endpoint. * * @param array $product_images Array of image objects, as defined in ImageAttachmentSchema. * @param array $cart_item Cart item array. * @param string $cart_item_key Cart item key. * @since 9.6.0 */ $filtered_images = apply_filters( 'woocommerce_store_api_cart_item_images', $product_images, $cart_item, $cart_item_key ); if ( ! is_array( $filtered_images ) || count( $filtered_images ) === 0 ) { return $product_images; } // Return the original images if the filtered image has no ID, or an invalid thumbnail or source URL. $valid_images = array(); $logger = wc_get_logger(); foreach ( $filtered_images as $image ) { // If id is not set then something is wrong with the image, and further logging would break (it uses the ID). if ( ! isset( $image->id ) ) { $logger->warning( 'After passing through woocommerce_cart_item_images filter, one of the images did not have an id property.' ); continue; } // Check if thumbnail is a valid url. if ( empty( $image->thumbnail ) || ! filter_var( $image->thumbnail, FILTER_VALIDATE_URL ) ) { $logger->warning( sprintf( 'After passing through woocommerce_cart_item_images filter, image with id %s did not have a valid thumbnail property.', $image->id ) ); continue; } // Check if src is a valid url. if ( empty( $image->src ) || ! filter_var( $image->src, FILTER_VALIDATE_URL ) ) { $logger->warning( sprintf( 'After passing through woocommerce_cart_item_images filter, image with id %s did not have a valid src property.', $image->id ) ); continue; } // Image is valid, add to resulting array. $valid_images[] = $image; } // If there are no valid images remaining, return original array. if ( count( $valid_images ) === 0 ) { return $product_images; } // Return the filtered images. return $valid_images; } /** * Format cart item data removing any HTML tag. * * @param array $cart_item Cart item array. * @return array */ protected function get_item_data( $cart_item ) { /** * Filters cart item data. * * Filters the variation option name for custom option slugs. * * @since 4.3.0 * * @internal Matches filter name in WooCommerce core. * * @param array $item_data Cart item data. Empty by default. * @param array $cart_item Cart item array. * @return array */ $item_data = apply_filters( 'woocommerce_get_item_data', array(), $cart_item ); $clean_item_data = []; foreach ( $item_data as $data ) { // We will check each piece of data in the item data element to ensure it is scalar. Extensions could add arrays // to this, which would cause a fatal in wp_strip_all_tags. If it is not scalar, we will return an empty array, // which will be filtered out in get_item_data (after this function has run). foreach ( $data as $data_value ) { if ( ! is_scalar( $data_value ) ) { continue 2; } } $clean_item_data[] = $this->format_item_data_element( $data ); } return $clean_item_data; } /** * Remove HTML tags from cart item data and set the `hidden` property to `__experimental_woocommerce_blocks_hidden`. * * @param array $item_data_element Individual element of a cart item data. * @return array */ protected function format_item_data_element( $item_data_element ) { if ( array_key_exists( '__experimental_woocommerce_blocks_hidden', $item_data_element ) ) { $item_data_element['hidden'] = $item_data_element['__experimental_woocommerce_blocks_hidden']; } return array_map( 'wp_kses_post', $item_data_element ); } } Schemas/V1/TermSchema.php 0000777 00000004235 15251730534 0011176 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * TermSchema class. */ class TermSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'term'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'term'; /** * Term properties. * * @return array */ public function get_properties() { return [ 'id' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Term name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'slug' => array( 'description' => __( 'String based identifier for the term.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'description' => array( 'description' => __( 'Term description.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'parent' => array( 'description' => __( 'Parent term ID, if applicable.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'count' => array( 'description' => __( 'Number of objects (posts of any type) assigned to the term.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ]; } /** * Convert a term object into an object suitable for the response. * * @param \WP_Term $term Term object. * @return array */ public function get_item_response( $term ) { return [ 'id' => (int) $term->term_id, 'name' => $this->prepare_html_response( $term->name ), 'slug' => $term->slug, 'description' => $this->prepare_html_response( $term->description ), 'parent' => (int) $term->parent, 'count' => (int) $term->count, ]; } } Schemas/V1/CartShippingRateSchema.php 0000777 00000026572 15251730534 0013506 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use WC_Shipping_Rate as ShippingRate; /** * CartShippingRateSchema class. */ class CartShippingRateSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'cart-shipping-rate'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'cart-shipping-rate'; /** * Cart schema properties. * * @return array */ public function get_properties() { return [ 'package_id' => [ 'description' => __( 'The ID of the package the shipping rates belong to.', 'woocommerce' ), 'type' => [ 'integer', 'string' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Name of the package.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'destination' => [ 'description' => __( 'Shipping destination address.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => [ 'address_1' => [ 'description' => __( 'First line of the address being shipped to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'address_2' => [ 'description' => __( 'Second line of the address being shipped to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'city' => [ 'description' => __( 'City of the address being shipped to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'state' => [ 'description' => __( 'ISO code, or name, for the state, province, or district of the address being shipped to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'postcode' => [ 'description' => __( 'Zip or Postcode of the address being shipped to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'country' => [ 'description' => __( 'ISO code for the country of the address being shipped to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], 'items' => [ 'description' => __( 'List of cart items the returned shipping rates apply to.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'key' => [ 'description' => __( 'Unique identifier for the item within the cart.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Name of the item.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'quantity' => [ 'description' => __( 'Quantity of the item in the current package.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'shipping_rates' => [ 'description' => __( 'List of shipping rates.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->get_rate_properties(), ], ], ]; } /** * Schema for a single rate. * * @return array */ protected function get_rate_properties() { return array_merge( [ 'rate_id' => [ 'description' => __( 'ID of the shipping rate.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Name of the shipping rate, e.g. Express shipping.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'description' => [ 'description' => __( 'Description of the shipping rate, e.g. Dispatched via USPS.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'delivery_time' => [ 'description' => __( 'Delivery time estimate text, e.g. 3-5 business days.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'price' => [ 'description' => __( 'Price of this shipping rate using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'taxes' => [ 'description' => __( 'Taxes applied to this shipping rate using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'method_id' => [ 'description' => __( 'ID of the shipping method that provided the rate.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'instance_id' => [ 'description' => __( 'Instance ID of the shipping method that provided the rate.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'meta_data' => [ 'description' => __( 'Meta data attached to the shipping rate.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'key' => [ 'description' => __( 'Meta key.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'value' => [ 'description' => __( 'Meta value.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'selected' => [ 'description' => __( 'True if this is the rate currently selected by the customer for the cart.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], $this->get_store_currency_properties() ); } /** * Convert a shipping rate from WooCommerce into a valid response. * * @param array $package Shipping package complete with rates from WooCommerce. * @return array */ public function get_item_response( $package ) { return [ 'package_id' => $package['package_id'], 'name' => $package['package_name'], 'destination' => $this->prepare_package_destination_response( $package ), 'items' => $this->prepare_package_items_response( $package ), 'shipping_rates' => $this->prepare_package_shipping_rates_response( $package ), ]; } /** * Gets and formats the destination address of a package. * * @param array $package Shipping package complete with rates from WooCommerce. * @return object */ protected function prepare_package_destination_response( $package ) { // If address_1 fails check address for back compatability. $address = isset( $package['destination']['address_1'] ) ? $package['destination']['address_1'] : $package['destination']['address']; return (object) $this->prepare_html_response( [ 'address_1' => $address, 'address_2' => $package['destination']['address_2'], 'city' => $package['destination']['city'], 'state' => $package['destination']['state'], 'postcode' => $package['destination']['postcode'], 'country' => $package['destination']['country'], ] ); } /** * Gets items from a package and creates an array of strings containing product names and quantities. * * @param array $package Shipping package complete with rates from WooCommerce. * @return array */ protected function prepare_package_items_response( $package ) { $items = array(); foreach ( $package['contents'] as $values ) { $items[] = [ 'key' => $values['key'], 'name' => $values['data']->get_name(), 'quantity' => $values['quantity'], ]; } return $items; } /** * Prepare an array of rates from a package for the response. * * @param array $package Shipping package complete with rates from WooCommerce. * @return array */ protected function prepare_package_shipping_rates_response( $package ) { $rates = $package['rates']; $selected_rates = wc()->session->get( 'chosen_shipping_methods', array() ); $selected_rate = isset( $selected_rates[ $package['package_id'] ] ) ? $selected_rates[ $package['package_id'] ] : ''; if ( empty( $selected_rate ) && ! empty( $package['rates'] ) ) { $selected_rate = wc_get_chosen_shipping_method_for_package( $package['package_id'], $package ); } $response = []; foreach ( $package['rates'] as $rate ) { $response[] = $this->get_rate_response( $rate, $selected_rate ); } return $response; } /** * Response for a single rate. * * @param WC_Shipping_Rate $rate Rate object. * @param string $selected_rate Selected rate. * @return array */ protected function get_rate_response( $rate, $selected_rate = '' ) { return $this->prepare_currency_response( [ 'rate_id' => $this->get_rate_prop( $rate, 'id' ), 'name' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'label' ) ), 'description' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'description' ) ), 'delivery_time' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'delivery_time' ) ), 'price' => $this->prepare_money_response( $this->get_rate_prop( $rate, 'cost' ), wc_get_price_decimals() ), 'taxes' => $this->prepare_money_response( array_sum( (array) $this->get_rate_prop( $rate, 'taxes' ) ), wc_get_price_decimals() ), 'instance_id' => $this->get_rate_prop( $rate, 'instance_id' ), 'method_id' => $this->get_rate_prop( $rate, 'method_id' ), 'meta_data' => $this->get_rate_meta_data( $rate ), 'selected' => $selected_rate === $this->get_rate_prop( $rate, 'id' ), ] ); } /** * Gets a prop of the rate object, if callable. * * @param WC_Shipping_Rate $rate Rate object. * @param string $prop Prop name. * @return string */ protected function get_rate_prop( $rate, $prop ) { $getter = 'get_' . $prop; return \is_callable( array( $rate, $getter ) ) ? $rate->$getter() : ''; } /** * Converts rate meta data into a suitable response object. * * @param WC_Shipping_Rate $rate Rate object. * @return array */ protected function get_rate_meta_data( $rate ) { $meta_data = $rate->get_meta_data(); return array_reduce( array_keys( $meta_data ), function( $return, $key ) use ( $meta_data ) { $return[] = [ 'key' => $key, 'value' => $meta_data[ $key ], ]; return $return; }, [] ); } } Schemas/V1/ProductSchema.php 0000777 00000101453 15251730534 0011707 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\Enums\ProductType; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits; use Automattic\WooCommerce\Blocks\Utils\ProductAvailabilityUtils; use Automattic\WooCommerce\Enums\ProductStockStatus; /** * ProductSchema class. */ class ProductSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'product'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'product'; /** * Image attachment schema instance. * * @var ImageAttachmentSchema */ protected $image_attachment_schema; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER ); } /** * Product schema properties. * * @return array */ public function get_properties() { return [ 'id' => [ 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Product name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'slug' => [ 'description' => __( 'Product slug.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'parent' => [ 'description' => __( 'ID of the parent product, if applicable.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'type' => [ 'description' => __( 'Product type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'variation' => [ 'description' => __( 'Product variation attributes, if applicable.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'permalink' => [ 'description' => __( 'Product URL.', 'woocommerce' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'short_description' => [ 'description' => __( 'Product short description in HTML format.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'description' => [ 'description' => __( 'Product full description in HTML format.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'on_sale' => [ 'description' => __( 'Is the product on sale?', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'sku' => [ 'description' => __( 'Unique identifier.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'prices' => [ 'description' => __( 'Price data provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'price' => [ 'description' => __( 'Current product price.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'regular_price' => [ 'description' => __( 'Regular product price.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'sale_price' => [ 'description' => __( 'Sale product price, if applicable.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'price_range' => [ 'description' => __( 'Price range, if applicable.', 'woocommerce' ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => [ 'min_amount' => [ 'description' => __( 'Price amount.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'max_amount' => [ 'description' => __( 'Price amount.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ] ), ], 'price_html' => array( 'description' => __( 'Price string formatted as HTML.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'average_rating' => [ 'description' => __( 'Reviews average rating.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'review_count' => [ 'description' => __( 'Amount of reviews that the product has.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'images' => [ 'description' => __( 'List of images.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => $this->image_attachment_schema->get_properties(), ], ], 'categories' => [ 'description' => __( 'List of categories, if applicable.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'Category ID', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Category name', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'slug' => [ 'description' => __( 'Category slug', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'link' => [ 'description' => __( 'Category link', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'tags' => [ 'description' => __( 'List of tags, if applicable.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'Tag ID', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Tag name', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'slug' => [ 'description' => __( 'Tag slug', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'link' => [ 'description' => __( 'Tag link.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'brands' => [ 'description' => __( 'List of brands, if applicable.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'Brand ID', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Brand name', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'slug' => [ 'description' => __( 'Brand slug', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'link' => [ 'description' => __( 'Brand link', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'attributes' => [ 'description' => __( 'List of attributes (taxonomy terms) assigned to the product. For variable products, these are mapped to variations (see the `variations` field).', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'The attribute ID, or 0 if the attribute is not taxonomy based.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'The attribute name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'taxonomy' => [ 'description' => __( 'The attribute taxonomy, or null if the attribute is not taxonomy based.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'has_variations' => [ 'description' => __( 'True if this attribute is used by product variations.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'terms' => [ 'description' => __( 'List of assigned attribute terms.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'The term ID, or 0 if the attribute is not a global attribute.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'The term name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'slug' => [ 'description' => __( 'The term slug.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'default' => [ 'description' => __( 'If this is a default attribute', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], ], ], ], 'variations' => [ 'description' => __( 'List of variation IDs, if applicable.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'The attribute ID, or 0 if the attribute is not taxonomy based.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'attributes' => [ 'description' => __( 'List of variation attributes.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'name' => [ 'description' => __( 'The attribute name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'value' => [ 'description' => __( 'The assigned attribute.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], ], ], ], 'grouped_products' => [ 'description' => __( 'List of grouped product IDs, if applicable.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'description' => __( 'List of grouped product ids.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], 'has_options' => [ 'description' => __( 'Does the product have additional options before it can be added to the cart?', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'is_purchasable' => [ 'description' => __( 'Is the product purchasable?', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'is_in_stock' => [ 'description' => __( 'Is the product in stock?', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'is_on_backorder' => [ 'description' => __( 'Is the product stock backordered? This will also return false if backorder notifications are turned off.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'stock_availability' => [ 'description' => __( 'Information about the product\'s availability.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => [ 'text' => [ 'description' => __( 'Stock availability text.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'class' => [ 'description' => __( 'Stock availability class.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], 'low_stock_remaining' => [ 'description' => __( 'Quantity left in stock if stock is low, or null if not applicable.', 'woocommerce' ), 'type' => [ 'number', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'sold_individually' => [ 'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'add_to_cart' => [ 'description' => __( 'Add to cart button parameters.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => [ 'text' => [ 'description' => __( 'Button text.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'description' => [ 'description' => __( 'Button description.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'url' => [ 'description' => __( 'Add to cart URL.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'minimum' => [ 'description' => __( 'The minimum quantity that can be added to the cart.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'maximum' => [ 'description' => __( 'The maximum quantity that can be added to the cart.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'multiple_of' => [ 'description' => __( 'The amount that quantities increment by. Quantity must be an multiple of this value.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'default' => 1, ], 'single_text' => [ 'description' => __( 'Button text in the single product page.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ), ]; } /** * Convert a WooCommerce product into an object suitable for the response. * * @param \WC_Product $product Product instance. * @return array */ public function get_item_response( $product ) { $availability = ProductAvailabilityUtils::get_product_availability( $product ); return [ 'id' => $product->get_id(), 'name' => $this->prepare_html_response( $product->get_title() ), 'slug' => $product->get_slug(), 'parent' => $product->get_parent_id(), 'type' => $product->get_type(), 'variation' => $this->prepare_html_response( $product->is_type( ProductType::VARIATION ) ? wc_get_formatted_variation( $product, true, true, false ) : '' ), 'permalink' => $product->get_permalink(), 'sku' => $this->prepare_html_response( $product->get_sku() ), 'short_description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) ), 'description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) ), 'on_sale' => $product->is_on_sale(), 'prices' => (object) $this->prepare_product_price_response( $product ), 'price_html' => $this->prepare_html_response( $product->get_price_html() ), 'average_rating' => (string) $product->get_average_rating(), 'review_count' => $product->get_review_count(), 'images' => $this->get_images( $product ), 'categories' => $this->get_term_list( $product, 'product_cat' ), 'tags' => $this->get_term_list( $product, 'product_tag' ), 'brands' => $this->get_term_list( $product, 'product_brand' ), 'attributes' => $this->get_attributes( $product ), 'variations' => $this->get_variations( $product ), 'grouped_products' => $this->get_grouped_products( $product ), 'has_options' => $product->has_options(), 'is_purchasable' => $product->is_purchasable(), 'is_in_stock' => $product->is_in_stock(), 'is_on_backorder' => ProductStockStatus::ON_BACKORDER === $product->get_stock_status(), 'low_stock_remaining' => $this->get_low_stock_remaining( $product ), 'stock_availability' => (object) array( 'text' => $availability['availability'] ?? '', 'class' => $availability['class'] ?? '', ), 'sold_individually' => $product->is_sold_individually(), 'add_to_cart' => (object) array_merge( [ 'text' => $this->prepare_html_response( $product->add_to_cart_text() ), 'description' => $this->prepare_html_response( $product->add_to_cart_description() ), 'url' => $this->prepare_html_response( $product->add_to_cart_url() ), 'single_text' => $this->prepare_html_response( $product->single_add_to_cart_text() ), ], ( new QuantityLimits() )->get_add_to_cart_limits( $product ) ), self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER, $product ), ]; } /** * Get list of product images. * * @param \WC_Product $product Product instance. * @return array */ protected function get_images( \WC_Product $product ) { $attachment_ids = array_merge( [ $product->get_image_id() ], $product->get_gallery_image_ids() ); return array_values( array_filter( array_map( [ $this->image_attachment_schema, 'get_item_response' ], $attachment_ids ) ) ); } /** * Gets remaining stock amount for a product. * * @param \WC_Product $product Product instance. * @return int|float|null */ protected function get_remaining_stock( \WC_Product $product ) { if ( is_null( $product->get_stock_quantity() ) ) { return null; } return $product->get_stock_quantity(); } /** * If a product has low stock, return the remaining stock amount for display. * * @param \WC_Product $product Product instance. * @return int|float|null */ protected function get_low_stock_remaining( \WC_Product $product ) { $remaining_stock = $this->get_remaining_stock( $product ); $stock_format = get_option( 'woocommerce_stock_format' ); // Don't show the low stock badge if the settings doesn't allow it. if ( 'no_amount' === $stock_format ) { return null; } // Show the low stock badge if the remaining stock is below or equal to the threshold. if ( ! is_null( $remaining_stock ) && $remaining_stock <= wc_get_low_stock_amount( $product ) ) { return max( $remaining_stock, 0 ); } return null; } /** * Returns true if the given attribute is valid. * * @param mixed $attribute Object or variable to check. * @return boolean */ protected function filter_valid_attribute( $attribute ) { return is_a( $attribute, '\WC_Product_Attribute' ); } /** * Returns true if the given attribute is valid and used for variations. * * @param mixed $attribute Object or variable to check. * @return boolean */ protected function filter_variation_attribute( $attribute ) { return $this->filter_valid_attribute( $attribute ) && $attribute->get_variation(); } /** * Get variation IDs and attributes from the DB. * * @param \WC_Product $product Product instance. * @returns array */ protected function get_variations( \WC_Product $product ) { $variation_ids = $product->is_type( ProductType::VARIABLE ) ? $product->get_visible_children() : []; if ( ! count( $variation_ids ) ) { return []; } /** * Gets default variation data which applies to all of this products variations. */ $attributes = array_filter( $product->get_attributes(), [ $this, 'filter_variation_attribute' ] ); $default_variation_meta_data = array_reduce( $attributes, function ( $defaults, $attribute ) use ( $product ) { $meta_key = wc_variation_attribute_name( $attribute->get_name() ); $defaults[ $meta_key ] = [ 'name' => wc_attribute_label( $attribute->get_name(), $product ), 'value' => null, ]; return $defaults; }, [] ); $default_variation_meta_keys = array_keys( $default_variation_meta_data ); /** * Gets individual variation data from the database, using cache where possible. */ $cache_group = 'product_variation_meta_data'; $cache_value = wp_cache_get( $product->get_id(), $cache_group ); $last_modified = get_the_modified_date( 'U', $product->get_id() ); if ( false === $cache_value || $last_modified !== $cache_value['last_modified'] ) { global $wpdb; // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared $variation_meta_data = $wpdb->get_results( " SELECT post_id as variation_id, meta_key as attribute_key, meta_value as attribute_value FROM {$wpdb->postmeta} WHERE post_id IN (" . implode( ',', array_map( 'esc_sql', $variation_ids ) ) . ") AND meta_key IN ('" . implode( "','", array_map( 'esc_sql', $default_variation_meta_keys ) ) . "') " ); // phpcs:enable wp_cache_set( $product->get_id(), [ 'last_modified' => $last_modified, 'data' => $variation_meta_data, ], $cache_group ); } else { $variation_meta_data = $cache_value['data']; } /** * Merges and formats default variation data with individual variation data. */ $attributes_by_variation = array_reduce( $variation_meta_data, function ( $values, $data ) use ( $default_variation_meta_keys ) { // The query above only includes the keys of $default_variation_meta_data so we know all of the attributes // being processed here apply to this product. However, we need an additional check here because the // cache may have been primed elsewhere and include keys from other products. // @see AbstractProductGrid::prime_product_variations. if ( in_array( $data->attribute_key, $default_variation_meta_keys, true ) ) { $values[ $data->variation_id ][ $data->attribute_key ] = $data->attribute_value; } return $values; }, array_fill_keys( $variation_ids, [] ) ); $variations = []; foreach ( $variation_ids as $variation_id ) { $attribute_data = $default_variation_meta_data; foreach ( $attributes_by_variation[ $variation_id ] as $meta_key => $meta_value ) { if ( '' !== $meta_value ) { $attribute_data[ $meta_key ]['value'] = $meta_value; } } $variations[] = (object) [ 'id' => $variation_id, 'attributes' => array_values( $attribute_data ), ]; } return $variations; } /** * Get grouped product IDs. * * @param \WC_Product $product Product instance. * @return array */ protected function get_grouped_products( \WC_Product $product ) { if ( $product->is_type( ProductType::GROUPED ) ) { return array_map( function ( $child ) { return $child->get_id(); }, $product->get_visible_children(), ); } return []; } /** * Get list of product attributes and attribute terms. * * @param \WC_Product $product Product instance. * @return array */ protected function get_attributes( \WC_Product $product ) { $attributes = array_filter( $product->get_attributes(), [ $this, 'filter_valid_attribute' ] ); $default_attributes = $product->get_default_attributes(); $return = []; foreach ( $attributes as $attribute_slug => $attribute ) { // Only visible or variation attributes will be exposed by this API. if ( ! $attribute->get_visible() && ! $attribute->get_variation() ) { continue; } $terms = $attribute->is_taxonomy() ? array_map( [ $this, 'prepare_product_attribute_taxonomy_value' ], $attribute->get_terms() ) : array_map( [ $this, 'prepare_product_attribute_value' ], $attribute->get_options() ); // Custom attribute names are sanitized to be the array keys. // So when we do the array_key_exists check below we also need to sanitize the attribute names. $sanitized_attribute_name = sanitize_key( $attribute->get_name() ); if ( array_key_exists( $sanitized_attribute_name, $default_attributes ) ) { foreach ( $terms as $term ) { $term->default = $term->slug === $default_attributes[ $sanitized_attribute_name ]; } } $return[] = (object) [ 'id' => $attribute->get_id(), 'name' => wc_attribute_label( $attribute->get_name(), $product ), 'taxonomy' => $attribute->is_taxonomy() ? $attribute->get_name() : null, 'has_variations' => true === $attribute->get_variation(), 'terms' => $terms, ]; } return $return; } /** * Prepare an attribute term for the response. * * @param \WP_Term $term Term object. * @return object */ protected function prepare_product_attribute_taxonomy_value( \WP_Term $term ) { return $this->prepare_product_attribute_value( $term->name, $term->term_id, $term->slug ); } /** * Prepare an attribute term for the response. * * @param string $name Attribute term name. * @param int $id Attribute term ID. * @param string $slug Attribute term slug. * @return object */ protected function prepare_product_attribute_value( $name, $id = 0, $slug = '' ) { return (object) [ 'id' => (int) $id, 'name' => $name, 'slug' => $slug ? $slug : $name, ]; } /** * Get an array of pricing data. * * @param \WC_Product $product Product instance. * @param string $tax_display_mode If returned prices are incl or excl of tax. * @return array */ protected function prepare_product_price_response( \WC_Product $product, $tax_display_mode = '' ) { $prices = []; $tax_display_mode = $this->get_tax_display_mode( $tax_display_mode ); $price_function = $this->get_price_function_from_tax_display_mode( $tax_display_mode ); // If we have a variable product, get the price from the variations (this will use the min value). if ( $product->is_type( ProductType::VARIABLE ) ) { $regular_price = $product->get_variation_regular_price(); $sale_price = $product->get_variation_sale_price(); } else { $regular_price = $product->get_regular_price(); $sale_price = $product->get_sale_price(); } $prices['price'] = $this->prepare_money_response( $price_function( $product ), wc_get_price_decimals() ); $prices['regular_price'] = $this->prepare_money_response( $price_function( $product, [ 'price' => $regular_price ] ), wc_get_price_decimals() ); $prices['sale_price'] = $this->prepare_money_response( $price_function( $product, [ 'price' => $sale_price ] ), wc_get_price_decimals() ); $prices['price_range'] = $this->get_price_range( $product, $tax_display_mode ); return $this->prepare_currency_response( $prices ); } /** * WooCommerce can return prices including or excluding tax; choose the correct method based on tax display mode. * * @param string $tax_display_mode Provided tax display mode. * @return string Valid tax display mode. */ protected function get_tax_display_mode( $tax_display_mode = '' ) { return in_array( $tax_display_mode, [ 'incl', 'excl' ], true ) ? $tax_display_mode : get_option( 'woocommerce_tax_display_shop' ); } /** * WooCommerce can return prices including or excluding tax; choose the correct method based on tax display mode. * * @param string $tax_display_mode If returned prices are incl or excl of tax. * @return string Function name. */ protected function get_price_function_from_tax_display_mode( $tax_display_mode ) { return 'incl' === $tax_display_mode ? 'wc_get_price_including_tax' : 'wc_get_price_excluding_tax'; } /** * Get price range from certain product types. * * @param \WC_Product $product Product instance. * @param string $tax_display_mode If returned prices are incl or excl of tax. * @return object|null */ protected function get_price_range( \WC_Product $product, $tax_display_mode = '' ) { $tax_display_mode = $this->get_tax_display_mode( $tax_display_mode ); if ( $product->is_type( ProductType::VARIABLE ) ) { $prices = $product->get_variation_prices( true ); if ( ! empty( $prices['price'] ) && ( min( $prices['price'] ) !== max( $prices['price'] ) ) ) { return (object) [ 'min_amount' => $this->prepare_money_response( min( $prices['price'] ), wc_get_price_decimals() ), 'max_amount' => $this->prepare_money_response( max( $prices['price'] ), wc_get_price_decimals() ), ]; } } if ( $product->is_type( ProductType::GROUPED ) ) { $children = $product->get_visible_children(); $price_function = 'incl' === $tax_display_mode ? 'wc_get_price_including_tax' : 'wc_get_price_excluding_tax'; foreach ( $children as $child ) { if ( '' !== $child->get_price() ) { $child_prices[] = $price_function( $child ); } } if ( ! empty( $child_prices ) ) { return (object) [ 'min_amount' => $this->prepare_money_response( min( $child_prices ), wc_get_price_decimals() ), 'max_amount' => $this->prepare_money_response( max( $child_prices ), wc_get_price_decimals() ), ]; } } return null; } /** * Returns a list of terms assigned to the product. * * @param \WC_Product $product Product object. * @param string $taxonomy Taxonomy name. * @return array Array of terms (id, name, slug). */ protected function get_term_list( \WC_Product $product, $taxonomy = '' ) { if ( ! $taxonomy ) { return []; } $terms = get_the_terms( $product->get_id(), $taxonomy ); if ( ! $terms || is_wp_error( $terms ) ) { return []; } $return = []; $default_category = (int) get_option( 'default_product_cat', 0 ); foreach ( $terms as $term ) { $link = get_term_link( $term, $taxonomy ); if ( is_wp_error( $link ) ) { continue; } if ( $term->term_id === $default_category ) { continue; } $return[] = (object) [ 'id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'link' => $link, ]; } return $return; } } Schemas/V1/ProductBrandSchema.php 0000777 00000006767 15251730534 0012672 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; /** * ProductBrandSchema class. */ class ProductBrandSchema extends TermSchema { /** * The schema item name. * * @var string */ protected $title = 'product-brand'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'product-brand'; /** * Image attachment schema instance. * * @var ImageAttachmentSchema */ protected $image_attachment_schema; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER ); } /** * Term properties. * * @return array */ public function get_properties() { $schema = parent::get_properties(); $schema['image'] = [ 'description' => __( 'Brand image.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, 'properties' => $this->image_attachment_schema->get_properties(), ]; $schema['review_count'] = [ 'description' => __( 'Number of reviews for products of this brand.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ]; $schema['permalink'] = [ 'description' => __( 'Brand URL.', 'woocommerce' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ]; return $schema; } /** * Convert a term object into an object suitable for the response. * * @param \WP_Term $term Term object. * @return array */ public function get_item_response( $term ) { $response = parent::get_item_response( $term ); $count = get_term_meta( $term->term_id, 'product_count_product_brand', true ); if ( $count ) { $response['count'] = (int) $count; } $response['image'] = $this->image_attachment_schema->get_item_response( get_term_meta( $term->term_id, 'thumbnail_id', true ) ); $response['review_count'] = $this->get_brand_review_count( $term ); $response['permalink'] = get_term_link( $term->term_id, 'product_brand' ); return $response; } /** * Get total number of reviews for products of a brand. * * @param \WP_Term $term Term object. * @return int */ protected function get_brand_review_count( $term ) { global $wpdb; $children = get_term_children( $term->term_id, 'product_brand' ); if ( ! $children || is_wp_error( $children ) ) { $terms_to_count_str = absint( $term->term_id ); } else { $terms_to_count = array_unique( array_map( 'absint', array_merge( array( $term->term_id ), $children ) ) ); $terms_to_count_str = implode( ',', $terms_to_count ); } $products_of_brand_sql = " SELECT SUM(comment_count) as review_count FROM {$wpdb->posts} AS posts INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = term_relationships.object_id WHERE term_relationships.term_taxonomy_id IN (" . esc_sql( $terms_to_count_str ) . ') '; $review_count = $wpdb->get_var( $products_of_brand_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared return (int) $review_count; } } Schemas/V1/ProductAttributeSchema.php 0000777 00000004773 15251730534 0013602 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * ProductAttributeSchema class. */ class ProductAttributeSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'product_attribute'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'product-attribute'; /** * Term properties. * * @return array */ public function get_properties() { return [ 'id' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Attribute name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'taxonomy' => array( 'description' => __( 'The attribute taxonomy name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'type' => array( 'description' => __( 'Attribute type.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'order' => array( 'description' => __( 'How terms in this attribute are sorted by default.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'has_archives' => array( 'description' => __( 'If this attribute has term archive pages.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'count' => array( 'description' => __( 'Number of terms in the attribute taxonomy.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ]; } /** * Convert an attribute object into an object suitable for the response. * * @param object $attribute Attribute object. * @return array */ public function get_item_response( $attribute ) { return [ 'id' => (int) $attribute->id, 'name' => $this->prepare_html_response( $attribute->name ), 'taxonomy' => $attribute->slug, 'type' => $attribute->type, 'order' => $attribute->order_by, 'has_archives' => $attribute->has_archives, 'count' => (int) \wp_count_terms( $attribute->slug ), ]; } } Schemas/V1/CartCouponSchema.php 0000777 00000005747 15251730534 0012355 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Utilities\CartController; /** * CartCouponSchema class. */ class CartCouponSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'cart_coupon'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'cart-coupon'; /** * Cart schema properties. * * @return array */ public function get_properties() { return [ 'code' => [ 'description' => __( 'The coupon\'s unique code.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'arg_options' => [ 'sanitize_callback' => 'wc_format_coupon_code', 'validate_callback' => [ $this, 'coupon_exists' ], ], ], 'discount_type' => [ 'description' => __( 'The discount type for the coupon (e.g. percentage or fixed amount)', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'arg_options' => [ 'validate_callback' => [ $this, 'coupon_exists' ], ], ], 'totals' => [ 'description' => __( 'Total amounts provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'total_discount' => [ 'description' => __( 'Total discount applied by this coupon.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_discount_tax' => [ 'description' => __( 'Total tax removed due to discount applied by this coupon.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ] ), ], ]; } /** * Check given coupon exists. * * @param string $coupon_code Coupon code. * @return bool */ public function coupon_exists( $coupon_code ) { $coupon = new \WC_Coupon( $coupon_code ); return (bool) $coupon->get_id() || $coupon->get_virtual(); } /** * Generate a response from passed coupon code. * * @param string $coupon_code Coupon code from the cart. * @return array */ public function get_item_response( $coupon_code ) { $controller = new CartController(); $cart = $controller->get_cart_instance(); $coupon = new \WC_Coupon( $coupon_code ); return [ 'code' => $coupon->get_code(), 'discount_type' => $coupon->get_discount_type(), 'totals' => (object) $this->prepare_currency_response( [ 'total_discount' => $this->prepare_money_response( $cart->get_coupon_discount_amount( $coupon_code ), wc_get_price_decimals() ), 'total_discount_tax' => $this->prepare_money_response( $cart->get_coupon_discount_tax_amount( $coupon_code ), wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ), ] ), ]; } } Schemas/V1/ItemSchema.php 0000777 00000026422 15251730534 0011167 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * ItemSchema class. */ abstract class ItemSchema extends ProductSchema { /** * Item schema properties. * * @return array */ public function get_properties() { return [ 'key' => [ 'description' => __( 'Unique identifier for the item.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'type' => [ 'description' => __( 'The item type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'id' => [ 'description' => __( 'The item product or variation ID.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'quantity' => [ 'description' => __( 'Quantity of this item.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'quantity_limits' => [ 'description' => __( 'How the quantity of this item should be controlled, for example, any limits in place.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => [ 'minimum' => [ 'description' => __( 'The minimum quantity allowed for this line item.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'maximum' => [ 'description' => __( 'The maximum quantity allowed for this line item.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'multiple_of' => [ 'description' => __( 'The amount that quantities increment by. Quantity must be an multiple of this value.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'default' => 1, ], 'editable' => [ 'description' => __( 'If the quantity is editable or fixed.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'default' => true, ], ], ], 'name' => [ 'description' => __( 'Product name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'short_description' => [ 'description' => __( 'Product short description in HTML format.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'description' => [ 'description' => __( 'Product full description in HTML format.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'sku' => [ 'description' => __( 'Stock keeping unit, if applicable.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'low_stock_remaining' => [ 'description' => __( 'Quantity left in stock if stock is low, or null if not applicable.', 'woocommerce' ), 'type' => [ 'number', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'backorders_allowed' => [ 'description' => __( 'True if backorders are allowed past stock availability.', 'woocommerce' ), 'type' => [ 'boolean' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'show_backorder_badge' => [ 'description' => __( 'True if the product is on backorder.', 'woocommerce' ), 'type' => [ 'boolean' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'sold_individually' => [ 'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'permalink' => [ 'description' => __( 'Product URL.', 'woocommerce' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'images' => [ 'description' => __( 'List of images.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->image_attachment_schema->get_properties(), ], ], 'variation' => [ 'description' => __( 'Chosen attributes (for variations).', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'raw_attribute' => [ 'description' => __( 'Variation system generated attribute name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ], 'attribute' => [ 'description' => __( 'Variation attribute name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'value' => [ 'description' => __( 'Variation attribute value.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'item_data' => [ 'description' => __( 'Metadata related to the item', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'name' => [ 'description' => __( 'Name of the metadata.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'value' => [ 'description' => __( 'Value of the metadata.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'display' => [ 'description' => __( 'Optionally, how the metadata value should be displayed to the user.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'prices' => [ 'description' => __( 'Price data for the product in the current line item, including or excluding taxes based on the "display prices during cart and checkout" setting. Provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'price' => [ 'description' => __( 'Current product price.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'regular_price' => [ 'description' => __( 'Regular product price.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'sale_price' => [ 'description' => __( 'Sale product price, if applicable.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'price_range' => [ 'description' => __( 'Price range, if applicable.', 'woocommerce' ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => [ 'min_amount' => [ 'description' => __( 'Price amount.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'max_amount' => [ 'description' => __( 'Price amount.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], 'raw_prices' => [ 'description' => __( 'Raw unrounded product prices used in calculations. Provided using a higher unit of precision than the currency.', 'woocommerce' ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => [ 'precision' => [ 'description' => __( 'Decimal precision of the returned prices.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'price' => [ 'description' => __( 'Current product price.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'regular_price' => [ 'description' => __( 'Regular product price.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'sale_price' => [ 'description' => __( 'Sale product price, if applicable.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ] ), ], 'totals' => [ 'description' => __( 'Item total amounts provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'line_subtotal' => [ 'description' => __( 'Line subtotal (the price of the product before coupon discounts have been applied).', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'line_subtotal_tax' => [ 'description' => __( 'Line subtotal tax.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'line_total' => [ 'description' => __( 'Line total (the price of the product after coupon discounts have been applied).', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'line_total_tax' => [ 'description' => __( 'Line total tax.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ] ), ], 'catalog_visibility' => [ 'description' => __( 'Whether the product is visible in the catalog', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ), ]; } } Schemas/V1/ShippingAddressSchema.php 0000777 00000005532 15251730534 0013357 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils; /** * ShippingAddressSchema class. * * Provides a generic shipping address schema for composition in other schemas. */ class ShippingAddressSchema extends AbstractAddressSchema { /** * The schema item name. * * @var string */ protected $title = 'shipping_address'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'shipping-address'; /** * Convert a term object into an object suitable for the response. * * @param \WC_Order|\WC_Customer $address An object with shipping address. * * @throws RouteException When the invalid object types are provided. * @return array */ public function get_item_response( $address ) { $validation_util = new ValidationUtils(); if ( ( $address instanceof \WC_Customer || $address instanceof \WC_Order ) ) { $shipping_country = $address->get_shipping_country(); $shipping_state = $address->get_shipping_state(); if ( ! $validation_util->validate_state( $shipping_state, $shipping_country ) ) { $shipping_state = ''; } $additional_address_fields = $this->additional_fields_controller->get_all_fields_from_object( $address, 'shipping' ); $address_object = array_merge( [ 'first_name' => $address->get_shipping_first_name(), 'last_name' => $address->get_shipping_last_name(), 'company' => $address->get_shipping_company(), 'address_1' => $address->get_shipping_address_1(), 'address_2' => $address->get_shipping_address_2(), 'city' => $address->get_shipping_city(), 'state' => $shipping_state, 'postcode' => $address->get_shipping_postcode(), 'country' => $shipping_country, 'phone' => $address->get_shipping_phone(), ], $additional_address_fields ); // Add any missing keys from additional_fields_controller to the address response. foreach ( $this->additional_fields_controller->get_address_fields_keys() as $field ) { if ( isset( $address_object[ $field ] ) ) { continue; } $address_object[ $field ] = ''; } foreach ( $address_object as $key => $value ) { if ( isset( $this->get_properties()[ $key ]['type'] ) && 'boolean' === $this->get_properties()[ $key ]['type'] ) { $address_object[ $key ] = (bool) $value; } else { $address_object[ $key ] = $this->prepare_html_response( $value ); } } return $address_object; } throw new RouteException( 'invalid_object_type', sprintf( /* translators: Placeholders are class and method names */ __( '%1$s requires an instance of %2$s or %3$s for the address', 'woocommerce' ), 'ShippingAddressSchema::get_item_response', 'WC_Customer', 'WC_Order' ), 500 ); } } Schemas/V1/CheckoutOrderSchema.php 0000777 00000001360 15251730534 0013024 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; /** * CheckoutOrderSchema class. */ class CheckoutOrderSchema extends CheckoutSchema { /** * The schema item name. * * @var string */ protected $title = 'checkout-order'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'checkout-order'; /** * Checkout schema properties. * * @return array */ public function get_properties() { $parent_properties = parent::get_properties(); unset( $parent_properties['create_account'] ); return $parent_properties; } } Schemas/V1/OrderFeeSchema.php 0000777 00000004315 15251730534 0011761 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * OrderFeeSchema class. */ class OrderFeeSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'order_fee'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'order-fee'; /** * Cart schema properties. * * @return array */ public function get_properties() { return [ 'id' => [ 'description' => __( 'Unique identifier for the fee within the cart', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Fee name', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'totals' => [ 'description' => __( 'Fee total amounts provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'total' => [ 'description' => __( 'Total amount for this fee.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_tax' => [ 'description' => __( 'Total tax amount for this fee.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ] ), ], ]; } /** * Convert a WooCommerce cart fee to an object suitable for the response. * * @param \WC_Order_Item_Fee $fee Order fee object. * @return array */ public function get_item_response( $fee ) { if ( ! $fee ) { return []; } return [ 'key' => $fee->get_id(), 'name' => $this->prepare_html_response( $fee->get_name() ), 'totals' => (object) $this->prepare_currency_response( [ 'total' => $this->prepare_money_response( $fee->get_total(), wc_get_price_decimals() ), 'total_tax' => $this->prepare_money_response( $fee->get_total_tax(), wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ), ] ), ]; } } Schemas/V1/CartSchema.php 0000777 00000037513 15251730534 0011165 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Utilities\CartController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use WC_Tax; /** * CartSchema class. */ class CartSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'cart'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'cart'; /** * Item schema instance. * * @var CartItemSchema */ public $item_schema; /** * Coupon schema instance. * * @var CartCouponSchema */ public $coupon_schema; /** * Product item schema instance representing cross-sell items. * * @var ProductSchema */ public $cross_sells_item_schema; /** * Fee schema instance. * * @var CartFeeSchema */ public $fee_schema; /** * Shipping rates schema instance. * * @var CartShippingRateSchema */ public $shipping_rate_schema; /** * Shipping address schema instance. * * @var ShippingAddressSchema */ public $shipping_address_schema; /** * Billing address schema instance. * * @var BillingAddressSchema */ public $billing_address_schema; /** * Error schema instance. * * @var ErrorSchema */ public $error_schema; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->item_schema = $this->controller->get( CartItemSchema::IDENTIFIER ); $this->cross_sells_item_schema = $this->controller->get( ProductSchema::IDENTIFIER ); $this->coupon_schema = $this->controller->get( CartCouponSchema::IDENTIFIER ); $this->fee_schema = $this->controller->get( CartFeeSchema::IDENTIFIER ); $this->shipping_rate_schema = $this->controller->get( CartShippingRateSchema::IDENTIFIER ); $this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER ); $this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER ); $this->error_schema = $this->controller->get( ErrorSchema::IDENTIFIER ); } /** * Cart schema properties. * * @return array */ public function get_properties() { return [ 'coupons' => [ 'description' => __( 'List of applied cart coupons.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->coupon_schema->get_properties() ), ], ], 'shipping_rates' => [ 'description' => __( 'List of available shipping rates for the cart.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->shipping_rate_schema->get_properties() ), ], ], 'shipping_address' => [ 'description' => __( 'Current set shipping address for the customer.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => $this->force_schema_readonly( $this->shipping_address_schema->get_properties() ), ], 'billing_address' => [ 'description' => __( 'Current set billing address for the customer.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => $this->force_schema_readonly( $this->billing_address_schema->get_properties() ), ], 'items' => [ 'description' => __( 'List of cart items.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->item_schema->get_properties() ), ], ], 'items_count' => [ 'description' => __( 'Number of items in the cart.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'items_weight' => [ 'description' => __( 'Total weight (in grams) of all products in the cart.', 'woocommerce' ), 'type' => 'number', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'cross_sells' => [ 'description' => __( 'List of cross-sells items related to cart items.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->cross_sells_item_schema->get_properties() ), ], ], 'needs_payment' => [ 'description' => __( 'True if the cart needs payment. False for carts with only free products and no shipping costs.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'needs_shipping' => [ 'description' => __( 'True if the cart needs shipping. False for carts with only digital goods or stores with no shipping methods set-up.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'has_calculated_shipping' => [ 'description' => __( 'True if the cart meets the criteria for showing shipping costs, and rates have been calculated and included in the totals.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'fees' => [ 'description' => __( 'List of cart fees.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->fee_schema->get_properties() ), ], ], 'totals' => [ 'description' => __( 'Cart total amounts provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'total_items' => [ 'description' => __( 'Total price of items in the cart.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_items_tax' => [ 'description' => __( 'Total tax on items in the cart.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_fees' => [ 'description' => __( 'Total price of any applied fees.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_fees_tax' => [ 'description' => __( 'Total tax on fees.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_discount' => [ 'description' => __( 'Total discount from applied coupons.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_discount_tax' => [ 'description' => __( 'Total tax removed due to discount from applied coupons.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_shipping' => [ 'description' => __( 'Total price of shipping. If shipping has not been calculated, a null response will be sent.', 'woocommerce' ), 'type' => [ 'string', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_shipping_tax' => [ 'description' => __( 'Total tax on shipping. If shipping has not been calculated, a null response will be sent.', 'woocommerce' ), 'type' => [ 'string', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_price' => [ 'description' => __( 'Total price the customer will pay.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_tax' => [ 'description' => __( 'Total tax applied to items and shipping.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'tax_lines' => [ 'description' => __( 'Lines of taxes applied to items and shipping.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'name' => [ 'description' => __( 'The name of the tax.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'price' => [ 'description' => __( 'The amount of tax charged.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'rate' => [ 'description' => __( 'The rate at which tax is applied.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], ] ), ], 'errors' => [ 'description' => __( 'List of cart item errors, for example, items in the cart which are out of stock.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->error_schema->get_properties() ), ], ], 'payment_methods' => [ 'description' => __( 'List of available payment method IDs that can be used to process the order.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'payment_requirements' => [ 'description' => __( 'List of required payment gateway features to process the order.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ), ]; } /** * Convert a woo cart into an object suitable for the response. * * @param \WC_Cart $cart Cart class instance. * @return array */ public function get_item_response( $cart ) { $controller = new CartController(); // Get cart errors first so if recalculations are performed, it's reflected in the response. $cart_errors = $this->get_cart_errors( $cart ); // Get shipping packages to return in the response from the cart. $shipping_packages = $cart->has_calculated_shipping() ? $controller->get_shipping_packages() : []; // Get visible cross sells products. $cross_sells = array_filter( array_map( 'wc_get_product', $cart->get_cross_sells() ), 'wc_products_array_filter_visible' ); return [ 'items' => $this->get_item_responses_from_schema( $this->item_schema, $cart->get_cart() ), 'coupons' => $this->get_item_responses_from_schema( $this->coupon_schema, $cart->get_applied_coupons() ), 'fees' => $this->get_item_responses_from_schema( $this->fee_schema, $cart->get_fees() ), 'totals' => (object) $this->prepare_currency_response( $this->get_totals( $cart ) ), 'shipping_address' => (object) $this->shipping_address_schema->get_item_response( wc()->customer ), 'billing_address' => (object) $this->billing_address_schema->get_item_response( wc()->customer ), 'needs_payment' => $cart->needs_payment(), 'needs_shipping' => $cart->needs_shipping(), 'payment_requirements' => $this->extend->get_payment_requirements(), 'has_calculated_shipping' => $cart->has_calculated_shipping(), 'shipping_rates' => $this->get_item_responses_from_schema( $this->shipping_rate_schema, $shipping_packages ), 'items_count' => $cart->get_cart_contents_count(), 'items_weight' => wc_get_weight( $cart->get_cart_contents_weight(), 'g' ), 'cross_sells' => $this->get_item_responses_from_schema( $this->cross_sells_item_schema, $cross_sells ), 'errors' => $cart_errors, 'payment_methods' => array_values( wp_list_pluck( WC()->payment_gateways->get_available_payment_gateways(), 'id' ) ), self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ), ]; } /** * Get total data. * * @param \WC_Cart $cart Cart class instance. * @return array */ protected function get_totals( $cart ) { $decimals = wc_get_price_decimals(); return [ 'total_items' => $this->prepare_money_response( $cart->get_subtotal(), $decimals ), 'total_items_tax' => $this->prepare_money_response( $cart->get_subtotal_tax(), $decimals ), 'total_fees' => $this->prepare_money_response( $cart->get_fee_total(), $decimals ), 'total_fees_tax' => $this->prepare_money_response( $cart->get_fee_tax(), $decimals ), 'total_discount' => $this->prepare_money_response( $cart->get_discount_total(), $decimals ), 'total_discount_tax' => $this->prepare_money_response( $cart->get_discount_tax(), $decimals ), 'total_shipping' => $cart->has_calculated_shipping() ? $this->prepare_money_response( $cart->get_shipping_total(), $decimals ) : null, 'total_shipping_tax' => $cart->has_calculated_shipping() ? $this->prepare_money_response( $cart->get_shipping_tax(), $decimals ) : null, // Explicitly request context='edit'; default ('view') will render total as markup. 'total_price' => $this->prepare_money_response( $cart->get_total( 'edit' ), $decimals ), 'total_tax' => $this->prepare_money_response( $cart->get_total_tax(), $decimals ), 'tax_lines' => $this->get_tax_lines( $cart ), ]; } /** * Get tax lines from the cart and format to match schema. * * @param \WC_Cart $cart Cart class instance. * @return array */ protected function get_tax_lines( $cart ) { $tax_lines = []; if ( 'itemized' !== get_option( 'woocommerce_tax_total_display' ) ) { return $tax_lines; } $cart_tax_totals = $cart->get_tax_totals(); $decimals = wc_get_price_decimals(); foreach ( $cart_tax_totals as $cart_tax_total ) { $tax_lines[] = array( 'name' => $cart_tax_total->label, 'price' => $this->prepare_money_response( $cart_tax_total->amount, $decimals ), 'rate' => WC_Tax::get_rate_percent( $cart_tax_total->tax_rate_id ), ); } return $tax_lines; } /** * Get cart validation errors. * * @param \WC_Cart $cart Cart class instance. * @return array */ protected function get_cart_errors( $cart ) { $controller = new CartController(); $errors = $controller->get_cart_errors(); $cart_errors = []; foreach ( (array) $errors->errors as $code => $messages ) { foreach ( (array) $messages as $message ) { $cart_errors[] = new \WP_Error( $code, $message, $errors->get_error_data( $code ) ); } } return array_values( array_map( [ $this->error_schema, 'get_item_response' ], $cart_errors ) ); } } Schemas/V1/AbstractSchema.php 0000777 00000031527 15251730534 0012036 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; use Automattic\WooCommerce\Blocks\Package; /** * AbstractSchema class. * * For REST Route Schemas */ abstract class AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'Schema'; /** * Rest extend instance. * * @var ExtendSchema */ protected $extend; /** * Schema Controller instance. * * @var SchemaController */ protected $controller; /** * Extending key that gets added to endpoint. * * @var string */ const EXTENDING_KEY = 'extensions'; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { $this->extend = $extend; $this->controller = $controller; } /** * Returns the full item schema. * * @return array */ public function get_item_schema() { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => $this->title, 'type' => 'object', 'properties' => $this->get_properties(), ); } /** * Returns the full item response. * * @param mixed $item Item to get response for. * @return array|stdClass */ public function get_item_response( $item ) { return []; } /** * Return schema properties. * * @return array */ abstract public function get_properties(); /** * Recursive removal of arg_options. * * @param array $properties Schema properties. */ protected function remove_arg_options( $properties ) { return array_map( function( $property ) { if ( isset( $property['properties'] ) ) { $property['properties'] = $this->remove_arg_options( $property['properties'] ); } elseif ( isset( $property['items']['properties'] ) ) { $property['items']['properties'] = $this->remove_arg_options( $property['items']['properties'] ); } unset( $property['arg_options'] ); return $property; }, (array) $properties ); } /** * Returns the public schema. * * @return array */ public function get_public_item_schema() { $schema = $this->get_item_schema(); if ( isset( $schema['properties'] ) ) { $schema['properties'] = $this->remove_arg_options( $schema['properties'] ); } return $schema; } /** * Returns extended data for a specific endpoint. * * @param string $endpoint The endpoint identifier. * @param array ...$passed_args An array of arguments to be passed to callbacks. * @return object the data that will get added. */ protected function get_extended_data( $endpoint, ...$passed_args ) { return $this->extend->get_endpoint_data( $endpoint, $passed_args ); } /** * Gets an array of schema defaults recursively. * * @param array $properties Schema property data. * @return array Array of defaults, pulled from arg_options */ protected function get_recursive_schema_property_defaults( $properties ) { $defaults = []; foreach ( $properties as $property_key => $property_value ) { if ( isset( $property_value['arg_options']['default'] ) ) { $defaults[ $property_key ] = $property_value['arg_options']['default']; } elseif ( isset( $property_value['properties'] ) ) { $defaults[ $property_key ] = $this->get_recursive_schema_property_defaults( $property_value['properties'] ); } } return $defaults; } /** * Gets a function that validates recursively. * * @param array $properties Schema property data. * @return function Anonymous validation callback. */ protected function get_recursive_validate_callback( $properties ) { /** * Validate a request argument based on details registered to the route. * * @param mixed $values * @param \WP_REST_Request $request * @param string $param * @return true|\WP_Error */ return function ( $values, $request, $param ) use ( $properties ) { foreach ( $properties as $property_key => $property_value ) { $current_value = isset( $values[ $property_key ] ) ? $values[ $property_key ] : null; $property_type = is_array( $property_value['type'] ) ? $property_value['type'] : [ $property_value['type'] ]; if ( empty( $current_value ) && in_array( 'null', $property_type, true ) ) { // If the value is null and the schema allows null, we can skip validation for children. continue; } if ( isset( $property_value['arg_options']['validate_callback'] ) ) { $callback = $property_value['arg_options']['validate_callback']; $result = is_callable( $callback ) ? $callback( $current_value, $request, $param ) : false; } else { $result = rest_validate_value_from_schema( $current_value, $property_value, $param . ' > ' . $property_key ); } if ( ! $result || is_wp_error( $result ) ) { // If schema validation fails, we return here as we don't need to validate any deeper. return $result; } if ( isset( $property_value['properties'] ) ) { $validate_callback = $this->get_recursive_validate_callback( $property_value['properties'] ); $result = $validate_callback( $current_value, $request, $param . ' > ' . $property_key ); if ( ! $result || is_wp_error( $result ) ) { // If schema validation fails, we return here as we don't need to validate any deeper. return $result; } } } return true; }; } /** * Gets a function that sanitizes recursively. * * @param array $properties Schema property data. * @return function Anonymous validation callback. */ protected function get_recursive_sanitize_callback( $properties ) { /** * Validate a request argument based on details registered to the route. * * @param mixed $values * @param \WP_REST_Request $request * @param string $param * @return true|\WP_Error */ return function ( $values, $request, $param ) use ( $properties ) { $sanitized_values = []; foreach ( $properties as $property_key => $property_value ) { $current_value = isset( $values[ $property_key ] ) ? $values[ $property_key ] : null; if ( isset( $property_value['arg_options']['sanitize_callback'] ) ) { $callback = $property_value['arg_options']['sanitize_callback']; $current_value = is_callable( $callback ) ? $callback( $current_value, $request, $param ) : $current_value; } else { $current_value = rest_sanitize_value_from_schema( $current_value, $property_value, $param . ' > ' . $property_key ); } // If sanitization failed, return the WP_Error object straight away. if ( is_wp_error( $current_value ) ) { return $current_value; } if ( isset( $property_value['properties'] ) ) { $sanitize_callback = $this->get_recursive_sanitize_callback( $property_value['properties'] ); $sanitized_values[ $property_key ] = $sanitize_callback( $current_value, $request, $param . ' > ' . $property_key ); } else { $sanitized_values[ $property_key ] = $current_value; } } return $sanitized_values; }; } /** * Returns extended schema for a specific endpoint. * * @param string $endpoint The endpoint identifer. * @param array ...$passed_args An array of arguments to be passed to callbacks. * @return array the data that will get added. */ protected function get_extended_schema( $endpoint, ...$passed_args ) { $extended_schema = $this->extend->get_endpoint_schema( $endpoint, $passed_args ); $defaults = $this->get_recursive_schema_property_defaults( $extended_schema ); return [ 'type' => 'object', 'context' => [ 'view', 'edit' ], 'arg_options' => [ 'default' => $defaults, 'validate_callback' => $this->get_recursive_validate_callback( $extended_schema ), 'sanitize_callback' => $this->get_recursive_sanitize_callback( $extended_schema ), ], 'properties' => $extended_schema, ]; } /** * Apply a schema get_item_response callback to an array of items and return the result. * * @param AbstractSchema $schema Schema class instance. * @param array $items Array of items. * @return array Array of values from the callback function. */ protected function get_item_responses_from_schema( AbstractSchema $schema, $items ) { $items = array_filter( $items ); if ( empty( $items ) ) { return []; } return array_values( array_map( [ $schema, 'get_item_response' ], $items ) ); } /** * Retrieves an array of endpoint arguments from the item schema for the controller. * * @uses rest_get_endpoint_args_for_schema() * @param string $method Optional. HTTP method of the request. * @return array Endpoint arguments. */ public function get_endpoint_args_for_item_schema( $method = \WP_REST_Server::CREATABLE ) { $schema = $this->get_item_schema(); $endpoint_args = rest_get_endpoint_args_for_schema( $schema, $method ); $endpoint_args = $this->remove_arg_options( $endpoint_args ); return $endpoint_args; } /** * Force all schema properties to be readonly. * * @param array $properties Schema. * @return array Updated schema. */ protected function force_schema_readonly( $properties ) { return array_map( function( $property ) { $property['readonly'] = true; if ( isset( $property['items']['properties'] ) ) { $property['items']['properties'] = $this->force_schema_readonly( $property['items']['properties'] ); } return $property; }, (array) $properties ); } /** * Returns consistent currency schema used across endpoints for prices. * * @return array */ protected function get_store_currency_properties() { return [ 'currency_code' => [ 'description' => __( 'Currency code (in ISO format) for returned prices.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'currency_symbol' => [ 'description' => __( 'Currency symbol for the currency which can be used to format returned prices.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'currency_minor_unit' => [ 'description' => __( 'Currency minor unit (number of digits after the decimal separator) for returned prices.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'currency_decimal_separator' => array( 'description' => __( 'Decimal separator for the currency which can be used to format returned prices.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'currency_thousand_separator' => array( 'description' => __( 'Thousand separator for the currency which can be used to format returned prices.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'currency_prefix' => array( 'description' => __( 'Price prefix for the currency which can be used to format returned prices.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'currency_suffix' => array( 'description' => __( 'Price prefix for the currency which can be used to format returned prices.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ]; } /** * Adds currency data to an array of monetary values. * * @param array $values Monetary amounts. * @return array Monetary amounts with currency data appended. */ protected function prepare_currency_response( $values ) { return $this->extend->get_formatter( 'currency' )->format( $values ); } /** * Convert monetary values from WooCommerce to string based integers, using * the smallest unit of a currency. * * @param string|float $amount Monetary amount with decimals. * @param int $decimals Number of decimals the amount is formatted with. * @param int $rounding_mode Defaults to the PHP_ROUND_HALF_UP constant. * @return string The new amount. */ protected function prepare_money_response( $amount, $decimals = 2, $rounding_mode = PHP_ROUND_HALF_UP ) { return $this->extend->get_formatter( 'money' )->format( $amount, [ 'decimals' => $decimals, 'rounding_mode' => $rounding_mode, ] ); } /** * Prepares HTML based content, such as post titles and content, for the API response. * * @param string|array $response Data to format. * @return string|array Formatted data. */ protected function prepare_html_response( $response ) { return $this->extend->get_formatter( 'html' )->format( $response ); } } Schemas/V1/AbstractAddressSchema.php 0000777 00000025126 15251730534 0013342 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Utilities\SanitizationUtils; use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils; use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\Blocks\Package; /** * AddressSchema class. * * Provides a generic address schema for composition in other schemas. */ abstract class AbstractAddressSchema extends AbstractSchema { /** * Additional fields controller. * * @var CheckoutFields */ protected $additional_fields_controller; /** * Constructor. * * @param ExtendSchema $extend ExtendSchema instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->additional_fields_controller = Package::container()->get( CheckoutFields::class ); } /** * Term properties. * * @internal Note that required properties don't require values, just that they are included in the request. * @return array */ public function get_properties() { return array_merge( [ 'first_name' => [ 'description' => __( 'First name', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'last_name' => [ 'description' => __( 'Last name', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'company' => [ 'description' => __( 'Company', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'address_1' => [ 'description' => __( 'Address', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'address_2' => [ 'description' => __( 'Apartment, suite, etc.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'city' => [ 'description' => __( 'City', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'state' => [ 'description' => __( 'State/County code, or name of the state, county, province, or district.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'postcode' => [ 'description' => __( 'Postal code', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'country' => [ 'description' => __( 'Country/Region code in ISO 3166-1 alpha-2 format.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'phone' => [ 'description' => __( 'Phone', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], ], $this->get_additional_address_fields_schema(), ); } /** * Sanitize and format the given address object. * * @param array $address Value being sanitized. * @param \WP_REST_Request $request The Request. * @param string $param The param being sanitized. * @return array */ public function sanitize_callback( $address, $request, $param ) { $validation_util = new ValidationUtils(); $sanitization_util = new SanitizationUtils(); $address = (array) $address; $schema = $this->get_properties(); // omit all keys from address that are not in the schema. This should account for email. $address = array_intersect_key( $address, $schema ); $address = array_reduce( array_keys( $address ), function ( $carry, $key ) use ( $address, $validation_util, $schema ) { switch ( $key ) { case 'country': $carry[ $key ] = wc_strtoupper( sanitize_text_field( wp_unslash( $address[ $key ] ) ) ); break; case 'state': $carry[ $key ] = $validation_util->format_state( sanitize_text_field( wp_unslash( $address[ $key ] ) ), $address['country'] ); break; case 'postcode': $carry[ $key ] = $address['postcode'] ? wc_format_postcode( sanitize_text_field( wp_unslash( $address['postcode'] ) ), $address['country'] ) : ''; break; default: $carry[ $key ] = rest_sanitize_value_from_schema( wp_unslash( $address[ $key ] ), $schema[ $key ], $key ); break; } if ( $this->additional_fields_controller->is_field( $key ) ) { $carry[ $key ] = $this->additional_fields_controller->sanitize_field( $key, $carry[ $key ] ); } return $carry; }, [] ); return $sanitization_util->wp_kses_array( $address ); } /** * Validate the given address object. * * @see rest_validate_value_from_schema * * @param array $address Value being sanitized. * @param \WP_REST_Request $request The Request. * @param string $param The param being sanitized. * @return true|\WP_Error */ public function validate_callback( $address, $request, $param ) { $errors = new \WP_Error(); $address = (array) $address; $validation_util = new ValidationUtils(); $schema = $this->get_properties(); // Omit all keys from address that are not in the schema. This should account for email. $address = array_intersect_key( $address, $schema ); // The flow is Validate -> Sanitize -> Re-Validate // First validation step is to ensure fields match their schema, then we sanitize to put them in the // correct format, and finally the second validation step is to ensure the correctly-formatted values // match what we expect (postcode etc.). foreach ( $address as $key => $value ) { // Only run specific validation on properties that are defined in the schema and present in the address. // This is for partial address pushes when only part of a customer address is sent. // Full schema address validation still happens later, so empty, required values are disallowed. if ( empty( $schema[ $key ] ) || empty( $address[ $key ] ) ) { continue; } if ( is_wp_error( rest_validate_value_from_schema( $value, $schema[ $key ], $key ) ) ) { $errors->add( 'invalid_' . $key, sprintf( /* translators: %s: field name */ __( 'Invalid %s provided.', 'woocommerce' ), $key ) ); } } // This condition will be true if any validation errors were encountered, e.g. wrong type supplied or invalid // option in enum fields. if ( $errors->has_errors() ) { return $errors; } $address = $this->sanitize_callback( $address, $request, $param ); if ( ! empty( $address['country'] ) && ! in_array( $address['country'], array_keys( wc()->countries->get_countries() ), true ) ) { $errors->add( 'invalid_country', sprintf( /* translators: %s valid country codes */ __( 'Invalid country code provided. Must be one of: %s', 'woocommerce' ), implode( ', ', array_keys( wc()->countries->get_countries() ) ) ) ); return $errors; } if ( ! empty( $address['state'] ) && ! $validation_util->validate_state( $address['state'], $address['country'] ) ) { $errors->add( 'invalid_state', sprintf( /* translators: %1$s given state, %2$s valid states */ __( 'The provided state (%1$s) is not valid. Must be one of: %2$s', 'woocommerce' ), esc_html( $address['state'] ), implode( ', ', array_keys( $validation_util->get_states_for_country( $address['country'] ) ) ) ) ); } if ( ! empty( $address['postcode'] ) && ! \WC_Validation::is_postcode( $address['postcode'], $address['country'] ) ) { $errors->add( 'invalid_postcode', __( 'The provided postcode / ZIP is not valid', 'woocommerce' ) ); } if ( ! empty( $address['phone'] ) ) { // This is a safe sanitize to prevent copy-paste issues with invisible chars. Won't ensure validation. $address['phone'] = wc_remove_non_displayable_chars( $address['phone'] ); if ( ! \WC_Validation::is_phone( $address['phone'] ) ) { $errors->add( 'invalid_phone', __( 'The provided phone number is not valid', 'woocommerce' ) ); } } // Get additional field keys here as we need to know if they are present in the address for validation. $additional_keys = array_keys( $this->get_additional_address_fields_schema() ); foreach ( array_keys( $address ) as $key ) { // Skip email here it will be validated in BillingAddressSchema. if ( 'email' === $key ) { continue; } // Only run specific validation on properties that are defined in the schema and present in the address. // This is for partial address pushes when only part of a customer address is sent. // Full schema address validation still happens later, so empty, required values are disallowed. if ( empty( $schema[ $key ] ) || empty( $address[ $key ] ) ) { continue; } $field_schema = $schema[ $key ]; $field_value = isset( $address[ $key ] ) ? $address[ $key ] : null; $result = rest_validate_value_from_schema( $field_value, $field_schema, $key ); if ( is_wp_error( $result ) && $result->has_errors() ) { $errors->merge_from( $result ); } } return $errors->has_errors( $errors ) ? $errors : true; } /** * Get additional address fields schema. * * @return array */ protected function get_additional_address_fields_schema() { $additional_fields_keys = $this->additional_fields_controller->get_address_fields_keys(); $fields = $this->additional_fields_controller->get_additional_fields(); $address_fields = array_filter( $fields, function ( $key ) use ( $additional_fields_keys ) { return in_array( $key, $additional_fields_keys, true ); }, ARRAY_FILTER_USE_KEY ); $schema = []; foreach ( $address_fields as $key => $field ) { $field_schema = [ 'description' => $field['label'], 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => $this->additional_fields_controller->is_conditional_field( $field ) ? false : true === $field['required'], ]; if ( 'select' === $field['type'] ) { $field_schema['enum'] = array_map( function ( $option ) { return $option['value']; }, $field['options'] ); } if ( 'checkbox' === $field['type'] ) { $field_schema['type'] = 'boolean'; } $schema[ $key ] = $field_schema; } return $schema; } } Schemas/V1/ProductReviewSchema.php 0000777 00000014175 15251730534 0013075 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\StoreApi\SchemaController; /** * ProductReviewSchema class. */ class ProductReviewSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'product_review'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'product-review'; /** * Image attachment schema instance. * * @var ImageAttachmentSchema */ protected $image_attachment_schema; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER ); } /** * Product review schema properties. * * @return array */ public function get_properties() { $properties = [ 'id' => [ 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'date_created' => [ 'description' => __( "The date the review was created, in the site's timezone.", 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'formatted_date_created' => [ 'description' => __( "The date the review was created, in the site's timezone in human-readable format.", 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'date_created_gmt' => [ 'description' => __( 'The date the review was created, as GMT.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'product_id' => [ 'description' => __( 'Unique identifier for the product that the review belongs to.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'product_name' => [ 'description' => __( 'Name of the product that the review belongs to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'product_permalink' => [ 'description' => __( 'Permalink of the product that the review belongs to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'product_image' => [ 'description' => __( 'Image of the product that the review belongs to.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => $this->image_attachment_schema->get_properties(), ], 'reviewer' => [ 'description' => __( 'Reviewer name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'review' => [ 'description' => __( 'The content of the review.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'arg_options' => [ 'sanitize_callback' => 'wp_filter_post_kses', ], 'readonly' => true, ], 'rating' => [ 'description' => __( 'Review rating (0 to 5).', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'verified' => [ 'description' => __( 'Shows if the reviewer bought the product or not.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ]; if ( get_option( 'show_avatars' ) ) { $avatar_properties = array(); $avatar_sizes = rest_get_avatar_sizes(); foreach ( $avatar_sizes as $size ) { $avatar_properties[ $size ] = array( /* translators: %d: avatar image size in pixels */ 'description' => sprintf( __( 'Avatar URL with image size of %d pixels.', 'woocommerce' ), $size ), 'type' => 'string', 'format' => 'uri', 'context' => array( 'embed', 'view', 'edit' ), ); } $properties['reviewer_avatar_urls'] = array( 'description' => __( 'Avatar URLs for the object reviewer.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $avatar_properties, ); } return $properties; } /** * Convert a WooCommerce product into an object suitable for the response. * * @param \WP_Comment $review Product review object. * @return array */ public function get_item_response( $review ) { $rating = get_comment_meta( $review->comment_ID, 'rating', true ) === '' ? null : (int) get_comment_meta( $review->comment_ID, 'rating', true ); return [ 'id' => (int) $review->comment_ID, 'date_created' => wc_rest_prepare_date_response( $review->comment_date ), 'formatted_date_created' => get_comment_date( 'F j, Y', $review->comment_ID ), 'date_created_gmt' => wc_rest_prepare_date_response( $review->comment_date_gmt ), 'product_id' => (int) $review->comment_post_ID, 'product_name' => get_the_title( (int) $review->comment_post_ID ), 'product_permalink' => get_permalink( (int) $review->comment_post_ID ), 'product_image' => $this->image_attachment_schema->get_item_response( get_post_thumbnail_id( (int) $review->comment_post_ID ) ), 'reviewer' => $review->comment_author, 'review' => wpautop( $review->comment_content ), 'rating' => $rating, 'verified' => wc_review_is_from_verified_owner( $review->comment_ID ), 'reviewer_avatar_urls' => rest_get_avatar_urls( $review->comment_author_email ), ]; } } Schemas/V1/CartExtensionsSchema.php 0000777 00000004323 15251730534 0013236 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\StoreApi\Utilities\CartController; /** * Class CartExtensionsSchema */ class CartExtensionsSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'cart-extensions'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'cart-extensions'; /** * Cart schema instance. * * @var CartSchema */ public $cart_schema; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->cart_schema = $this->controller->get( CartSchema::IDENTIFIER ); } /** * Cart extensions schema properties. * * @return array */ public function get_properties() { return []; } /** * Handle the request and return a valid response for this endpoint. * * @param \WP_REST_Request $request Request containing data for the extension callback. * @throws RouteException When callback is not callable or parameters are incorrect. * * @return array */ public function get_item_response( $request = null ) { try { $callback = $this->extend->get_update_callback( $request['namespace'] ); } catch ( \Exception $e ) { throw new RouteException( 'woocommerce_rest_cart_extensions_error', esc_html( $e->getMessage() ), 400 ); } // Run the callback. Exceptions are not caught here. $callback( $request['data'] ); try { // We recalculate the cart if we had something to run. $controller = new CartController(); $cart = $controller->calculate_totals(); $response = $this->cart_schema->get_item_response( $cart ); return rest_ensure_response( $response ); } catch ( \Exception $e ) { throw new RouteException( 'woocommerce_rest_cart_extensions_error', esc_html( $e->getMessage() ), 400 ); } } } Schemas/V1/ProductCollectionDataSchema.php 0000777 00000011712 15251730534 0014513 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * ProductCollectionDataSchema class. */ class ProductCollectionDataSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'product-collection-data'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'product-collection-data'; /** * Product collection data schema properties. * * @return array */ public function get_properties() { return [ 'price_range' => [ 'description' => __( 'Min and max prices found in collection of products, provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'min_price' => [ 'description' => __( 'Min price found in collection of products.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'max_price' => [ 'description' => __( 'Max price found in collection of products.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ] ), ], 'attribute_counts' => [ 'description' => __( 'Returns number of products within attribute terms.', 'woocommerce' ), 'type' => [ 'array', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'term' => [ 'description' => __( 'Term ID', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'count' => [ 'description' => __( 'Number of products.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'rating_counts' => [ 'description' => __( 'Returns number of products with each average rating.', 'woocommerce' ), 'type' => [ 'array', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'rating' => [ 'description' => __( 'Average rating', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'count' => [ 'description' => __( 'Number of products.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'stock_status_counts' => [ 'description' => __( 'Returns number of products with each stock status.', 'woocommerce' ), 'type' => [ 'array', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'status' => [ 'description' => __( 'Status', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'count' => [ 'description' => __( 'Number of products.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], 'taxonomy_counts' => [ 'description' => __( 'Returns number of products within taxonomy terms.', 'woocommerce' ), 'type' => [ 'array', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'term' => [ 'description' => __( 'Term ID', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'count' => [ 'description' => __( 'Number of products.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], ]; } /** * Format data. * * @param array $data Collection data to format and return. * @return array */ public function get_item_response( $data ) { return [ 'price_range' => ! is_null( $data['min_price'] ) && ! is_null( $data['max_price'] ) ? (object) $this->prepare_currency_response( [ 'min_price' => $this->prepare_money_response( $data['min_price'], wc_get_price_decimals() ), 'max_price' => $this->prepare_money_response( $data['max_price'], wc_get_price_decimals() ), ] ) : null, 'attribute_counts' => $data['attribute_counts'], 'rating_counts' => $data['rating_counts'], 'stock_status_counts' => $data['stock_status_counts'], 'taxonomy_counts' => $data['taxonomy_counts'], ]; } } Schemas/V1/BillingAddressSchema.php 0000777 00000010731 15251730534 0013153 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils; /** * BillingAddressSchema class. * * Provides a generic billing address schema for composition in other schemas. */ class BillingAddressSchema extends AbstractAddressSchema { /** * The schema item name. * * @var string */ protected $title = 'billing_address'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'billing-address'; /** * Term properties. * * @return array */ public function get_properties() { $properties = parent::get_properties(); return array_merge( $properties, [ 'email' => [ 'description' => __( 'Email', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => true, ], ] ); } /** * Sanitize and format the given address object. * * @param array $address Value being sanitized. * @param \WP_REST_Request $request The Request. * @param string $param The param being sanitized. * @return array */ public function sanitize_callback( $address, $request, $param ) { $address = parent::sanitize_callback( $address, $request, $param ); if ( isset( $address['email'] ) ) { $address['email'] = sanitize_email( wp_unslash( $address['email'] ) ); } return $address; } /** * Validate the given address object. * * @param array $address Value being validated. * @param \WP_REST_Request $request The Request. * @param string $param The param being validated. * @return true|\WP_Error */ public function validate_callback( $address, $request, $param ) { $errors = parent::validate_callback( $address, $request, $param ); $address = (array) $address; $errors = is_wp_error( $errors ) ? $errors : new \WP_Error(); if ( ! empty( $address['email'] ) && ! is_email( $address['email'] ) ) { $errors->add( 'invalid_email', __( 'The provided email address is not valid', 'woocommerce' ) ); } return $errors->has_errors( $errors ) ? $errors : true; } /** * Convert a term object into an object suitable for the response. * * @param \WC_Order|\WC_Customer $address An object with billing address. * * @throws RouteException When the invalid object types are provided. * @return array */ public function get_item_response( $address ) { $validation_util = new ValidationUtils(); if ( ( $address instanceof \WC_Customer || $address instanceof \WC_Order ) ) { $billing_country = $address->get_billing_country(); $billing_state = $address->get_billing_state(); if ( ! $validation_util->validate_state( $billing_state, $billing_country ) ) { $billing_state = ''; } $additional_address_fields = $this->additional_fields_controller->get_all_fields_from_object( $address, 'billing' ); $address_object = \array_merge( [ 'first_name' => $address->get_billing_first_name(), 'last_name' => $address->get_billing_last_name(), 'company' => $address->get_billing_company(), 'address_1' => $address->get_billing_address_1(), 'address_2' => $address->get_billing_address_2(), 'city' => $address->get_billing_city(), 'state' => $billing_state, 'postcode' => $address->get_billing_postcode(), 'country' => $billing_country, 'email' => $address->get_billing_email(), 'phone' => $address->get_billing_phone(), ], $additional_address_fields ); // Add any missing keys from additional_fields_controller to the address response. foreach ( $this->additional_fields_controller->get_address_fields_keys() as $field ) { if ( isset( $address_object[ $field ] ) ) { continue; } $address_object[ $field ] = ''; } foreach ( $address_object as $key => $value ) { if ( isset( $this->get_properties()[ $key ]['type'] ) && 'boolean' === $this->get_properties()[ $key ]['type'] ) { $address_object[ $key ] = (bool) $value; } else { $address_object[ $key ] = $this->prepare_html_response( $value ); } } return $address_object; } throw new RouteException( 'invalid_object_type', sprintf( /* translators: Placeholders are class and method names */ __( '%1$s requires an instance of %2$s or %3$s for the address', 'woocommerce' ), 'BillingAddressSchema::get_item_response', 'WC_Customer', 'WC_Order' ), 500 ); } } Schemas/V1/AI/ProductsSchema.php 0000777 00000000347 15251730534 0012363 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\StoreApi\Schemas\V1\AI; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; /** * ProductsSchema class. * * @internal */ class ProductsSchema {} Schemas/V1/OrderItemSchema.php 0000777 00000011250 15251730534 0012154 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Utilities\ProductItemTrait; /** * OrderItemSchema class. */ class OrderItemSchema extends ItemSchema { use ProductItemTrait; /** * The schema item name. * * @var string */ protected $title = 'order_item'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'order-item'; /** * Get order items data. * * @param \WC_Order_Item_Product $order_item Order item instance. * @return array */ public function get_item_response( $order_item ) { $order = $order_item->get_order(); $product = $order_item->get_product(); $product_properties = [ 'short_description' => '', 'description' => '', 'sku' => '', 'permalink' => '', 'catalog_visibility' => 'hidden', 'prices' => [ 'price' => '', 'regular_price' => '', 'sale_price' => '', 'price_range' => null, 'currency_code' => '', 'currency_symbol' => '', 'currency_minor_unit' => 2, 'currency_decimal_separator' => '.', 'currency_thousand_separator' => ',', 'currency_prefix' => '', 'currency_suffix' => '', 'raw_prices' => [ 'precision' => 6, 'price' => '', 'regular_price' => '', 'sale_price' => '', ], ], 'sold_individually' => false, 'images' => [], 'variation' => [], ]; if ( is_a( $product, 'WC_Product' ) ) { $product_properties['short_description'] = $product->get_short_description(); $product_properties['description'] = $product->get_description(); $product_properties['sku'] = $product->get_sku(); $product_properties['permalink'] = $product->get_permalink(); $product_properties['catalog_visibility'] = $product->get_catalog_visibility(); $product_properties['prices'] = $this->prepare_product_price_response( $product, get_option( 'woocommerce_tax_display_cart' ) ); $product_properties['sold_individually'] = $product->is_sold_individually(); $product_properties['images'] = $this->get_images( $product ); // Only include variation data for product variations, not simple products. // This is consistent with the cart endpoint behavior. if ( $product instanceof \WC_Product_Variation ) { $product_properties['variation'] = $this->format_variation_data( $product->get_attributes(), $product ); } } return [ 'key' => $order->get_order_key(), 'id' => $order_item->get_id(), 'quantity' => $order_item->get_quantity(), 'quantity_limits' => array( 'minimum' => $order_item->get_quantity(), 'maximum' => $order_item->get_quantity(), 'multiple_of' => 1, 'editable' => false, ), 'name' => $order_item->get_name(), 'short_description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product_properties['short_description'] ) ) ), 'description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product_properties['description'] ) ) ), 'sku' => $this->prepare_html_response( $product_properties['sku'] ), 'low_stock_remaining' => null, 'backorders_allowed' => false, 'show_backorder_badge' => false, 'sold_individually' => $product_properties['sold_individually'] ?? false, 'permalink' => $product_properties['permalink'], 'images' => $product_properties['images'], 'variation' => $product_properties['variation'], 'item_data' => $order_item->get_all_formatted_meta_data(), 'prices' => (object) $product_properties['prices'], 'totals' => (object) $this->prepare_currency_response( $this->get_totals( $order_item ) ), 'catalog_visibility' => $product_properties['catalog_visibility'], ]; } /** * Get totals data. * * @param \WC_Order_Item_Product $order_item Order item instance. * @return array */ public function get_totals( $order_item ) { return [ 'line_subtotal' => $this->prepare_money_response( $order_item->get_subtotal(), wc_get_price_decimals() ), 'line_subtotal_tax' => $this->prepare_money_response( $order_item->get_subtotal_tax(), wc_get_price_decimals() ), 'line_total' => $this->prepare_money_response( $order_item->get_total(), wc_get_price_decimals() ), 'line_total_tax' => $this->prepare_money_response( $order_item->get_total_tax(), wc_get_price_decimals() ), ]; } } Schemas/V1/OrderSchema.php 0000777 00000031367 15251730534 0011350 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\StoreApi\Utilities\OrderController; /** * OrderSchema class. */ class OrderSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'order'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'order'; /** * Item schema instance. * * @var OrderItemSchema */ public $item_schema; /** * Order controller class instance. * * @var OrderController */ protected $order_controller; /** * Coupon schema instance. * * @var OrderCouponSchema */ public $coupon_schema; /** * Product item schema instance representing cross-sell items. * * @var ProductSchema */ public $cross_sells_item_schema; /** * Fee schema instance. * * @var OrderFeeSchema */ public $fee_schema; /** * Shipping rates schema instance. * * @var CartShippingRateSchema */ public $shipping_rate_schema; /** * Shipping address schema instance. * * @var ShippingAddressSchema */ public $shipping_address_schema; /** * Billing address schema instance. * * @var BillingAddressSchema */ public $billing_address_schema; /** * Error schema instance. * * @var ErrorSchema */ public $error_schema; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->item_schema = $this->controller->get( OrderItemSchema::IDENTIFIER ); $this->coupon_schema = $this->controller->get( OrderCouponSchema::IDENTIFIER ); $this->fee_schema = $this->controller->get( OrderFeeSchema::IDENTIFIER ); $this->shipping_rate_schema = $this->controller->get( CartShippingRateSchema::IDENTIFIER ); $this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER ); $this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER ); $this->error_schema = $this->controller->get( ErrorSchema::IDENTIFIER ); $this->order_controller = new OrderController(); } /** * Order schema properties. * * @return array */ public function get_properties() { return [ 'id' => [ 'description' => __( 'The order ID.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'items' => [ 'description' => __( 'Line items data.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->item_schema->get_properties() ), ], ], 'totals' => [ 'description' => __( 'Order totals.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'subtotal' => [ 'description' => __( 'Subtotal of the order.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_discount' => [ 'description' => __( 'Total discount from applied coupons.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_shipping' => [ 'description' => __( 'Total price of shipping.', 'woocommerce' ), 'type' => [ 'string', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_fees' => [ 'description' => __( 'Total price of any applied fees.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_tax' => [ 'description' => __( 'Total tax applied to the order.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_refund' => [ 'description' => __( 'Total refund applied to the order.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_price' => [ 'description' => __( 'Total price the customer will pay.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_items' => [ 'description' => __( 'Total price of items in the order.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_items_tax' => [ 'description' => __( 'Total tax on items in the order.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_fees_tax' => [ 'description' => __( 'Total tax on fees.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_discount_tax' => [ 'description' => __( 'Total tax removed due to discount from applied coupons.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_shipping_tax' => [ 'description' => __( 'Total tax on shipping. If shipping has not been calculated, a null response will be sent.', 'woocommerce' ), 'type' => [ 'string', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'tax_lines' => [ 'description' => __( 'Lines of taxes applied to items and shipping.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'name' => [ 'description' => __( 'The name of the tax.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'price' => [ 'description' => __( 'The amount of tax charged.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'rate' => [ 'description' => __( 'The rate at which tax is applied.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ], ], ], ] ), ], 'coupons' => [ 'description' => __( 'List of applied cart coupons.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->coupon_schema->get_properties() ), ], ], 'shipping_address' => [ 'description' => __( 'Current set shipping address for the customer.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => $this->force_schema_readonly( $this->shipping_address_schema->get_properties() ), ], 'billing_address' => [ 'description' => __( 'Current set billing address for the customer.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => $this->force_schema_readonly( $this->billing_address_schema->get_properties() ), ], 'needs_payment' => [ 'description' => __( 'True if the cart needs payment. False for carts with only free products and no shipping costs.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'needs_shipping' => [ 'description' => __( 'True if the cart needs shipping. False for carts with only digital goods or stores with no shipping methods set-up.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'errors' => [ 'description' => __( 'List of cart item errors, for example, items in the cart which are out of stock.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'properties' => $this->force_schema_readonly( $this->error_schema->get_properties() ), ], ], 'payment_requirements' => [ 'description' => __( 'List of required payment gateway features to process the order.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'status' => [ 'description' => __( 'Status of the order.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ]; } /** * Get an order for response. * * @param \WC_Order $order Order instance. * @return array */ public function get_item_response( $order ) { $order_id = $order->get_id(); $errors = []; $failed_order_stock_error = $this->order_controller->get_failed_order_stock_error( $order_id ); if ( $failed_order_stock_error ) { $errors[] = $failed_order_stock_error; } return [ 'id' => $order_id, 'status' => $order->get_status(), 'items' => $this->get_item_responses_from_schema( $this->item_schema, $order->get_items() ), 'coupons' => $this->get_item_responses_from_schema( $this->coupon_schema, $order->get_items( 'coupon' ) ), 'fees' => $this->get_item_responses_from_schema( $this->fee_schema, $order->get_items( 'fee' ) ), 'totals' => (object) $this->prepare_currency_response( $this->get_totals( $order ) ), 'shipping_address' => (object) $this->shipping_address_schema->get_item_response( $order ), 'billing_address' => (object) $this->billing_address_schema->get_item_response( $order ), 'needs_payment' => $order->needs_payment(), 'needs_shipping' => $order->needs_shipping_address(), 'payment_requirements' => $this->extend->get_payment_requirements(), 'errors' => $errors, ]; } /** * Get total data. * * @param \WC_Order $order Order instance. * @return array */ protected function get_totals( $order ) { return [ 'subtotal' => $this->prepare_money_response( $order->get_subtotal() ), 'total_discount' => $this->prepare_money_response( $order->get_total_discount() ), 'total_shipping' => $this->prepare_money_response( $order->get_total_shipping() ), 'total_fees' => $this->prepare_money_response( $order->get_total_fees() ), 'total_tax' => $this->prepare_money_response( $order->get_total_tax() ), 'total_refund' => $this->prepare_money_response( $order->get_total_refunded() ), 'total_price' => $this->prepare_money_response( $order->get_total() ), 'total_items' => $this->prepare_money_response( array_sum( array_map( function( $item ) { return $item->get_total(); }, array_values( $order->get_items( 'line_item' ) ) ) ) ), 'total_items_tax' => $this->prepare_money_response( array_sum( array_map( function( $item ) { return $item->get_tax_total(); }, array_values( $order->get_items( 'tax' ) ) ) ) ), 'total_fees_tax' => $this->prepare_money_response( array_sum( array_map( function( $item ) { return $item->get_total_tax(); }, array_values( $order->get_items( 'fee' ) ) ) ) ), 'total_discount_tax' => $this->prepare_money_response( $order->get_discount_tax() ), 'total_shipping_tax' => $this->prepare_money_response( $order->get_shipping_tax() ), 'tax_lines' => array_map( function( $item ) { return [ 'name' => $item->get_label(), 'price' => $this->prepare_money_response( $item->get_tax_total() ), 'rate' => strval( $item->get_rate_percent() ), ]; }, array_values( $order->get_items( 'tax' ) ) ), ]; } } Schemas/V1/CheckoutSchema.php 0000777 00000036057 15251730534 0012043 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; use Automattic\WooCommerce\Blocks\Package; use Automattic\WooCommerce\StoreApi\Utilities\SanitizationUtils; use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema; /** * CheckoutSchema class. */ class CheckoutSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'checkout'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'checkout'; /** * Billing address schema instance. * * @var BillingAddressSchema */ protected $billing_address_schema; /** * Shipping address schema instance. * * @var ShippingAddressSchema */ protected $shipping_address_schema; /** * Image Attachment schema instance. * * @var ImageAttachmentSchema */ protected $image_attachment_schema; /** * Cart schema instance. * * @var CartSchema */ protected $cart_schema; /** * Additional fields controller. * * @var CheckoutFields */ protected $additional_fields_controller; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER ); $this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER ); $this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER ); $this->cart_schema = $this->controller->get( CartSchema::IDENTIFIER ); $this->additional_fields_controller = Package::container()->get( CheckoutFields::class ); } /** * Checkout schema properties. * * @return array */ public function get_properties() { $additional_field_schema = $this->get_additional_fields_schema(); return [ 'order_id' => [ 'description' => __( 'The order ID to process during checkout.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'status' => [ 'description' => __( 'Order status. Payment providers will update this value after payment.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'order_key' => [ 'description' => __( 'Order key used to check validity or protect access to certain order data.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'order_number' => [ 'description' => __( 'Order number used for display.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'customer_note' => [ 'description' => __( 'Note added to the order by the customer during checkout.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'customer_id' => [ 'description' => __( 'Customer ID if registered. Will return 0 for guests.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'billing_address' => [ 'description' => __( 'Billing address.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'properties' => $this->billing_address_schema->get_properties(), 'arg_options' => [ 'sanitize_callback' => [ $this->billing_address_schema, 'sanitize_callback' ], 'validate_callback' => [ $this->billing_address_schema, 'validate_callback' ], ], 'required' => true, ], 'shipping_address' => [ 'description' => __( 'Shipping address.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'properties' => $this->shipping_address_schema->get_properties(), 'arg_options' => [ 'sanitize_callback' => [ $this->shipping_address_schema, 'sanitize_callback' ], 'validate_callback' => [ $this->shipping_address_schema, 'validate_callback' ], ], ], 'payment_method' => [ 'description' => __( 'The ID of the payment method being used to process the payment.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], // Validation may be based on cart contents which is not available here; this returns all enabled // gateways. Further validation occurs during the request. 'enum' => array_merge( [ '' ], array_values( WC()->payment_gateways->get_payment_gateway_ids() ) ), ], 'create_account' => [ 'description' => __( 'Whether to create a new user account as part of order processing.', 'woocommerce' ), 'type' => 'boolean', 'context' => [ 'view', 'edit' ], ], 'payment_result' => [ 'description' => __( 'Result of payment processing, or null if not yet processed.', 'woocommerce' ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => [ 'payment_status' => [ 'description' => __( 'Status of the payment returned by the gateway. One of success, pending, failure, error.', 'woocommerce' ), 'readonly' => true, 'type' => 'string', ], 'payment_details' => [ 'description' => __( 'An array of data being returned from the payment gateway.', 'woocommerce' ), 'readonly' => true, 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'key' => [ 'type' => 'string', ], 'value' => [ 'type' => 'string', ], ], ], ], 'redirect_url' => [ 'description' => __( 'A URL to redirect the customer after checkout. This could be, for example, a link to the payment processors website.', 'woocommerce' ), 'readonly' => true, 'type' => 'string', ], ], ], 'additional_fields' => [ 'description' => __( 'Additional fields to be persisted on the order.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'properties' => $additional_field_schema, 'arg_options' => [ 'sanitize_callback' => [ $this, 'sanitize_additional_fields' ], 'validate_callback' => [ $this, 'validate_additional_fields' ], ], 'required' => $this->schema_has_required_property( $additional_field_schema ), ], self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ), ]; } /** * Return the response for checkout. * * @param object $item Results from checkout action. * @return array */ public function get_item_response( $item ) { $cart = property_exists( $item, 'cart' ) ? $item->cart : null; $payment_result = property_exists( $item, 'payment_result' ) ? $item->payment_result : null; return $this->get_checkout_response( $item->order, $payment_result, $cart ); } /** * Get the checkout response based on the current order and any payments. * * @param \WC_Order $order Order object. * @param PaymentResult|null $payment_result Payment result object. * @param \WC_Cart|null $cart Cart object. * @return array */ protected function get_checkout_response( \WC_Order $order, ?PaymentResult $payment_result = null, ?\WC_Cart $cart = null ) { $payment_result = $payment_result ? [ 'payment_status' => $payment_result->status, 'payment_details' => $this->prepare_payment_details_for_response( $payment_result->payment_details ), 'redirect_url' => $payment_result->redirect_url, ] : null; return [ 'order_id' => $order->get_id(), 'status' => $order->get_status(), 'order_key' => $order->get_order_key(), 'order_number' => $order->get_order_number(), 'customer_note' => $order->get_customer_note(), 'customer_id' => $order->get_customer_id(), 'billing_address' => (object) $this->billing_address_schema->get_item_response( $order ), 'shipping_address' => (object) $this->shipping_address_schema->get_item_response( $order ), 'payment_method' => $order->get_payment_method(), 'payment_result' => $payment_result, 'additional_fields' => (object) $this->get_additional_fields_response( $order ), '__experimentalCart' => $cart ? (object) $this->cart_schema->get_item_response( $cart ) : null, self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ), ]; } /** * This prepares the payment details for the response so it's following the * schema where it's an array of objects. * * @param array $payment_details An array of payment details from the processed payment. * * @return array An array of objects where each object has the key and value * as distinct properties. */ protected function prepare_payment_details_for_response( array $payment_details ) { return array_map( function ( $key, $value ) { return (object) [ 'key' => $key, 'value' => $value, ]; }, array_keys( $payment_details ), $payment_details ); } /** * Get the additional fields response. * * @param \WC_Order $order Order object. * @return array */ protected function get_additional_fields_response( \WC_Order $order ) { $fields = wp_parse_args( $this->additional_fields_controller->get_all_fields_from_object( $order, 'other' ), $this->additional_fields_controller->get_all_fields_from_object( wc()->customer, 'other' ) ); $additional_field_schema = $this->get_additional_fields_schema(); foreach ( $fields as $key => $value ) { if ( ! isset( $additional_field_schema[ $key ] ) ) { unset( $fields[ $key ] ); continue; } // This makes sure we're casting checkboxes from "1" and "0" to boolean. In the frontend, "0" is treated as truthy. if ( isset( $additional_field_schema[ $key ]['type'] ) && 'boolean' === $additional_field_schema[ $key ]['type'] ) { $fields[ $key ] = (bool) $value; } else { $fields[ $key ] = $this->prepare_html_response( $value ); } } return (object) $fields; } /** * Get the schema for additional fields. * * @return array */ protected function get_additional_fields_schema() { return $this->generate_additional_fields_schema( $this->additional_fields_controller->get_fields_for_location( 'contact' ), $this->additional_fields_controller->get_fields_for_location( 'order' ) ); } /** * Generate the schema for additional fields. * * @param array[] ...$args One or more arrays of additional fields. * @return array */ protected function generate_additional_fields_schema( ...$args ) { $additional_fields = array_merge( ...$args ); $schema = []; foreach ( $additional_fields as $key => $field ) { $field_schema = [ 'description' => $field['label'], 'type' => 'string', 'context' => [ 'view', 'edit' ], 'required' => $this->additional_fields_controller->is_conditional_field( $field ) ? false : true === $field['required'], ]; if ( 'select' === $field['type'] ) { $field_schema['enum'] = array_map( function ( $option ) { return $option['value']; }, $field['options'] ); if ( true !== $field['required'] || $this->additional_fields_controller->is_conditional_field( $field ) ) { $field_schema['enum'][] = ''; } } if ( 'checkbox' === $field['type'] ) { $field_schema['type'] = 'boolean'; } if ( 'checkbox' === $field['type'] && true === $field['required'] ) { $field_schema['enum'][] = true; } $schema[ $key ] = $field_schema; } return $schema; } /** * Check if any additional field is required, so that the parent item is required as well. * * @param array $additional_fields_schema Additional fields schema. * @return bool */ protected function schema_has_required_property( $additional_fields_schema ) { return array_reduce( array_keys( $additional_fields_schema ), function ( $carry, $key ) use ( $additional_fields_schema ) { return $carry || true === $additional_fields_schema[ $key ]['required']; }, false ); } /** * Sanitize and format additional fields object. * * @param array $fields Values being sanitized. * @return array */ public function sanitize_additional_fields( $fields ) { $properties = $this->get_additional_fields_schema(); $sanitization_utils = new SanitizationUtils(); $fields = $sanitization_utils->wp_kses_array( array_reduce( array_keys( $fields ), function ( $carry, $key ) use ( $fields, $properties ) { if ( ! isset( $properties[ $key ] ) ) { return $carry; } $field_schema = $properties[ $key ]; $rest_sanitized = rest_sanitize_value_from_schema( wp_unslash( $fields[ $key ] ), $field_schema, $key ); $rest_sanitized = $this->additional_fields_controller->sanitize_field( $key, $rest_sanitized ); $carry[ $key ] = $rest_sanitized; return $carry; }, [] ) ); return $sanitization_utils->wp_kses_array( $fields ); } /** * Validate additional fields object. This does not validate required fields nor customer validation rules because * this may be a partial request. That will happen later when the full request is processed during POST. This only * validates against the schema. * * @see rest_validate_value_from_schema * * @param array $fields Value being sanitized. * @param \WP_REST_Request $request The Request. * @return true|\WP_Error */ public function validate_additional_fields( $fields, $request ) { $errors = new \WP_Error(); $fields = $this->sanitize_additional_fields( $fields ); $additional_field_schema = $this->get_additional_fields_schema(); // for PUT requests, we only want to validate the fields that are being updated. if ( $request->get_method() === 'PUT' ) { $additional_field_schema = array_intersect_key( $additional_field_schema, $fields ); } // on POST, loop over the schema instead of the fields. This is to ensure missing fields are validated. foreach ( $additional_field_schema as $key => $schema ) { if ( ! isset( $fields[ $key ] ) && true !== $schema['required'] ) { // Optional fields can go missing. continue; } $result = rest_validate_value_from_schema( $fields[ $key ] ?? null, $schema, $key ); if ( is_wp_error( $result ) && $result->has_errors() ) { $location = $this->additional_fields_controller->get_field_location( $key ); foreach ( $result->get_error_codes() as $code ) { $result->add_data( array( 'location' => $location, 'key' => $key, ), $code ); } $errors->merge_from( $result ); } } return $errors->has_errors() ? $errors : true; } } Schemas/V1/ProductCategorySchema.php 0000777 00000006773 15251730534 0013416 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; /** * ProductCategorySchema class. */ class ProductCategorySchema extends TermSchema { /** * The schema item name. * * @var string */ protected $title = 'product-category'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'product-category'; /** * Image attachment schema instance. * * @var ImageAttachmentSchema */ protected $image_attachment_schema; /** * Constructor. * * @param ExtendSchema $extend Rest Extending instance. * @param SchemaController $controller Schema Controller instance. */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); $this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER ); } /** * Term properties. * * @return array */ public function get_properties() { $schema = parent::get_properties(); $schema['image'] = [ 'description' => __( 'Category image.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, 'properties' => $this->image_attachment_schema->get_properties(), ]; $schema['review_count'] = [ 'description' => __( 'Number of reviews for products in this category.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ]; $schema['permalink'] = [ 'description' => __( 'Category URL.', 'woocommerce' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ]; return $schema; } /** * Convert a term object into an object suitable for the response. * * @param \WP_Term $term Term object. * @return array */ public function get_item_response( $term ) { $response = parent::get_item_response( $term ); $count = get_term_meta( $term->term_id, 'product_count_product_cat', true ); if ( $count ) { $response['count'] = (int) $count; } $response['image'] = $this->image_attachment_schema->get_item_response( get_term_meta( $term->term_id, 'thumbnail_id', true ) ); $response['review_count'] = $this->get_category_review_count( $term ); $response['permalink'] = get_term_link( $term->term_id, 'product_cat' ); return $response; } /** * Get total number of reviews for products in a category. * * @param \WP_Term $term Term object. * @return int */ protected function get_category_review_count( $term ) { global $wpdb; $children = get_term_children( $term->term_id, 'product_cat' ); if ( ! $children || is_wp_error( $children ) ) { $terms_to_count_str = absint( $term->term_id ); } else { $terms_to_count = array_unique( array_map( 'absint', array_merge( array( $term->term_id ), $children ) ) ); $terms_to_count_str = implode( ',', $terms_to_count ); } $products_of_category_sql = " SELECT SUM(comment_count) as review_count FROM {$wpdb->posts} AS posts INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = term_relationships.object_id WHERE term_relationships.term_taxonomy_id IN (" . esc_sql( $terms_to_count_str ) . ') '; $review_count = $wpdb->get_var( $products_of_category_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared return (int) $review_count; } } Schemas/V1/ImageAttachmentSchema.php 0000777 00000005070 15251730534 0013320 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * ImageAttachmentSchema class. */ class ImageAttachmentSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'image'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'image'; /** * Product schema properties. * * @return array */ public function get_properties() { return [ 'id' => [ 'description' => __( 'Image ID.', 'woocommerce' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], ], 'src' => [ 'description' => __( 'Full size image URL.', 'woocommerce' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit' ], ], 'thumbnail' => [ 'description' => __( 'Thumbnail URL.', 'woocommerce' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit' ], ], 'srcset' => [ 'description' => __( 'Thumbnail srcset for responsive images.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'sizes' => [ 'description' => __( 'Thumbnail sizes for responsive images.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'name' => [ 'description' => __( 'Image name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'alt' => [ 'description' => __( 'Image alternative text.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], ]; } /** * Convert a WooCommerce product into an object suitable for the response. * * @param int $attachment_id Image attachment ID. * @return object|null */ public function get_item_response( $attachment_id ) { if ( ! $attachment_id ) { return null; } $attachment = wp_get_attachment_image_src( $attachment_id, 'full' ); if ( ! is_array( $attachment ) ) { return null; } $thumbnail = wp_get_attachment_image_src( $attachment_id, 'woocommerce_thumbnail' ); return (object) [ 'id' => (int) $attachment_id, 'src' => current( $attachment ), 'thumbnail' => current( $thumbnail ), 'srcset' => (string) wp_get_attachment_image_srcset( $attachment_id, 'full' ), 'sizes' => (string) wp_get_attachment_image_sizes( $attachment_id, 'full' ), 'name' => get_the_title( $attachment_id ), 'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), ]; } } Schemas/V1/OrderCouponSchema.php 0000777 00000004644 15251730534 0012532 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * OrderCouponSchema class. */ class OrderCouponSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'order_coupon'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'order-coupon'; /** * Cart schema properties. * * @return array */ public function get_properties() { return [ 'code' => [ 'description' => __( 'The coupons unique code.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'discount_type' => [ 'description' => __( 'The discount type for the coupon (e.g. percentage or fixed amount)', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'totals' => [ 'description' => __( 'Total amounts provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'total_discount' => [ 'description' => __( 'Total discount applied by this coupon.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_discount_tax' => [ 'description' => __( 'Total tax removed due to discount applied by this coupon.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ] ), ], ]; } /** * Convert an order coupon to an object suitable for the response. * * @param \WC_Order_Item_Coupon $coupon Order coupon object. * @return array */ public function get_item_response( $coupon ) { $coupon_object = new \WC_Coupon( $coupon->get_code() ); return [ 'code' => $coupon->get_code(), 'discount_type' => $coupon_object ? $coupon_object->get_discount_type() : '', 'totals' => (object) $this->prepare_currency_response( [ 'total_discount' => $this->prepare_money_response( $coupon->get_discount(), wc_get_price_decimals() ), 'total_discount_tax' => $this->prepare_money_response( $coupon->get_discount_tax(), wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ), ] ), ]; } } Schemas/V1/ErrorSchema.php 0000777 00000002177 15251730534 0011363 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * ErrorSchema class. */ class ErrorSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'error'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'error'; /** * Product schema properties. * * @return array */ public function get_properties() { return [ 'code' => [ 'description' => __( 'Error code', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'message' => [ 'description' => __( 'Error message', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ]; } /** * Convert a WP_Error into an object suitable for the response. * * @param \WP_Error $error Error object. * @return array */ public function get_item_response( $error ) { return [ 'code' => $this->prepare_html_response( $error->get_error_code() ), 'message' => $this->prepare_html_response( $error->get_error_message() ), ]; } } Schemas/V1/BatchSchema.php 0000777 00000000653 15251730534 0011310 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * BatchSchema class. */ class BatchSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'batch'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'batch'; /** * Batch schema properties. * * @return array */ public function get_properties() { return []; } } Schemas/V1/Agentic/CheckoutSessionSchema.php 0000777 00000066064 15251730534 0014762 0 ustar 00 <?php /** * CheckoutSessionSchema class. * * @package Automattic\WooCommerce\StoreApi\Schemas\V1\Agentic */ declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Agentic; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\SessionKey; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\CheckoutSessionStatus; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\MessageType; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\MessageContentType; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\FulfillmentType; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\TotalType; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\LinkType; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\PaymentMethod; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\AgenticCheckoutSession; use Automattic\WooCommerce\StoreApi\Utilities\AgenticCheckoutUtils; use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils; use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait; use WC_Order; /** * Handles the schema for Agentic Checkout API checkout sessions. * This schema formats WooCommerce cart/order data according to the * Agentic Commerce Protocol specification. * * @internal The specification for agentic requests is subject to abrupt changes; backwards compatibility cannot be guaranteed. */ class CheckoutSessionSchema extends AbstractSchema { use DraftOrderTrait; /** * The schema item name. * * @var string */ protected $title = 'agentic_checkout_session'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'agentic-checkout-session'; /** * Checkout session schema properties. * * @return array */ public function get_properties() { return [ 'id' => [ 'description' => __( 'Unique identifier for the checkout session.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'buyer' => [ 'description' => __( 'Buyer information.', 'woocommerce' ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'properties' => [ 'first_name' => [ 'description' => __( 'First name.', 'woocommerce' ), 'type' => 'string', ], 'last_name' => [ 'description' => __( 'Last name.', 'woocommerce' ), 'type' => 'string', ], 'email' => [ 'description' => __( 'Email address.', 'woocommerce' ), 'type' => 'string', ], 'phone_number' => [ 'description' => __( 'Phone number.', 'woocommerce' ), 'type' => 'string', ], ], ], 'payment_provider' => [ 'description' => __( 'Payment provider information.', 'woocommerce' ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'properties' => [ 'provider' => [ 'description' => __( 'Payment provider identifier.', 'woocommerce' ), 'type' => 'string', ], 'supported_payment_methods' => [ 'description' => __( 'List of supported payment methods.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'string', ], ], ], ], 'status' => [ 'description' => __( 'Status of the checkout session.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'enum' => [ CheckoutSessionStatus::NOT_READY_FOR_PAYMENT, CheckoutSessionStatus::READY_FOR_PAYMENT, CheckoutSessionStatus::COMPLETED, CheckoutSessionStatus::CANCELED, ], 'readonly' => true, ], 'currency' => [ 'description' => __( 'Currency code (ISO 4217).', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'line_items' => [ 'description' => __( 'Line items in the checkout session.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'Line item ID.', 'woocommerce' ), 'type' => 'string', ], 'item' => [ 'description' => __( 'Product item details.', 'woocommerce' ), 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'Product ID.', 'woocommerce' ), 'type' => 'string', ], 'quantity' => [ 'description' => __( 'Quantity.', 'woocommerce' ), 'type' => 'integer', ], ], ], 'base_amount' => [ 'description' => __( 'Base amount in cents.', 'woocommerce' ), 'type' => 'integer', ], 'discount' => [ 'description' => __( 'Discount amount in cents.', 'woocommerce' ), 'type' => 'integer', ], 'subtotal' => [ 'description' => __( 'Subtotal in cents.', 'woocommerce' ), 'type' => 'integer', ], 'tax' => [ 'description' => __( 'Tax amount in cents.', 'woocommerce' ), 'type' => 'integer', ], 'total' => [ 'description' => __( 'Total amount in cents.', 'woocommerce' ), 'type' => 'integer', ], ], ], ], 'fulfillment_address' => [ 'description' => __( 'Fulfillment/shipping address.', 'woocommerce' ), 'type' => [ 'object', 'null' ], 'context' => [ 'view', 'edit' ], 'properties' => [ 'name' => [ 'description' => __( 'Full name.', 'woocommerce' ), 'type' => 'string', ], 'line_one' => [ 'description' => __( 'Address line 1.', 'woocommerce' ), 'type' => 'string', ], 'line_two' => [ 'description' => __( 'Address line 2.', 'woocommerce' ), 'type' => [ 'string', 'null' ], ], 'city' => [ 'description' => __( 'City.', 'woocommerce' ), 'type' => 'string', ], 'state' => [ 'description' => __( 'State/province.', 'woocommerce' ), 'type' => 'string', ], 'country' => [ 'description' => __( 'Country code (ISO 3166-1 alpha-2).', 'woocommerce' ), 'type' => 'string', ], 'postal_code' => [ 'description' => __( 'Postal/ZIP code.', 'woocommerce' ), 'type' => 'string', ], ], ], 'fulfillment_options' => [ 'description' => __( 'Available fulfillment options.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'type' => [ 'description' => __( 'Fulfillment type.', 'woocommerce' ), 'type' => 'string', 'enum' => [ FulfillmentType::SHIPPING, FulfillmentType::DIGITAL ], ], 'id' => [ 'description' => __( 'Fulfillment option ID.', 'woocommerce' ), 'type' => 'string', ], 'title' => [ 'description' => __( 'Title.', 'woocommerce' ), 'type' => 'string', ], 'subtitle' => [ 'description' => __( 'Subtitle.', 'woocommerce' ), 'type' => [ 'string', 'null' ], ], 'carrier' => [ 'description' => __( 'Carrier name.', 'woocommerce' ), 'type' => [ 'string', 'null' ], ], 'earliest_delivery_time' => [ 'description' => __( 'Earliest delivery time (ISO 8601).', 'woocommerce' ), 'type' => [ 'string', 'null' ], ], 'latest_delivery_time' => [ 'description' => __( 'Latest delivery time (ISO 8601).', 'woocommerce' ), 'type' => [ 'string', 'null' ], ], 'subtotal' => [ 'description' => __( 'Subtotal in cents.', 'woocommerce' ), 'type' => 'integer', ], 'tax' => [ 'description' => __( 'Tax in cents.', 'woocommerce' ), 'type' => 'integer', ], 'total' => [ 'description' => __( 'Total in cents.', 'woocommerce' ), 'type' => 'integer', ], ], ], ], 'fulfillment_option_id' => [ 'description' => __( 'Selected fulfillment option ID.', 'woocommerce' ), 'type' => [ 'string', 'null' ], 'context' => [ 'view', 'edit' ], ], 'totals' => [ 'description' => __( 'Order totals breakdown.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'type' => [ 'description' => __( 'Total type.', 'woocommerce' ), 'type' => 'string', ], 'display_text' => [ 'description' => __( 'Display text.', 'woocommerce' ), 'type' => 'string', ], 'amount' => [ 'description' => __( 'Amount in cents.', 'woocommerce' ), 'type' => 'integer', ], ], ], ], 'messages' => [ 'description' => __( 'Messages (info, warnings, errors).', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'type' => [ 'description' => __( 'Message type.', 'woocommerce' ), 'type' => 'string', 'enum' => [ MessageType::INFO, MessageType::WARNING, MessageType::ERROR ], ], 'param' => [ 'description' => __( 'JSON path to the related field.', 'woocommerce' ), 'type' => [ 'string', 'null' ], ], 'content_type' => [ 'description' => __( 'Content type.', 'woocommerce' ), 'type' => 'string', 'enum' => [ MessageContentType::PLAIN, MessageContentType::MARKDOWN ], ], 'content' => [ 'description' => __( 'Message content.', 'woocommerce' ), 'type' => 'string', ], ], ], ], 'links' => [ 'description' => __( 'Related links.', 'woocommerce' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], 'items' => [ 'type' => 'object', 'properties' => [ 'type' => [ 'description' => __( 'Link type.', 'woocommerce' ), 'type' => 'string', ], 'url' => [ 'description' => __( 'URL.', 'woocommerce' ), 'type' => 'string', ], ], ], ], ]; } /** * Convert a WooCommerce cart to the Agentic Checkout session format. * * @param AgenticCheckoutSession $checkout_session Checkout session object. * @return array Formatted checkout session data. */ public function get_item_response( $checkout_session ) { $cart = $checkout_session->get_cart(); // If validation already went through and we have errors, no need to repeat them. if ( ! $checkout_session->get_messages()->has_errors() ) { // Validate the checkout session. Messages will be added to the collection, if any. AgenticCheckoutUtils::validate( $checkout_session ); } $completed_order = WC()->session ? wc_get_order( WC()->session->get( SessionKey::AGENTIC_CHECKOUT_COMPLETED_ORDER_ID ) ) : null; // Get line items from cart, or from completed order if cart is empty. $cart_items = $cart->get_cart(); $line_items = $completed_order instanceof WC_Order ? $this->format_line_items_from_order( $completed_order ) : $this->format_line_items_from_cart( $cart_items ); $response = [ 'id' => $checkout_session->get_id(), 'buyer' => $completed_order instanceof WC_Order ? $this->format_buyer_from_order( $completed_order ) : $this->format_buyer(), 'payment_provider' => $this->format_payment_provider(), 'status' => AgenticCheckoutUtils::calculate_status( $checkout_session ), 'currency' => $completed_order instanceof WC_Order ? strtolower( $completed_order->get_currency() ) : strtolower( get_woocommerce_currency() ), 'line_items' => $line_items, 'fulfillment_address' => $completed_order instanceof WC_Order ? $this->format_fulfillment_address_from_order( $completed_order ) : $this->format_fulfillment_address(), 'fulfillment_options' => $completed_order instanceof WC_Order ? $this->format_fulfillment_options_from_order( $completed_order ) : $this->format_fulfillment_options(), 'fulfillment_option_id' => $completed_order instanceof WC_Order ? $this->get_selected_fulfillment_option_id_from_order( $completed_order ) : $this->get_selected_fulfillment_option_id(), 'totals' => $completed_order instanceof WC_Order ? $this->format_totals_from_order( $completed_order ) : $this->format_totals( $cart ), 'messages' => $checkout_session->get_messages()->get_formatted_messages(), 'links' => $this->get_links(), ]; // Add order data if a completed order exists. if ( $completed_order instanceof WC_Order ) { $response['order'] = [ 'id' => (string) $completed_order->get_id(), 'checkout_session_id' => $checkout_session->get_id(), 'permalink_url' => $completed_order->get_checkout_order_received_url(), ]; } return $response; } /** * Format buyer information. * * @return array|null Buyer data or null. */ protected function format_buyer() { $customer = WC()->customer; if ( ! $customer ) { return null; } $first_name = $customer->get_billing_first_name() ? $customer->get_billing_first_name() : $customer->get_shipping_first_name(); $last_name = $customer->get_billing_last_name() ? $customer->get_billing_last_name() : $customer->get_shipping_last_name(); $email = $customer->get_billing_email(); if ( ! $first_name && ! $last_name && ! $email ) { return null; } return [ 'first_name' => $first_name ? $first_name : '', 'last_name' => $last_name ? $last_name : '', 'email' => $email ? $email : '', 'phone_number' => $customer->get_billing_phone() ? $customer->get_billing_phone() : '', ]; } /** * Format buyer information from order. * * @param \WC_Order $order Order object. * @return array|null Buyer data or null. */ protected function format_buyer_from_order( $order ) { $first_name = $order->get_billing_first_name() ? $order->get_billing_first_name() : $order->get_shipping_first_name(); $last_name = $order->get_billing_last_name() ? $order->get_billing_last_name() : $order->get_shipping_last_name(); $email = $order->get_billing_email(); if ( ! $first_name && ! $last_name && ! $email ) { return null; } return [ 'first_name' => $first_name ? $first_name : '', 'last_name' => $last_name ? $last_name : '', 'email' => $email ? $email : '', 'phone_number' => $order->get_billing_phone() ? $order->get_billing_phone() : '', ]; } /** * Format payment provider information. * * @return array|null Payment provider data or null. */ protected function format_payment_provider() { $available_gateways = WC()->payment_gateways()->get_available_payment_gateways(); if ( empty( $available_gateways ) ) { return null; } // Look for gateway with agentic_commerce capability. $gateway = AgenticCheckoutUtils::get_agentic_commerce_gateway( $available_gateways ); if ( null !== $gateway ) { return [ 'provider' => $gateway->get_agentic_commerce_provider(), 'supported_payment_methods' => $gateway->get_agentic_commerce_payment_methods(), ]; } return [ 'provider' => 'stripe', 'supported_payment_methods' => [ PaymentMethod::CARD ], // Default, can be expanded. ]; } /** * Convert amount from decimal to cents. * * @param string|float $amount Amount in decimal. * @return int Amount in cents. */ protected function amount_to_cents( $amount ) { return (int) $this->extend->get_formatter( 'money' )->format( $amount ); } /** * Format line items from cart. * * @param array $cart_items Cart items array. * @return array Formatted line items. */ protected function format_line_items_from_cart( $cart_items ) { $items = []; foreach ( $cart_items as $cart_item_key => $cart_item ) { $product = $cart_item['data']; $quantity = $cart_item['quantity']; $base_amount = $this->amount_to_cents( $product->get_price() * $quantity ); $discount = $this->amount_to_cents( $cart_item['line_subtotal'] - $cart_item['line_total'] ); $subtotal = $base_amount - $discount; $tax = $this->amount_to_cents( $cart_item['line_tax'] ); $total = $subtotal + $tax; $items[] = [ 'id' => (string) $cart_item_key, 'item' => [ 'id' => (string) $product->get_id(), 'quantity' => $quantity, ], 'base_amount' => $base_amount, 'discount' => $discount, 'subtotal' => $subtotal, 'tax' => $tax, 'total' => $total, ]; } return $items; } /** * Format line items from order. * * @param \WC_Order $order Order object. * @return array Formatted line items. */ protected function format_line_items_from_order( $order ) { $items = []; foreach ( $order->get_items() as $item_id => $item ) { $quantity = $item->get_quantity(); $base_amount = $this->amount_to_cents( $item->get_subtotal() ); $discount = $this->amount_to_cents( $item->get_subtotal() - $item->get_total() ); $subtotal = $base_amount - $discount; $tax = $this->amount_to_cents( $item->get_total_tax() ); $total = $subtotal + $tax; // Use product_id from the order item, with variation_id as fallback. $item_product_id = $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id(); $items[] = [ 'id' => (string) $item_id, 'item' => [ 'id' => (string) $item_product_id, 'quantity' => $quantity, ], 'base_amount' => $base_amount, 'discount' => $discount, 'subtotal' => $subtotal, 'tax' => $tax, 'total' => $total, ]; } return $items; } /** * Format fulfillment address. * * @return array|null Address data or null. */ protected function format_fulfillment_address() { $customer = WC()->customer; if ( ! $customer || ! $customer->get_shipping_address_1() ) { return null; } return $this->build_address_array( $customer->get_shipping_first_name(), $customer->get_shipping_last_name(), $customer->get_shipping_address_1(), $customer->get_shipping_address_2(), $customer->get_shipping_city(), $customer->get_shipping_state(), $customer->get_shipping_country(), $customer->get_shipping_postcode() ); } /** * Format fulfillment address from order. * * @param \WC_Order $order Order object. * @return array|null Address data or null. */ protected function format_fulfillment_address_from_order( $order ) { if ( ! $order->get_shipping_address_1() ) { return null; } return $this->build_address_array( $order->get_shipping_first_name(), $order->get_shipping_last_name(), $order->get_shipping_address_1(), $order->get_shipping_address_2(), $order->get_shipping_city(), $order->get_shipping_state(), $order->get_shipping_country(), $order->get_shipping_postcode() ); } /** * Build address array from components. * * @param string $first_name First name. * @param string $last_name Last name. * @param string $address_1 Address line 1. * @param string $address_2 Address line 2. * @param string $city City. * @param string $state State. * @param string $country Country. * @param string $postcode Postcode. * @return array Address array. */ protected function build_address_array( $first_name, $last_name, $address_1, $address_2, $city, $state, $country, $postcode ) { $name = trim( $first_name . ' ' . $last_name ); return [ 'name' => $name ? $name : 'Customer', 'line_one' => $address_1, 'line_two' => $address_2 ? $address_2 : '', 'city' => $city, 'state' => $state, 'country' => $country, 'postal_code' => $postcode, ]; } /** * Format fulfillment options (shipping methods). * * @return array Fulfillment options. */ protected function format_fulfillment_options() { $options = []; $packages = WC()->shipping()->get_packages(); foreach ( $packages as $package ) { if ( empty( $package['rates'] ) ) { continue; } foreach ( $package['rates'] as $rate ) { $options[] = [ 'type' => FulfillmentType::SHIPPING, 'id' => $rate->get_id(), 'title' => $rate->get_label(), 'subtitle' => null, 'carrier' => $rate->get_method_id(), 'earliest_delivery_time' => null, 'latest_delivery_time' => null, 'subtotal' => $this->amount_to_cents( $rate->get_cost() ), 'tax' => $this->amount_to_cents( $rate->get_shipping_tax() ), 'total' => $this->amount_to_cents( $rate->get_cost() + $rate->get_shipping_tax() ), ]; } } return $options; } /** * Format fulfillment options from order. * * @param \WC_Order $order Order object. * @return array Fulfillment options. */ protected function format_fulfillment_options_from_order( $order ) { $options = []; $shipping_methods = $order->get_shipping_methods(); foreach ( $shipping_methods as $item ) { $options[] = [ 'type' => FulfillmentType::SHIPPING, 'id' => $item->get_method_id() . ':' . $item->get_instance_id(), 'title' => $item->get_name(), 'subtitle' => null, 'carrier' => $item->get_method_id(), 'earliest_delivery_time' => null, 'latest_delivery_time' => null, 'subtotal' => $this->amount_to_cents( $item->get_total() ), 'tax' => $this->amount_to_cents( $item->get_total_tax() ), 'total' => $this->amount_to_cents( $item->get_total() + $item->get_total_tax() ), ]; } return $options; } /** * Get selected fulfillment option ID. * * @return string|null Selected option ID or null. */ protected function get_selected_fulfillment_option_id() { $chosen_methods = WC()->session->get( SessionKey::CHOSEN_SHIPPING_METHODS ); return ! empty( $chosen_methods[0] ) ? $chosen_methods[0] : null; } /** * Get selected fulfillment option ID from order. * * @param \WC_Order $order Order object. * @return string|null Selected option ID or null. */ protected function get_selected_fulfillment_option_id_from_order( $order ) { $shipping_methods = $order->get_shipping_methods(); if ( empty( $shipping_methods ) ) { return null; } $shipping_method = reset( $shipping_methods ); return $shipping_method->get_method_id() . ':' . $shipping_method->get_instance_id(); } /** * Format totals array. * * @param \WC_Cart $cart Cart object. * @return array Totals array. */ protected function format_totals( $cart ) { $totals = []; // Items base amount. $items_base = 0; foreach ( $cart->get_cart() as $cart_item ) { $product = $cart_item['data']; $items_base += $product->get_price() * $cart_item['quantity']; } $totals[] = [ 'type' => TotalType::ITEMS_BASE_AMOUNT, 'display_text' => __( 'Items Base Amount', 'woocommerce' ), 'amount' => $this->amount_to_cents( $items_base ), ]; // Items discount. $discount = $cart->get_cart_discount_total(); $totals[] = [ 'type' => TotalType::ITEMS_DISCOUNT, 'display_text' => __( 'Items Discount', 'woocommerce' ), 'amount' => $this->amount_to_cents( $discount ), ]; // Subtotal. $totals[] = [ 'type' => TotalType::SUBTOTAL, 'display_text' => __( 'Subtotal', 'woocommerce' ), 'amount' => $this->amount_to_cents( $cart->get_subtotal() - $discount ), ]; // Fulfillment (shipping). $totals[] = [ 'type' => TotalType::FULFILLMENT, 'display_text' => __( 'Shipping', 'woocommerce' ), 'amount' => $this->amount_to_cents( $cart->get_shipping_total() ), ]; // Tax. $totals[] = [ 'type' => TotalType::TAX, 'display_text' => __( 'Tax', 'woocommerce' ), 'amount' => $this->amount_to_cents( $cart->get_total_tax() ), ]; // Total. $totals[] = [ 'type' => TotalType::TOTAL, 'display_text' => __( 'Total', 'woocommerce' ), 'amount' => $this->amount_to_cents( $cart->get_total( 'edit' ) ), ]; return $totals; } /** * Format totals array from order. * * @param \WC_Order $order Order object. * @return array Totals array. */ protected function format_totals_from_order( $order ) { $totals = []; // Items base amount. $items_base = 0; foreach ( $order->get_items() as $item ) { $product = $item->get_product(); $items_base += $product->get_price() * $item->get_quantity(); } $totals[] = [ 'type' => TotalType::ITEMS_BASE_AMOUNT, 'display_text' => __( 'Items Base Amount', 'woocommerce' ), 'amount' => $this->amount_to_cents( $items_base ), ]; // Items discount. $discount = $order->get_discount_total(); $totals[] = [ 'type' => TotalType::ITEMS_DISCOUNT, 'display_text' => __( 'Items Discount', 'woocommerce' ), 'amount' => $this->amount_to_cents( $discount ), ]; // Subtotal. $totals[] = [ 'type' => TotalType::SUBTOTAL, 'display_text' => __( 'Subtotal', 'woocommerce' ), 'amount' => $this->amount_to_cents( $items_base - $discount ), ]; // Fulfillment (shipping). $totals[] = [ 'type' => TotalType::FULFILLMENT, 'display_text' => __( 'Shipping', 'woocommerce' ), 'amount' => $this->amount_to_cents( $order->get_shipping_total() ), ]; // Tax. $totals[] = [ 'type' => TotalType::TAX, 'display_text' => __( 'Tax', 'woocommerce' ), 'amount' => $this->amount_to_cents( $order->get_total_tax() ), ]; // Total. $totals[] = [ 'type' => TotalType::TOTAL, 'display_text' => __( 'Total', 'woocommerce' ), 'amount' => $this->amount_to_cents( $order->get_total() ), ]; return $totals; } /** * Get links for the session. * * @return array Links array. */ protected function get_links() { $links = []; // Terms of use. $terms_page_id = wc_terms_and_conditions_page_id(); if ( $terms_page_id ) { $permalink = get_permalink( $terms_page_id ); if ( $permalink ) { $links[] = [ 'type' => LinkType::TERMS_OF_USE, 'url' => $permalink, ]; } } // Privacy policy. $privacy_page_id = get_option( 'wp_page_for_privacy_policy' ); if ( $privacy_page_id ) { $permalink = get_permalink( $privacy_page_id ); if ( $permalink ) { $links[] = [ 'type' => LinkType::PRIVACY_POLICY, 'url' => $permalink, ]; } } return $links; } } Schemas/V1/CartFeeSchema.php 0000777 00000004171 15251730534 0011577 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * CartFeeSchema class. */ class CartFeeSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'cart_fee'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'cart-fee'; /** * Cart schema properties. * * @return array */ public function get_properties() { return [ 'id' => [ 'description' => __( 'Unique identifier for the fee within the cart.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Fee name.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'totals' => [ 'description' => __( 'Fee total amounts provided using the smallest unit of the currency.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'readonly' => true, 'properties' => array_merge( $this->get_store_currency_properties(), [ 'total' => [ 'description' => __( 'Total amount for this fee.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'total_tax' => [ 'description' => __( 'Total tax amount for this fee.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], ] ), ], ]; } /** * Convert a WooCommerce cart fee to an object suitable for the response. * * @param array $fee Cart fee data. * @return array */ public function get_item_response( $fee ) { return [ 'key' => $fee->id, 'name' => $this->prepare_html_response( $fee->name ), 'totals' => (object) $this->prepare_currency_response( [ 'total' => $this->prepare_money_response( $fee->total, wc_get_price_decimals() ), 'total_tax' => $this->prepare_money_response( $fee->tax, wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ), ] ), ]; } } Schemas/V1/PatternsSchema.php 0000777 00000001241 15251730534 0012061 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\StoreApi\Schemas\V1; /** * OrderSchema class. */ class PatternsSchema extends AbstractSchema { /** * The schema item name. * * @var string */ protected $title = 'patterns'; /** * The schema item identifier. * * @var string */ const IDENTIFIER = 'patterns'; /** * Patterns schema properties. * * @return array */ public function get_properties() { return []; } /** * Get the Patterns response. * * @param array $item Item to get response for. * * @return array */ public function get_item_response( $item ) { return [ 'success' => true, ]; } } Formatters.php 0000777 00000002327 15251730534 0007423 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi; use \Exception; use Automattic\WooCommerce\StoreApi\Formatters\DefaultFormatter; /** * Formatters class. * * Allows formatter classes to be registered. Formatters are exposed to extensions via the ExtendSchema class. */ class Formatters { /** * Holds an array of formatter class instances. * * @var array */ private $formatters = []; /** * Get a new instance of a formatter class. * * @throws Exception An Exception is thrown if a non-existing formatter is used and the user is admin. * * @param string $name Name of the formatter. * @return FormatterInterface Formatter class instance. */ public function __get( $name ) { if ( ! isset( $this->formatters[ $name ] ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG && current_user_can( 'manage_woocommerce' ) ) { throw new Exception( $name . ' formatter does not exist' ); } return new DefaultFormatter(); } return $this->formatters[ $name ]; } /** * Register a formatter class for usage. * * @param string $name Name of the formatter. * @param string $class A formatter class name. */ public function register( $name, $class ) { $this->formatters[ $name ] = new $class(); } } Utilities/DraftOrderTrait.php 0000777 00000003417 15251730534 0012311 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; /** * DraftOrderTrait * * Shared functionality for getting and setting draft order IDs from session. */ trait DraftOrderTrait { /** * Gets draft order data from the customer session. * * @return integer */ protected function get_draft_order_id() { if ( ! wc()->session ) { wc()->initialize_session(); } return wc()->session->get( 'store_api_draft_order', 0 ); } /** * Updates draft order data in the customer session. * * @param integer $order_id Draft order ID. */ protected function set_draft_order_id( $order_id ) { if ( ! wc()->session ) { wc()->initialize_session(); } wc()->session->set( 'store_api_draft_order', $order_id ); } /** * Uses the draft order ID to return an order object, if valid. * * @return \WC_Order|null; */ protected function get_draft_order() { $draft_order_id = $this->get_draft_order_id(); $draft_order = $draft_order_id ? wc_get_order( $draft_order_id ) : false; return $this->is_valid_draft_order( $draft_order ) ? $draft_order : null; } /** * Whether the passed argument is a draft order or an order that is * pending/failed and the cart hasn't changed. * * @param \WC_Order $order_object Order object to check. * @return boolean Whether the order is valid as a draft order. */ protected function is_valid_draft_order( $order_object ) { if ( ! $order_object instanceof \WC_Order ) { return false; } // Draft orders are okay. if ( $order_object->has_status( 'checkout-draft' ) ) { return true; } // Pending and failed orders can be retried if the cart hasn't changed. if ( $order_object->needs_payment() && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) { return true; } return false; } } Utilities/RateLimits.php 0000777 00000014221 15251730534 0011321 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; use WC_Rate_Limiter; use WC_Cache_Helper; /** * RateLimits class. */ class RateLimits extends WC_Rate_Limiter { /** * Cache group. */ const CACHE_GROUP = 'store_api_rate_limit'; /** * Rate limiting enabled default value. * * @var boolean */ const ENABLED = false; /** * Proxy support enabled default value. * * @var boolean */ const PROXY_SUPPORT = false; /** * Default amount of max requests allowed for the defined timeframe. * * @var int */ const LIMIT = 25; /** * Default time in seconds before rate limits are reset. * * @var int */ const SECONDS = 10; /** * Gets a cache prefix. * * @param string $action_id Identifier of the action. * @return string */ protected static function get_cache_key( $action_id ): string { return WC_Cache_Helper::get_cache_prefix( 'store_api_rate_limit' . $action_id ); } /** * Get current rate limit row from DB and normalize types. This query is not cached, and returns * a new rate limit row if none exists. * * @param string $action_id Identifier of the action. * * @return object Object containing reset and remaining. */ protected static function get_rate_limit_row( string $action_id ): object { global $wpdb; $time = time(); $row = $wpdb->get_row( $wpdb->prepare( " SELECT rate_limit_expiry as reset, rate_limit_remaining as remaining FROM {$wpdb->prefix}wc_rate_limits WHERE rate_limit_key = %s AND rate_limit_expiry > %s ", $action_id, $time ), 'OBJECT' ); if ( empty( $row ) ) { $options = self::get_options(); return (object) [ 'reset' => (int) $options->seconds + $time, 'remaining' => (int) $options->limit, ]; } return (object) [ 'reset' => (int) $row->reset, 'remaining' => (int) $row->remaining, ]; } /** * Returns current rate limit values using cache where possible. * * @param string $action_id Identifier of the action. * * @return object */ public static function get_rate_limit( string $action_id ): object { $current_limit = self::get_cached( $action_id ); if ( false === $current_limit ) { $current_limit = self::get_rate_limit_row( $action_id ); self::set_cache( $action_id, $current_limit ); } return $current_limit; } /** * If exceeded, seconds until reset. * * @param string $action_id Identifier of the action. * * @return bool|int */ public static function is_exceeded_retry_after( string $action_id ) { $current_limit = self::get_rate_limit( $action_id ); $time = time(); // Before the next run is allowed, retry forbidden. if ( $time <= (int) $current_limit->reset && 0 === (int) $current_limit->remaining ) { return (int) $current_limit->reset - $time; } // After the next run is allowed, retry allowed. return false; } /** * Sets the rate limit delay in seconds for action with identifier $id. * * @param string $action_id Identifier of the action. * * @return object Current rate limits. */ public static function update_rate_limit( string $action_id ): object { global $wpdb; $options = self::get_options(); $time = time(); $rate_limit_expiry = $time + (int) $options->seconds; $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}wc_rate_limits (`rate_limit_key`, `rate_limit_expiry`, `rate_limit_remaining`) VALUES (%s, %d, %d) ON DUPLICATE KEY UPDATE `rate_limit_remaining` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_remaining`), GREATEST(`rate_limit_remaining` - 1, 0)), `rate_limit_expiry` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_expiry`), `rate_limit_expiry`); ", $action_id, $rate_limit_expiry, (int) $options->limit - 1, $time, $time ) ); $current_limit = self::get_rate_limit_row( $action_id ); self::set_cache( $action_id, $current_limit ); return $current_limit; } /** * Retrieve a cached store api rate limit. * * @param string $action_id Identifier of the action. * @return false|object */ protected static function get_cached( $action_id ) { return wp_cache_get( self::get_cache_key( $action_id ), self::CACHE_GROUP ); } /** * Cache a rate limit. * * @param string $action_id Identifier of the action. * @param object $current_limit Current limit object with expiry and retries remaining. * @return bool */ protected static function set_cache( $action_id, $current_limit ): bool { return wp_cache_set( self::get_cache_key( $action_id ), $current_limit, self::CACHE_GROUP ); } /** * Return options for Rate Limits, to be returned by the "woocommerce_store_api_rate_limit_options" filter. * * @return object Default options. */ public static function get_options(): object { $default_options = [ /** * Filters the Store API rate limit check, which is disabled by default. * * This can be used also to disable the rate limit check when testing API endpoints via a REST API client. */ 'enabled' => self::ENABLED, /** * Filters whether proxy support is enabled for the Store API rate limit check. This is disabled by default. * * If the store is behind a proxy, load balancer, CDN etc. the user can enable this to properly obtain * the client's IP address through standard transport headers. */ 'proxy_support' => self::PROXY_SUPPORT, 'limit' => self::LIMIT, 'seconds' => self::SECONDS, ]; return (object) array_merge( // By using array_merge we ensure we get a properly populated options object. $default_options, /** * Filters options for Rate Limits. * * @param array $rate_limit_options Array of option values. * @return array * * @since 8.9.0 */ apply_filters( 'woocommerce_store_api_rate_limit_options', $default_options ) ); } /** * Gets a single option through provided name. * * @param string $option Option name. * * @return mixed */ public static function get_option( $option ) { if ( ! is_string( $option ) || ! defined( 'RateLimits::' . strtoupper( $option ) ) ) { return null; } return self::get_options()[ $option ]; } } Utilities/ProductQuery.php 0000777 00000046403 15251730534 0011721 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\Enums\ProductStatus; use Automattic\WooCommerce\Enums\ProductType; use Automattic\WooCommerce\Enums\CatalogVisibility; use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator; use WC_Tax; /** * Product Query class. * * Helper class to handle product queries for the API. */ class ProductQuery implements QueryClausesGenerator { /** * Prepare query args to pass to WP_Query for a REST API request. * * @param \WP_REST_Request $request Request data. * @return array */ public function prepare_objects_query( $request ) { $args = array( 'offset' => $request['offset'], 'order' => $request['order'], 'orderby' => $request['orderby'], 'paged' => $request['page'], 'post__in' => $request['include'], 'post__not_in' => $request['exclude'], 'posts_per_page' => $request['per_page'] ? $request['per_page'] : -1, 'post_parent__in' => $request['parent'], 'post_parent__not_in' => $request['parent_exclude'], 'search' => $request['search'], // This uses search rather than s intentionally to handle searches internally. 'slug' => $request['slug'], 'fields' => 'ids', 'ignore_sticky_posts' => true, 'post_status' => ProductStatus::PUBLISH, 'date_query' => array(), 'post_type' => 'product', ); // If searching for a specific SKU or slug, allow any post type. if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) { $args['post_type'] = array( 'product', 'product_variation' ); } // Taxonomy query to filter products by type, category, tag, shipping class, and attribute. $tax_query = array(); // Filter product type by slug. if ( ! empty( $request['type'] ) ) { if ( ProductType::VARIATION === $request['type'] ) { $args['post_type'] = 'product_variation'; } else { $args['post_type'] = 'product'; $tax_query[] = array( 'taxonomy' => 'product_type', 'field' => 'slug', 'terms' => $request['type'], ); } } if ( 'date' === $args['orderby'] ) { $args['orderby'] = 'date ID'; } // Set before into date query. Date query must be specified as an array of an array. if ( isset( $request['before'] ) ) { $args['date_query'][0]['before'] = $request['before']; } // Set after into date query. Date query must be specified as an array of an array. if ( isset( $request['after'] ) ) { $args['date_query'][0]['after'] = $request['after']; } // Set date query column. Defaults to post_date. if ( isset( $request['date_column'] ) && ! empty( $args['date_query'][0] ) ) { $args['date_query'][0]['column'] = 'post_' . $request['date_column']; } // Set custom args to handle later during clauses. $custom_keys = array( 'sku', 'min_price', 'max_price', 'stock_status', ); foreach ( $custom_keys as $key ) { if ( ! empty( $request[ $key ] ) ) { $args[ $key ] = $request[ $key ]; } } $operator_mapping = array( 'in' => 'IN', 'not_in' => 'NOT IN', 'and' => 'AND', ); // Gets all registered product taxonomies and prefixes them with `tax_`. // This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field. // eg an `sku` taxonomy will be mapped to `tax_sku`. $all_product_taxonomies = array_map( function ( $value ) { return '_unstable_tax_' . $value; }, get_taxonomies( array( 'object_type' => array( 'product' ) ), 'names' ) ); // Map between taxonomy name and arg key. $default_taxonomies = array( 'product_cat' => 'category', 'product_tag' => 'tag', 'product_brand' => 'brand', ); $taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies ); // Set tax_query for each passed arg. foreach ( $taxonomies as $taxonomy => $key ) { if ( ! empty( $request[ $key ] ) ) { $type = is_numeric( $request[ $key ][0] ) ? 'term_id' : 'slug'; $operator = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN'; $tax_query[] = array( 'taxonomy' => $taxonomy, 'field' => $type, 'terms' => $request[ $key ], 'operator' => $operator, ); } } // Filter by attributes. if ( ! empty( $request['attributes'] ) ) { $att_queries = array(); foreach ( $request['attributes'] as $attribute ) { if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) { continue; } if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) { $operator = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN'; $att_queries[] = array( 'taxonomy' => $attribute['attribute'], 'field' => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug', 'terms' => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'], 'operator' => $operator, ); } } if ( 1 < count( $att_queries ) ) { // Add relation arg when using multiple attributes. $relation = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN'; $tax_query[] = array( 'relation' => $relation, $att_queries, ); } else { $tax_query = array_merge( $tax_query, $att_queries ); } } // Build tax_query if taxonomies are set. if ( ! empty( $tax_query ) && 'product_variation' !== $args['post_type'] ) { if ( ! empty( $args['tax_query'] ) ) { $args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore } else { $args['tax_query'] = $tax_query; // phpcs:ignore } } else { // For product_variantions we need to convert the tax_query to a meta_query. if ( ! empty( $args['tax_query'] ) ) { $args['meta_query'] = $this->convert_tax_query_to_meta_query( array_merge( $tax_query, $args['tax_query'] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query } else { $args['meta_query'] = $this->convert_tax_query_to_meta_query( $tax_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query } } // Filter featured. if ( is_bool( $request['featured'] ) ) { $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => 'featured', 'operator' => true === $request['featured'] ? 'IN' : 'NOT IN', ); } // Filter by on sale products. if ( is_bool( $request['on_sale'] ) ) { $on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in'; $on_sale_ids = wc_get_product_ids_on_sale(); // Use 0 when there's no on sale products to avoid return all products. $on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids; $args[ $on_sale_key ] += $on_sale_ids; } $catalog_visibility = $request->get_param( 'catalog_visibility' ); $rating = $request->get_param( 'rating' ); $visibility_options = wc_get_product_visibility_options(); if ( in_array( $catalog_visibility, array_keys( $visibility_options ), true ) ) { $exclude_from_catalog = CatalogVisibility::SEARCH === $catalog_visibility ? '' : 'exclude-from-catalog'; $exclude_from_search = CatalogVisibility::CATALOG === $catalog_visibility ? '' : 'exclude-from-search'; $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => array( $exclude_from_catalog, $exclude_from_search ), 'operator' => CatalogVisibility::HIDDEN === $catalog_visibility ? 'AND' : 'NOT IN', 'rating_filter' => true, ); } if ( $rating ) { $rating_terms = array(); foreach ( $rating as $value ) { $rating_terms[] = 'rated-' . $value; } $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => $rating_terms, ); } $orderby = $request->get_param( 'orderby' ); $order = $request->get_param( 'order' ); $ordering_args = wc()->query->get_catalog_ordering_args( $orderby, $order ); $args['orderby'] = $ordering_args['orderby']; $args['order'] = $ordering_args['order']; if ( 'include' === $orderby ) { $args['orderby'] = 'post__in'; } elseif ( 'id' === $orderby ) { $args['orderby'] = 'ID'; // ID must be capitalized. } elseif ( 'slug' === $orderby ) { $args['orderby'] = 'name'; } if ( $ordering_args['meta_key'] ) { $args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore } return $args; } /** * Convert the tax_query to a meta_query which is needed to support filtering by attributes for variations. * * @param array $tax_query The tax_query to convert. * @return array */ public function convert_tax_query_to_meta_query( $tax_query ) { $meta_query = array(); foreach ( $tax_query as $tax_query_item ) { $taxonomy = $tax_query_item['taxonomy']; $terms = $tax_query_item['terms']; $meta_key = 'attribute_' . $taxonomy; $meta_query[] = array( 'key' => $meta_key, 'value' => $terms, ); if ( isset( $tax_query_item['operator'] ) ) { $meta_query[0]['compare'] = $tax_query_item['operator']; } } return $meta_query; } /** * Get results of query. * * @param \WP_REST_Request $request Request data. * @return array */ public function get_results( $request ) { $query_args = $this->prepare_objects_query( $request ); add_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10, 2 ); $query = new \WP_Query(); $results = $query->query( $query_args ); $total_posts = $query->found_posts; // Out-of-bounds, run the query again without LIMIT for total count. if ( $total_posts < 1 && $query_args['paged'] > 1 ) { unset( $query_args['paged'] ); $count_query = new \WP_Query(); $count_query->query( $query_args ); $total_posts = $count_query->found_posts; } remove_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10 ); return array( 'results' => $results, 'total' => (int) $total_posts, 'pages' => $query->query_vars['posts_per_page'] > 0 ? (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ) : 1, ); } /** * Get objects. * * @param \WP_REST_Request $request Request data. * @return array */ public function get_objects( $request ) { $results = $this->get_results( $request ); if ( is_callable( '_prime_post_caches' ) ) { _prime_post_caches( $results['results'] ); } return array( 'objects' => array_map( 'wc_get_product', $results['results'] ), 'total' => $results['total'], 'pages' => $results['pages'], ); } /** * Get last modified date for all products. * * @return int timestamp. */ public function get_last_modified() { global $wpdb; $last_modified = $wpdb->get_var( "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' );" ); return $last_modified ? strtotime( $last_modified ) : null; } /** * Add in conditional search filters for products. * * @param array $args Query args. * @param \WP_Query $wp_query WP_Query object. * @return array */ public function add_query_clauses( array $args, \WP_Query $wp_query ): array { global $wpdb; if ( $wp_query->get( 'search' ) ) { $search = '%' . $wpdb->esc_like( $wp_query->get( 'search' ) ) . '%'; $search_query = wc_product_sku_enabled() ? $wpdb->prepare( " AND ( $wpdb->posts.post_title LIKE %s OR wc_product_meta_lookup.sku LIKE %s ) ", $search, $search ) : $wpdb->prepare( " AND $wpdb->posts.post_title LIKE %s ", $search ); $args['where'] .= $search_query; $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); } if ( $wp_query->get( 'sku' ) ) { $skus = explode( ',', $wp_query->get( 'sku' ) ); // Include the current string as a SKU too. if ( 1 < count( $skus ) ) { $skus[] = $wp_query->get( 'sku' ); } $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); $args['where'] .= ' AND wc_product_meta_lookup.sku IN (\'' . implode( '\',\'', array_map( 'esc_sql', $skus ) ) . '\')'; } if ( $wp_query->get( 'slug' ) ) { $slugs = explode( ',', $wp_query->get( 'slug' ) ); // Include the current string as a slug too. if ( 1 < count( $slugs ) ) { $slugs[] = $wp_query->get( 'slug' ); } $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); $post_name__in = implode( '","', array_map( 'esc_sql', $slugs ) ); $args['where'] .= " AND $wpdb->posts.post_name IN (\"$post_name__in\")"; } if ( $wp_query->get( 'stock_status' ) ) { $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); $args['where'] .= ' AND wc_product_meta_lookup.stock_status IN (\'' . implode( '\',\'', array_map( 'esc_sql', $wp_query->get( 'stock_status' ) ) ) . '\')'; } elseif ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) { $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); $args['where'] .= ' AND wc_product_meta_lookup.stock_status NOT IN (\'outofstock\')'; } if ( $wp_query->get( 'min_price' ) || $wp_query->get( 'max_price' ) ) { $args = $this->add_price_filter_clauses( $args, $wp_query ); } return $args; } /** * Add in conditional price filters. * * @param array $args Query args. * @param \WC_Query $wp_query WC_Query object. * @return array */ protected function add_price_filter_clauses( $args, $wp_query ) { global $wpdb; $adjust_for_taxes = $this->adjust_price_filters_for_displayed_taxes(); $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); if ( $wp_query->get( 'min_price' ) ) { $min_price_filter = $this->prepare_price_filter( $wp_query->get( 'min_price' ) ); if ( $adjust_for_taxes ) { $args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'max_price', '>=' ); } else { $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price >= %f ', $min_price_filter ); } } if ( $wp_query->get( 'max_price' ) ) { $max_price_filter = $this->prepare_price_filter( $wp_query->get( 'max_price' ) ); if ( $adjust_for_taxes ) { $args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'min_price', '<=' ); } else { $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price <= %f ', $max_price_filter ); } } return $args; } /** * Get query for price filters when dealing with displayed taxes. * * @param float $price_filter Price filter to apply. * @param string $column Price being filtered (min or max). * @param string $operator Comparison operator for column. * @return string Constructed query. */ protected function get_price_filter_query_for_displayed_taxes( $price_filter, $column = 'min_price', $operator = '>=' ) { global $wpdb; // Select only used tax classes to avoid unwanted calculations. $product_tax_classes = $wpdb->get_col( "SELECT DISTINCT tax_class FROM {$wpdb->wc_product_meta_lookup};" ); if ( empty( $product_tax_classes ) ) { return ''; } $or_queries = array(); // We need to adjust the filter for each possible tax class and combine the queries into one. foreach ( $product_tax_classes as $tax_class ) { $adjusted_price_filter = $this->adjust_price_filter_for_tax_class( $price_filter, $tax_class ); $or_queries[] = $wpdb->prepare( '( wc_product_meta_lookup.tax_class = %s AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )', $tax_class, $adjusted_price_filter ); } // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared return $wpdb->prepare( ' AND ( wc_product_meta_lookup.tax_status = "taxable" AND ( 0=1 OR ' . implode( ' OR ', $or_queries ) . ') OR ( wc_product_meta_lookup.tax_status != "taxable" AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f ) ) ', $price_filter ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared } /** * If price filters need adjustment to work with displayed taxes, this returns true. * * This logic is used when prices are stored in the database differently to how they are being displayed, with regards * to taxes. * * @return boolean */ protected function adjust_price_filters_for_displayed_taxes() { $display = get_option( 'woocommerce_tax_display_shop' ); $database = wc_prices_include_tax() ? 'incl' : 'excl'; return $display !== $database; } /** * Converts price filter from subunits to decimal. * * @param string|int $price_filter Raw price filter in subunit format. * @return float Price filter in decimal format. */ protected function prepare_price_filter( $price_filter ) { return floatval( $price_filter / ( 10 ** wc_get_price_decimals() ) ); } /** * Adjusts a price filter based on a tax class and whether or not the amount includes or excludes taxes. * * This calculation logic is based on `wc_get_price_excluding_tax` and `wc_get_price_including_tax` in core. * * @param float $price_filter Price filter amount as entered. * @param string $tax_class Tax class for adjustment. * @return float */ protected function adjust_price_filter_for_tax_class( $price_filter, $tax_class ) { $tax_display = get_option( 'woocommerce_tax_display_shop' ); $tax_rates = WC_Tax::get_rates( $tax_class ); $base_tax_rates = WC_Tax::get_base_tax_rates( $tax_class ); // If prices are shown incl. tax, we want to remove the taxes from the filter amount to match prices stored excl. tax. if ( 'incl' === $tax_display ) { /** * Filters if taxes should be removed from locations outside the store base location. * * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing * with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10 * regardless of location and taxes. * * @since 2.6.0 * * @internal Matches filter name in WooCommerce core. * * @param boolean $adjust_non_base_location_prices True by default. * @return boolean */ $taxes = apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ? WC_Tax::calc_tax( $price_filter, $base_tax_rates, true ) : WC_Tax::calc_tax( $price_filter, $tax_rates, true ); return $price_filter - array_sum( $taxes ); } // If prices are shown excl. tax, add taxes to match the prices stored in the DB. $taxes = WC_Tax::calc_tax( $price_filter, $tax_rates, false ); return $price_filter + array_sum( $taxes ); } /** * Join wc_product_meta_lookup to posts if not already joined. * * @param string $sql SQL join. * @return string */ protected function append_product_sorting_table_join( $sql ) { global $wpdb; if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) { $sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; } return $sql; } } Utilities/CheckoutTrait.php 0000777 00000025504 15251730534 0012023 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\Jetpack\Constants; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Payments\PaymentContext; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject; use Automattic\WooCommerce\Admin\Features\Features; use WC_Customer; /** * CheckoutTrait * * Shared functionality for checkout route. */ trait CheckoutTrait { /** * Prepare a single item for response. Handles setting the status based on the payment result. * * @param mixed $item Item to format to schema. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, \WP_REST_Request $request ) { $response = parent::prepare_item_for_response( $item, $request ); $status_codes = [ 'success' => 200, 'pending' => 202, 'failure' => 400, 'error' => 500, ]; if ( isset( $item->payment_result ) && $item->payment_result instanceof PaymentResult ) { $response->set_status( $status_codes[ $item->payment_result->status ] ?? 200 ); } return $response; } /** * For orders which do not require payment, just update status. * * @param \WP_REST_Request $request Request object. * @param PaymentResult $payment_result Payment result object. */ private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) { $this->order->payment_complete(); // Mark the payment as successful. $payment_result->set_status( 'success' ); $payment_result->set_redirect_url( $this->order->get_checkout_order_received_url() ); } /** * Fires an action hook instructing active payment gateways to process the payment for an order and provide a result. * * @throws RouteException On error. * * @param \WP_REST_Request $request Request object. * @param PaymentResult $payment_result Payment result object. */ private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) { try { // Prepare the payment context object to pass through payment hooks. $context = new PaymentContext(); $context->set_payment_method( $this->get_request_payment_method_id( $request ) ); $context->set_payment_data( $this->get_request_payment_data( $request ) ); $context->set_order( $this->order ); /** * Process payment with context. * * @hook woocommerce_rest_checkout_process_payment_with_context * * @throws \Exception If there is an error taking payment, an \Exception object can be thrown with an error message. * * @param PaymentContext $context Holds context for the payment, including order ID and payment method. * @param PaymentResult $payment_result Result object for the transaction. */ do_action_ref_array( 'woocommerce_rest_checkout_process_payment_with_context', [ $context, &$payment_result ] ); if ( ! $payment_result instanceof PaymentResult ) { throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woocommerce' ), 500 ); } } catch ( \Exception $e ) { $additional_data = []; // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment /** * Allows to check if WP_DEBUG mode is enabled before returning previous Exception. * * @param bool The WP_DEBUG mode. */ if ( apply_filters( 'woocommerce_return_previous_exceptions', Constants::is_true( 'WP_DEBUG' ) ) && $e->getPrevious() ) { $additional_data = [ 'previous' => get_class( $e->getPrevious() ), ]; } throw new RouteException( 'woocommerce_rest_checkout_process_payment_error', esc_html( $e->getMessage() ), 400, array_map( 'esc_attr', $additional_data ) ); } } /** * Gets the chosen payment method ID from the request. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return string */ private function get_request_payment_method_id( \WP_REST_Request $request ) { $payment_method = $this->get_request_payment_method( $request ); return is_null( $payment_method ) ? '' : $payment_method->id; } /** * Gets and formats payment request data. * * @param \WP_REST_Request $request Request object. * @return array */ private function get_request_payment_data( \WP_REST_Request $request ) { static $payment_data = []; if ( ! empty( $payment_data ) ) { return $payment_data; } if ( ! empty( $request['payment_data'] ) ) { foreach ( $request['payment_data'] as $data ) { $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); } } return $payment_data; } /** * Update the current order using the posted values from the request. * * @param \WP_REST_Request $request Full details about the request. */ private function update_order_from_request( \WP_REST_Request $request ) { $this->order->set_customer_note( wc_sanitize_textarea( $request['customer_note'] ) ?? '' ); $payment_method = $this->get_request_payment_method( $request ); if ( null !== $payment_method ) { WC()->session->set( 'chosen_payment_method', $payment_method->id ); $this->order->set_payment_method( $payment_method->id ); $this->order->set_payment_method_title( $payment_method->title ); } elseif ( ! $this->order->needs_payment() ) { $this->order->set_payment_method( '' ); } wc_log_order_step( '[Store API #5::update_order_from_request] Set customer note and payment method', array( 'order_id' => $this->order->get_id(), 'payment' => $this->order->get_payment_method_title(), ) ); $this->persist_additional_fields_for_order( $request ); wc_log_order_step( '[Store API #5::update_order_from_request] Persisted additional fields', array( 'order_id' => $this->order->get_id(), 'payment' => $this->order->get_payment_method_title(), ) ); wc_do_deprecated_action( '__experimental_woocommerce_blocks_checkout_update_order_from_request', array( $this->order, $request, ), '6.3.0', 'woocommerce_store_api_checkout_update_order_from_request', 'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.' ); wc_do_deprecated_action( 'woocommerce_blocks_checkout_update_order_from_request', array( $this->order, $request, ), '7.2.0', 'woocommerce_store_api_checkout_update_order_from_request', 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.' ); /** * Fires when the Checkout Block/Store API updates an order's from the API request data. * * This hook gives extensions the chance to update orders based on the data in the request. This can be used in * conjunction with the ExtendSchema class to post custom data and then process it. * * @since 7.2.0 * * @param \WC_Order $order Order object. * @param \WP_REST_Request $request Full details about the request. */ do_action( 'woocommerce_store_api_checkout_update_order_from_request', $this->order, $request ); $this->order->save(); } /** * Gets the chosen payment method title from the request. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return string */ private function get_request_payment_method_title( \WP_REST_Request $request ) { $payment_method = $this->get_request_payment_method( $request ); return is_null( $payment_method ) ? '' : $payment_method->get_title(); } /** * Persist additional fields for the order after validating them. * * @param \WP_REST_Request $request Full details about the request. */ private function persist_additional_fields_for_order( \WP_REST_Request $request ) { if ( Features::is_enabled( 'experimental-blocks' ) ) { $document_object = $this->get_document_object_from_rest_request( $request ); $document_object->set_context( 'order' ); $additional_fields_order = $this->additional_fields_controller->get_contextual_fields_for_location( 'order', $document_object ); $additional_fields_contact = $this->additional_fields_controller->get_contextual_fields_for_location( 'contact', $document_object ); $additional_fields = array_merge( $additional_fields_order, $additional_fields_contact ); } else { $additional_fields_order = $this->additional_fields_controller->get_fields_for_location( 'order' ); $additional_fields_contact = $this->additional_fields_controller->get_fields_for_location( 'contact' ); $additional_fields = array_merge( $additional_fields_order, $additional_fields_contact ); } $field_values = (array) $request['additional_fields'] ?? []; foreach ( $additional_fields as $key => $field ) { if ( isset( $field_values[ $key ] ) ) { $this->additional_fields_controller->persist_field_for_order( $key, $field_values[ $key ], $this->order, 'other', false ); } } // The above logic sets visible fields, but not hidden fields. Unset the hidden fields here. $other_posted_field_values = array_diff_key( $field_values, $additional_fields ); foreach ( $other_posted_field_values as $key => $value ) { if ( $this->additional_fields_controller->is_field( $key ) ) { $this->additional_fields_controller->persist_field_for_order( $key, '', $this->order, 'other', false ); } } // We need to sync the customer additional fields with the order otherwise they will be overwritten on next page load. if ( 0 !== $this->order->get_customer_id() && get_current_user_id() === $this->order->get_customer_id() ) { $this->additional_fields_controller->sync_customer_additional_fields_with_order( $this->order, wc()->customer ); } } /** * Returns a document object from a REST request. * * @param \WP_REST_Request $request The REST request. * @return DocumentObject The document object or null if experimental blocks are not enabled. */ public function get_document_object_from_rest_request( \WP_REST_Request $request ) { return new DocumentObject( [ 'customer' => [ 'billing_address' => $request['billing_address'], 'shipping_address' => $request['shipping_address'], 'additional_fields' => array_intersect_key( $request['additional_fields'] ?? [], array_flip( $this->additional_fields_controller->get_contact_fields_keys() ) ), ], 'checkout' => [ 'payment_method' => $request['payment_method'], 'create_account' => $request['create_account'], 'customer_note' => $request['customer_note'], 'additional_fields' => array_intersect_key( $request['additional_fields'] ?? [], array_flip( $this->additional_fields_controller->get_order_fields_keys() ) ), ], ] ); } } Utilities/ProductItemTrait.php 0000777 00000006053 15251730534 0012513 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; /** * ProductItemTrait * * Shared functionality for formating product item data. */ trait ProductItemTrait { /** * Get an array of pricing data. * * @param \WC_Product $product Product instance. * @param string $tax_display_mode If returned prices are incl or excl of tax. * @return array */ protected function prepare_product_price_response( \WC_Product $product, $tax_display_mode = '' ) { $tax_display_mode = $this->get_tax_display_mode( $tax_display_mode ); $price_function = $this->get_price_function_from_tax_display_mode( $tax_display_mode ); $prices = parent::prepare_product_price_response( $product, $tax_display_mode ); // Add raw prices (prices with greater precision). $prices['raw_prices'] = array( 'precision' => wc_get_rounding_precision(), 'price' => $this->prepare_money_response( $price_function( $product ), wc_get_rounding_precision() ), 'regular_price' => $this->prepare_money_response( $price_function( $product, array( 'price' => $product->get_regular_price() ) ), wc_get_rounding_precision() ), 'sale_price' => $this->prepare_money_response( $price_function( $product, array( 'price' => $product->get_sale_price() ) ), wc_get_rounding_precision() ), ); return $prices; } /** * Format variation data, for example convert slugs such as attribute_pa_size to Size. * * @param array $variation_data Array of data from the cart. * @param \WC_Product $product Product data. * @return array */ protected function format_variation_data( $variation_data, $product ) { $return = array(); if ( ! is_iterable( $variation_data ) ) { return $return; } foreach ( $variation_data as $key => $value ) { $taxonomy = wc_attribute_taxonomy_name( str_replace( 'attribute_pa_', '', urldecode( $key ) ) ); if ( taxonomy_exists( $taxonomy ) ) { // If this is a term slug, get the term's nice name. $term = get_term_by( 'slug', $value, $taxonomy ); if ( ! is_wp_error( $term ) && $term && $term->name ) { $value = $term->name; } $label = wc_attribute_label( $taxonomy ); } else { /** * Filters the variation option name. * * Filters the variation option name for custom option slugs. * * @since 2.5.0 * * @internal Matches filter name in WooCommerce core. * * @param string $value The name to display. * @param null $unused Unused because this is not a variation taxonomy. * @param string $taxonomy Taxonomy or product attribute name. * @param \WC_Product $product Product data. * @return string */ $value = apply_filters( 'woocommerce_variation_option_name', $value, null, $taxonomy, $product ); $label = wc_attribute_label( str_replace( 'attribute_', '', $key ), $product ); } $return[] = array( 'raw_attribute' => $this->prepare_html_response( $key ), 'attribute' => $this->prepare_html_response( $label ), 'value' => $this->prepare_html_response( $value ), ); } return $return; } } Utilities/Pagination.php 0000777 00000004064 15251730534 0011341 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; /** * Pagination class. */ class Pagination { /** * Add pagination headers to a response object. * * @param \WP_REST_Response $response Reference to the response object. * @param \WP_REST_Request $request The request object. * @param int $total_items Total items found. * @param int $total_pages Total pages found. * @return \WP_REST_Response */ public function add_headers( $response, $request, $total_items, $total_pages ) { $response->header( 'X-WP-Total', $total_items ); $response->header( 'X-WP-TotalPages', $total_pages ); $current_page = $this->get_current_page( $request ); $link_base = $this->get_link_base( $request ); if ( $current_page > 1 ) { $previous_page = $current_page - 1; if ( $previous_page > $total_pages ) { $previous_page = $total_pages; } $this->add_page_link( $response, 'prev', $previous_page, $link_base ); } if ( $total_pages > $current_page ) { $this->add_page_link( $response, 'next', ( $current_page + 1 ), $link_base ); } return $response; } /** * Get current page. * * @param \WP_REST_Request $request The request object. * @return int Get the page from the request object. */ protected function get_current_page( $request ) { return (int) $request->get_param( 'page' ); } /** * Get base for links from the request object. * * @param \WP_REST_Request $request The request object. * @return string */ protected function get_link_base( $request ) { return esc_url( add_query_arg( $request->get_query_params(), rest_url( $request->get_route() ) ) ); } /** * Add a page link. * * @param \WP_REST_Response $response Reference to the response object. * @param string $name Page link name. e.g. prev. * @param int $page Page number. * @param string $link_base Base URL. */ protected function add_page_link( &$response, $name, $page, $link_base ) { $response->link_header( $name, add_query_arg( 'page', $page, $link_base ) ); } } Utilities/CartController.php 0000777 00000141666 15251730534 0012217 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\Checkout\Helpers\ReserveStock; use Automattic\WooCommerce\Enums\ProductStatus; use Automattic\WooCommerce\Enums\ProductType; use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException; use Automattic\WooCommerce\StoreApi\Exceptions\NotPurchasableException; use Automattic\WooCommerce\StoreApi\Exceptions\OutOfStockException; use Automattic\WooCommerce\StoreApi\Exceptions\PartialOutOfStockException; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Exceptions\TooManyInCartException; use Automattic\WooCommerce\Internal\FraudProtection\CartEventTracker; use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController; use Automattic\WooCommerce\StoreApi\Utilities\ArrayUtils; use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait; use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler; use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits; use WP_Error; /** * Woo Cart Controller class. * * Helper class to bridge the gap between the cart API and Woo core. */ class CartController { use DraftOrderTrait; /** * Makes the cart and sessions available to a route by loading them from core. */ public function load_cart() { if ( ! did_action( 'woocommerce_load_cart_from_session' ) ) { // Initialize the cart. wc_load_cart(); } // Load cart from session. $cart = $this->get_cart_instance(); $cart->cart_context = 'store-api'; $cart->get_cart(); } /** * Normalizes the cart by fixing any quantity violations. */ public function normalize_cart() { $quantity_limits = new QuantityLimits(); $cart_items = $this->get_cart_items(); foreach ( $cart_items as $cart_item ) { $normalized_qty = $quantity_limits->normalize_cart_item_quantity( $cart_item['quantity'], $cart_item ); if ( $normalized_qty !== $cart_item['quantity'] ) { try { $this->set_cart_item_quantity( $cart_item['key'], $normalized_qty ); } catch ( RouteException $e ) { // Ignore errors and continue. continue; } } } } /** * Gets the latest cart instance, and ensures totals have been calculated before returning. * * @return \WC_Cart */ public function get_cart_for_response() { return did_action( 'woocommerce_after_calculate_totals' ) ? $this->get_cart_instance() : $this->calculate_totals(); } /** * Recalculates the cart totals and returns the updated cart instance. * * @since 9.2.0 Calculate shipping was removed here because it's called already by calculate_totals. * * @return \WC_Cart */ public function calculate_totals() { $cart = $this->get_cart_instance(); $cart->get_cart(); $cart->calculate_fees(); $cart->calculate_totals(); return $cart; } /** * Based on the core cart class but returns errors rather than rendering notices directly. * * @todo Overriding the core add_to_cart method was necessary because core outputs notices when an item is added to * the cart. For us this would cause notices to build up and output on the store, out of context. Core would need * refactoring to split notices out from other cart actions. * * @throws RouteException Exception if invalid data is detected. * * @param array $request Add to cart request params. * @return string */ public function add_to_cart( $request ) { $cart = $this->get_cart_instance(); $request = wp_parse_args( $request, [ 'id' => 0, 'quantity' => 1, 'variation' => [], 'cart_item_data' => [], ] ); $request = $this->filter_request_data( $this->parse_variation_data( $request ) ); $product = $this->get_product_for_cart( $request ); $cart_id = $cart->generate_cart_id( $this->get_product_id( $product ), $this->get_variation_id( $product ), $request['variation'], $request['cart_item_data'] ); $quantity_limits = new QuantityLimits(); // If quantity was not passed, it should default to the minimum allowed quantity. if ( null === $request['quantity'] ) { $request['quantity'] = $quantity_limits->get_add_to_cart_limits( $product )['minimum']; } $this->validate_add_to_cart( $product, $request ); $existing_cart_id = $cart->find_product_in_cart( $cart_id ); $request_quantity = wc_stock_amount( $request['quantity'] ); if ( $existing_cart_id ) { $cart_item = $cart->cart_contents[ $existing_cart_id ]; $updated_quantity = $request_quantity + $cart_item['quantity']; $quantity_validation = $quantity_limits->validate_cart_item_quantity( $updated_quantity, $cart_item ); if ( is_wp_error( $quantity_validation ) ) { throw new RouteException( esc_html( $quantity_validation->get_error_code() ), esc_html( $quantity_validation->get_error_message() ), 400 ); } $cart->set_quantity( $existing_cart_id, $updated_quantity, true ); return $existing_cart_id; } // Normalize quantity. $add_to_cart_limits = $quantity_limits->get_add_to_cart_limits( $product ); if ( $add_to_cart_limits['maximum'] ) { $request_quantity = min( $request_quantity, $add_to_cart_limits['maximum'] ); } $request_quantity = max( $request_quantity, $add_to_cart_limits['minimum'] ); $request_quantity = $quantity_limits->limit_to_multiple( $request_quantity, $add_to_cart_limits['multiple_of'] ); /** * Filters the item being added to the cart. * * @since 2.5.0 * * @internal Matches filter name in WooCommerce core. * * @param array $cart_item_data Array of cart item data being added to the cart. * @param string $cart_id Id of the item in the cart. * @return array Updated cart item data. */ $cart->cart_contents[ $cart_id ] = apply_filters( 'woocommerce_add_cart_item', array_merge( $request['cart_item_data'], array( 'key' => $cart_id, 'product_id' => $this->get_product_id( $product ), 'variation_id' => $this->get_variation_id( $product ), 'variation' => $request['variation'], 'quantity' => $request_quantity, 'data' => $product, 'data_hash' => wc_get_cart_item_data_hash( $product ), ) ), $cart_id ); /** * Filters the entire cart contents when the cart changes. * * @since 2.5.0 * * @internal Matches filter name in WooCommerce core. * * @param array $cart_contents Array of all cart items. * @return array Updated array of all cart items. */ $cart->cart_contents = apply_filters( 'woocommerce_cart_contents_changed', $cart->cart_contents ); /** * Fires when an item is added to the cart. * * This hook fires when an item is added to the cart. This is triggered from the Store API in this context, but * WooCommerce core add to cart events trigger the same hook. * * @since 2.5.0 * * @internal Matches action name in WooCommerce core. * * @param string $cart_id ID of the item in the cart. * @param integer $product_id ID of the product added to the cart. * @param integer $request_quantity Quantity of the item added to the cart. * @param integer $variation_id Variation ID of the product added to the cart. * @param array $variation Array of variation data. * @param array $cart_item_data Array of other cart item data. */ do_action( 'woocommerce_add_to_cart', $cart_id, $this->get_product_id( $product ), $request_quantity, $this->get_variation_id( $product ), $request['variation'], $request['cart_item_data'] ); // Track cart event for fraud protection. if ( $product instanceof \WC_Product && wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() ) { wc_get_container()->get( CartEventTracker::class ) ->track_cart_item_added( $cart_id, $this->get_product_id( $product ), (int) $request_quantity, $this->get_variation_id( $product ) ); } return $cart_id; } /** * Based on core `set_quantity` method, but validates if an item is sold individually first and enforces any limits in * place. * * @throws RouteException Exception if invalid data is detected. * * @param string $item_id Cart item id. * @param int|float $quantity Cart quantity. */ public function set_cart_item_quantity( $item_id, $quantity = 1 ) { $cart_item = $this->get_cart_item( $item_id ); if ( empty( $cart_item ) ) { throw new RouteException( 'woocommerce_rest_cart_invalid_key', esc_html__( 'Cart item does not exist.', 'woocommerce' ), 409 ); } $product = $cart_item['data'] ?? false; if ( ! $product instanceof \WC_Product ) { throw new RouteException( 'woocommerce_rest_cart_invalid_product', esc_html__( 'Cart item is invalid.', 'woocommerce' ), 404 ); } $quantity_validation = ( new QuantityLimits() )->validate_cart_item_quantity( $quantity, $cart_item ); if ( is_wp_error( $quantity_validation ) ) { throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } $cart = $this->get_cart_instance(); $cart->set_quantity( $item_id, $quantity ); } /** * Validate all items in the cart and check for errors. * * @throws RouteException Exception if invalid data is detected. * * @param \WC_Product $product Product object associated with the cart item. * @param array $request Add to cart request params. */ public function validate_add_to_cart( \WC_Product $product, $request ) { if ( ! $product->is_purchasable() ) { $this->throw_default_product_exception( $product ); } if ( floatval( $request['quantity'] ) <= 0 ) { throw new RouteException( 'woocommerce_rest_product_invalid_quantity', sprintf( /* translators: %s: product name */ esc_html__( 'You cannot add "%s" with a quantity less than or equal to 0 to the cart.', 'woocommerce' ), esc_html( $product->get_name() ) ), 400 ); } if ( ! $product->is_in_stock() ) { throw new RouteException( 'woocommerce_rest_product_out_of_stock', sprintf( /* translators: %s: product name */ esc_html__( 'You cannot add "%s" to the cart because the product is out of stock.', 'woocommerce' ), $product->get_name() ), 400 ); } if ( $product->managing_stock() && ! $product->backorders_allowed() ) { $request_quantity = wc_stock_amount( $request['quantity'] ); $qty_remaining = $this->get_remaining_stock_for_product( $product ); $qty_in_cart = $this->get_product_quantity_in_cart( $product ); if ( $qty_remaining < $qty_in_cart + $request_quantity ) { throw new RouteException( 'woocommerce_rest_product_partially_out_of_stock', sprintf( /* translators: 1: product name 2: quantity in stock */ esc_html__( 'You cannot add that amount of "%1$s" to the cart because there is not enough stock (%2$s remaining).', 'woocommerce' ), $product->get_name(), wc_format_stock_quantity_for_display( $qty_remaining, $product ) ), 400 ); } } /** * Filters if an item being added to the cart passed validation checks. * * Allow 3rd parties to validate if an item can be added to the cart. This is a legacy hook from Woo core. * This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture * notices and convert to exceptions instead. * * @since 7.2.0 * * @deprecated * @param boolean $passed_validation True if the item passed validation. * @param integer $product_id Product ID being validated. * @param integer $quantity Quantity added to the cart. * @param integer $variation_id Variation ID being added to the cart. * @param array $variation Variation data. * @return boolean */ $passed_validation = apply_filters( 'woocommerce_add_to_cart_validation', true, $this->get_product_id( $product ), $request['quantity'], $this->get_variation_id( $product ), $request['variation'], $request['cart_item_data'] ); if ( ! $passed_validation ) { // Validation did not pass - see if an error notice was thrown. NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_add_to_cart_error' ); // If no notice was thrown, throw the default notice instead. $this->throw_default_product_exception( $product ); } /** * Fires during validation when adding an item to the cart via the Store API. * * @param \WC_Product $product Product object being added to the cart. * @param array $request Add to cart request params including id, quantity, and variation attributes. * @deprecated 7.1.0 Use woocommerce_store_api_validate_add_to_cart instead. */ wc_do_deprecated_action( 'wooocommerce_store_api_validate_add_to_cart', array( $product, $request, ), '7.1.0', 'woocommerce_store_api_validate_add_to_cart', 'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_add_to_cart instead.' ); /** * Fires during validation when adding an item to the cart via the Store API. * * Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent * add to cart from happening. * * @since 7.1.0 * * @param \WC_Product $product Product object being added to the cart. * @param array $request Add to cart request params including id, quantity, and variation attributes. */ do_action( 'woocommerce_store_api_validate_add_to_cart', $product, $request ); } /** * Generates the error message for out of stock products and adds product names to it. * * @param string $singular The message to use when only one product is in the list. * @param string $plural The message to use when more than one product is in the list. * @param array $items The list of cart items whose names should be inserted into the message. * @returns string The translated and correctly pluralised message. */ private function add_product_names_to_message( $singular, $plural, $items ) { $product_names = wc_list_pluck( $items, 'getProductName' ); $message = ( count( $items ) > 1 ) ? $plural : $singular; return sprintf( $message, ArrayUtils::natural_language_join( $product_names, true ) ); } /** * Takes a string describing the type of stock extension, whether there is a single product or multiple products * causing this exception and returns an appropriate error message. * * @param string $exception_type The type of exception encountered. * @param string $singular_or_plural Whether to get the error message for a single product or multiple. * * @return string */ private function get_error_message_for_stock_exception_type( $exception_type, $singular_or_plural ) { $stock_error_messages = [ 'out_of_stock' => [ /* translators: %s: product name. */ 'singular' => esc_html__( '%s is out of stock and cannot be purchased. Please remove it from your cart.', 'woocommerce' ), /* translators: %s: product names. */ 'plural' => esc_html__( '%s are out of stock and cannot be purchased. Please remove them from your cart.', 'woocommerce' ), ], 'not_purchasable' => [ /* translators: %s: product name. */ 'singular' => esc_html__( '%s cannot be purchased. Please remove it from your cart.', 'woocommerce' ), /* translators: %s: product names. */ 'plural' => esc_html__( '%s cannot be purchased. Please remove them from your cart.', 'woocommerce' ), ], 'too_many_in_cart' => [ /* translators: %s: product names. */ 'singular' => esc_html__( 'There are too many %s in the cart. Only 1 can be purchased. Please reduce the quantity in your cart.', 'woocommerce' ), /* translators: %s: product names. */ 'plural' => esc_html__( 'There are too many %s in the cart. Only 1 of each can be purchased. Please reduce the quantities in your cart.', 'woocommerce' ), ], 'partial_out_of_stock' => [ /* translators: %s: product names. */ 'singular' => esc_html__( 'There is not enough %s in stock. Please reduce the quantity in your cart.', 'woocommerce' ), /* translators: %s: product names. */ 'plural' => esc_html__( 'There are not enough %s in stock. Please reduce the quantities in your cart.', 'woocommerce' ), ], ]; if ( isset( $stock_error_messages[ $exception_type ] ) && isset( $stock_error_messages[ $exception_type ][ $singular_or_plural ] ) ) { return $stock_error_messages[ $exception_type ][ $singular_or_plural ]; } return esc_html__( 'There was an error with an item in your cart.', 'woocommerce' ); } /** * Validate cart and check for errors. * * @throws InvalidCartException Exception if invalid data is detected in the cart. */ public function validate_cart() { $this->validate_cart_items(); $this->validate_cart_coupons(); $cart = $this->get_cart_instance(); $cart_errors = new WP_Error(); /** * Fires an action to validate the cart. * * Functions hooking into this should add custom errors using the provided WP_Error instance. * * @since 7.2.0 * * @example See docs/examples/validate-cart.md * * @param \WP_Error $errors WP_Error object. * @param \WC_Cart $cart Cart object. */ do_action( 'woocommerce_store_api_cart_errors', $cart_errors, $cart ); if ( $cart_errors->has_errors() ) { throw new InvalidCartException( 'woocommerce_cart_error', $cart_errors, 409 ); } // Before running the woocommerce_check_cart_items hook, unhook validation from the core cart. remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_items' ), 1 ); remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_coupons' ), 1 ); // Before running actions, store notices. $previous_notices = WC()->session->get( 'wc_notices' ); /** * Fires when cart items are being validated. * * Allow 3rd parties to validate cart items. This is a legacy hook from Woo core. * This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture * notices and convert to wp errors instead. * * @since 7.2.0 * * @deprecated * @internal Matches action name in WooCommerce core. */ do_action( 'woocommerce_check_cart_items' ); $cart_errors = NoticeHandler::convert_notices_to_wp_errors( 'woocommerce_rest_cart_item_error' ); // Restore notices. WC()->session->set( 'wc_notices', $previous_notices ); if ( $cart_errors->has_errors() ) { throw new InvalidCartException( 'woocommerce_cart_error', $cart_errors, 409 ); } } /** * When placing an order, validate that the cart is not empty. * * @throws InvalidCartException Exception if the cart is empty. */ public function validate_cart_not_empty() { $cart_items = $this->get_cart_items(); if ( empty( $cart_items ) ) { throw new InvalidCartException( 'woocommerce_cart_error', // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Errors are converted to response objects later. new WP_Error( 'woocommerce_rest_cart_empty', esc_html__( 'Cannot place an order, your cart is empty.', 'woocommerce' ), 400 ), 400 ); } } /** * Validate all items in the cart and check for errors. * * @throws InvalidCartException Exception if invalid data is detected due to insufficient stock levels. */ public function validate_cart_items() { $cart_items = $this->get_cart_items(); $errors = []; $out_of_stock_products = []; $too_many_in_cart_products = []; $partial_out_of_stock_products = []; $not_purchasable_products = []; foreach ( $cart_items as $cart_item ) { try { $this->validate_cart_item( $cart_item ); } catch ( RouteException $error ) { $errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() ); } catch ( TooManyInCartException $error ) { $too_many_in_cart_products[] = $error; } catch ( NotPurchasableException $error ) { $not_purchasable_products[] = $error; } catch ( PartialOutOfStockException $error ) { $partial_out_of_stock_products[] = $error; } catch ( OutOfStockException $error ) { $out_of_stock_products[] = $error; } } if ( count( $errors ) > 0 ) { $error = new WP_Error(); foreach ( $errors as $wp_error ) { $error->merge_from( $wp_error ); } throw new InvalidCartException( 'woocommerce_cart_error', $error, 409 ); } $error = $this->stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products ); if ( $error->has_errors() ) { throw new InvalidCartException( 'woocommerce_stock_availability_error', $error, 409 ); } } /** * This method will take arrays of exceptions relating to stock, and will convert them to a WP_Error object. * * @param TooManyInCartException[] $too_many_in_cart_products Array of TooManyInCartExceptions. * @param NotPurchasableException[] $not_purchasable_products Array of NotPurchasableExceptions. * @param PartialOutOfStockException[] $partial_out_of_stock_products Array of PartialOutOfStockExceptions. * @param OutOfStockException[] $out_of_stock_products Array of OutOfStockExceptions. * * @return WP_Error The WP_Error object returned. Will have errors if any exceptions were in the args. It will be empty if they do not. */ private function stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products ) { $error = new WP_Error(); if ( count( $out_of_stock_products ) > 0 ) { $singular_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'singular' ); $plural_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'plural' ); $error->add( 'woocommerce_rest_product_out_of_stock', $this->add_product_names_to_message( $singular_error, $plural_error, $out_of_stock_products ) ); } if ( count( $not_purchasable_products ) > 0 ) { $singular_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'singular' ); $plural_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'plural' ); $error->add( 'woocommerce_rest_product_not_purchasable', $this->add_product_names_to_message( $singular_error, $plural_error, $not_purchasable_products ) ); } if ( count( $too_many_in_cart_products ) > 0 ) { $singular_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'singular' ); $plural_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'plural' ); $error->add( 'woocommerce_rest_product_too_many_in_cart', $this->add_product_names_to_message( $singular_error, $plural_error, $too_many_in_cart_products ) ); } if ( count( $partial_out_of_stock_products ) > 0 ) { $singular_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'singular' ); $plural_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'plural' ); $error->add( 'woocommerce_rest_product_partially_out_of_stock', $this->add_product_names_to_message( $singular_error, $plural_error, $partial_out_of_stock_products ) ); } return $error; } /** * Validates an existing cart item and returns any errors. * * @throws TooManyInCartException Exception if more than one product that can only be purchased individually is in * the cart. * @throws PartialOutOfStockException Exception if an item has a quantity greater than what is available in stock. * @throws OutOfStockException Exception thrown when an item is entirely out of stock. * @throws NotPurchasableException Exception thrown when an item is not purchasable. * @param array $cart_item Cart item array. */ public function validate_cart_item( $cart_item ) { $product = $cart_item['data'] ?? false; if ( ! $product instanceof \WC_Product ) { return; } if ( ! $product->is_purchasable() ) { throw new NotPurchasableException( 'woocommerce_rest_product_not_purchasable', $product->get_name() ); } if ( $product->is_sold_individually() && $cart_item['quantity'] > 1 ) { throw new TooManyInCartException( 'woocommerce_rest_product_too_many_in_cart', $product->get_name() ); } if ( ! $product->is_in_stock() ) { throw new OutOfStockException( 'woocommerce_rest_product_out_of_stock', $product->get_name() ); } if ( $product->managing_stock() && ! $product->backorders_allowed() ) { $qty_remaining = $this->get_remaining_stock_for_product( $product ); $qty_in_cart = $this->get_product_quantity_in_cart( $product ); if ( $qty_remaining < $qty_in_cart ) { throw new PartialOutOfStockException( 'woocommerce_rest_product_partially_out_of_stock', $product->get_name() ); } } /** * Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent * add to cart from occurring. * * @param \WC_Product $product Product object being added to the cart. * @param array $cart_item Cart item array. * @deprecated 7.1.0 Use woocommerce_store_api_validate_cart_item instead. */ wc_do_deprecated_action( 'wooocommerce_store_api_validate_cart_item', array( $product, $cart_item, ), '7.1.0', 'woocommerce_store_api_validate_cart_item', 'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_cart_item instead.' ); /** * Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent * add to cart from occurring. * * @since 7.1.0 * * @param \WC_Product $product Product object being added to the cart. * @param array $cart_item Cart item array. */ do_action( 'woocommerce_store_api_validate_cart_item', $product, $cart_item ); } /** * Validate all coupons in the cart and check for errors. * * @throws InvalidCartException Exception if invalid data is detected. */ public function validate_cart_coupons() { $cart_coupons = $this->get_cart_coupons(); $errors = []; foreach ( $cart_coupons as $code ) { $coupon = new \WC_Coupon( $code ); try { $this->validate_cart_coupon( $coupon ); } catch ( RouteException $error ) { $errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() ); } } if ( ! empty( $errors ) ) { $error = new WP_Error(); foreach ( $errors as $wp_error ) { $error->merge_from( $wp_error ); } throw new InvalidCartException( 'woocommerce_coupons_error', $error, 409 ); } } /** * Validate the cart and get a list of errors. * * @return WP_Error A WP_Error instance containing the cart's errors. */ public function get_cart_errors() { $errors = new WP_Error(); try { $this->validate_cart(); } catch ( RouteException $error ) { $errors->add( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() ); } catch ( InvalidCartException $error ) { $errors->merge_from( $error->getError() ); } catch ( \Exception $error ) { $errors->add( $error->getCode(), $error->getMessage() ); } return $errors; } /** * Get main instance of cart class. * * @throws RouteException When cart cannot be loaded. * @return \WC_Cart */ public function get_cart_instance() { $cart = wc()->cart; if ( ! $cart || ! $cart instanceof \WC_Cart ) { throw new RouteException( 'woocommerce_rest_cart_error', esc_html__( 'Unable to retrieve cart.', 'woocommerce' ), 500 ); } return $cart; } /** * Return a cart item from the woo core cart class. * * @param string $item_id Cart item id. * @return array */ public function get_cart_item( $item_id ) { $cart = $this->get_cart_instance(); return isset( $cart->cart_contents[ $item_id ] ) ? $cart->cart_contents[ $item_id ] : []; } /** * Returns all cart items. * * @param callable $callback Optional callback to apply to the array filter. * @return array */ public function get_cart_items( $callback = null ) { $cart = $this->get_cart_instance(); return $callback ? array_filter( $cart->get_cart(), $callback ) : array_filter( $cart->get_cart() ); } /** * Get hashes for items in the current cart. Useful for tracking changes. * * @return array */ public function get_cart_hashes() { $cart = $this->get_cart_instance(); return [ 'line_items' => $cart->get_cart_hash(), 'shipping' => md5( wp_json_encode( [ $cart->get_shipping_methods(), wc()->session->get( 'chosen_shipping_methods' ) ] ) ), 'fees' => md5( wp_json_encode( $cart->get_fees() ) ), 'coupons' => md5( wp_json_encode( $cart->get_applied_coupons() ) ), 'taxes' => md5( wp_json_encode( $cart->get_taxes() ) ), ]; } /** * Empty cart contents. */ public function empty_cart() { $cart = $this->get_cart_instance(); $cart->empty_cart(); } /** * See if cart has applied coupon by code. * * @param string $coupon_code Cart coupon code. * @return bool */ public function has_coupon( $coupon_code ) { $cart = $this->get_cart_instance(); return $cart->has_discount( $coupon_code ); } /** * Returns all applied coupons. * * @param callable $callback Optional callback to apply to the array filter. * @return array */ public function get_cart_coupons( $callback = null ) { $cart = $this->get_cart_instance(); return $callback ? array_filter( $cart->get_applied_coupons(), $callback ) : array_filter( $cart->get_applied_coupons() ); } /** * Get shipping packages from the cart with calculated shipping rates. * * @todo this can be refactored once https://github.com/woocommerce/woocommerce/pull/26101 lands. * * @param bool $calculate_rates Should rates for the packages also be returned. * @return array */ public function get_shipping_packages( $calculate_rates = true ) { $cart = $this->get_cart_instance(); // See if we need to calculate anything. if ( ! $cart->needs_shipping() ) { return []; } $packages = $cart->get_shipping_packages(); // Return early if no packages. if ( empty( $packages ) ) { return []; } return $calculate_rates ? wc()->shipping()->calculate_shipping( $packages ) : $packages; } /** * Selects a shipping rate. * * @param int|string $package_id ID of the package to choose a rate for. * @param string $rate_id ID of the rate being chosen. */ public function select_shipping_rate( $package_id, $rate_id ) { if ( ! is_string( $rate_id ) ) { return; } $cart = $this->get_cart_instance(); $session_data = wc()->session->get( 'chosen_shipping_methods' ) ? wc()->session->get( 'chosen_shipping_methods' ) : []; $session_data[ $package_id ] = $rate_id; wc()->session->set( 'chosen_shipping_methods', $session_data ); } /** * Based on the core cart class but returns errors rather than rendering notices directly. * * @todo Overriding the core apply_coupon method was necessary because core outputs notices when a coupon gets * applied. For us this would cause notices to build up and output on the store, out of context. Core would need * refactoring to split notices out from other cart actions. * * @throws RouteException Exception if invalid data is detected. * * @param string $coupon_code Coupon code. */ public function apply_coupon( $coupon_code ) { $cart = $this->get_cart_instance(); $applied_coupons = $this->get_cart_coupons(); $coupon = new \WC_Coupon( $coupon_code ); if ( ! wc_is_same_coupon( $coupon->get_code(), $coupon_code ) ) { throw new RouteException( 'woocommerce_rest_cart_coupon_error', sprintf( /* translators: %s coupon code */ esc_html__( '"%s" is an invalid coupon code.', 'woocommerce' ), esc_html( $coupon_code ) ), 400 ); } if ( $this->has_coupon( $coupon_code ) ) { throw new RouteException( 'woocommerce_rest_cart_coupon_error', sprintf( /* translators: %s coupon code */ esc_html__( 'Coupon code "%s" has already been applied.', 'woocommerce' ), esc_html( $coupon->get_code() ) ), 400 ); } $discounts = new \WC_Discounts( $this->get_cart_instance() ); $valid = $discounts->is_coupon_valid( $coupon ); if ( is_wp_error( $valid ) ) { throw new RouteException( 'woocommerce_rest_cart_coupon_error', esc_html( wp_strip_all_tags( $valid->get_error_message() ) ), 400, $valid->get_error_data() // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped ); } // Prevents new coupons being added if individual use coupons are already in the cart. $individual_use_coupons = $this->get_cart_coupons( function ( $code ) { $coupon = new \WC_Coupon( $code ); return $coupon->get_individual_use(); } ); foreach ( $individual_use_coupons as $code ) { $individual_use_coupon = new \WC_Coupon( $code ); /** * Filters if a coupon can be applied alongside other individual use coupons. * * @since 2.6.0 * * @internal Matches filter name in WooCommerce core. * * @param boolean $apply_with_individual_use_coupon Defaults to false. * @param \WC_Coupon $coupon Coupon object applied to the cart. * @param \WC_Coupon $individual_use_coupon Individual use coupon already applied to the cart. * @param array $applied_coupons Array of applied coupons already applied to the cart. * @return boolean */ if ( false === apply_filters( 'woocommerce_apply_with_individual_use_coupon', false, $coupon, $individual_use_coupon, $applied_coupons ) ) { throw new RouteException( 'woocommerce_rest_cart_coupon_error', sprintf( /* translators: %s: coupon code */ esc_html__( '"%s" has already been applied and cannot be used in conjunction with other coupons.', 'woocommerce' ), esc_html( $individual_use_coupon->get_code() ) ), 400 ); } } if ( $coupon->get_individual_use() ) { /** * Filter coupons to remove when applying an individual use coupon. * * @since 2.6.0 * * @internal Matches filter name in WooCommerce core. * * @param array $coupons Array of coupons to remove from the cart. * @param \WC_Coupon $coupon Coupon object applied to the cart. * @param array $applied_coupons Array of applied coupons already applied to the cart. * @return array */ $coupons_to_remove = array_diff( $applied_coupons, apply_filters( 'woocommerce_apply_individual_use_coupon', array(), $coupon, $applied_coupons ) ); foreach ( $coupons_to_remove as $code ) { $cart->remove_coupon( $code ); } $applied_coupons = array_diff( $applied_coupons, $coupons_to_remove ); } $applied_coupons[] = $coupon_code; $cart->set_applied_coupons( $applied_coupons ); /** * Fires after a coupon has been applied to the cart. * * @since 2.6.0 * * @internal Matches action name in WooCommerce core. * * @param string $coupon_code The coupon code that was applied. */ do_action( 'woocommerce_applied_coupon', $coupon_code ); } /** * Validates an existing cart coupon and returns any errors. * * @param \WC_Coupon $coupon Coupon object applied to the cart. * * @throws RouteException Exception if invalid data is detected. */ protected function validate_cart_coupon( \WC_Coupon $coupon ) { if ( ! $coupon->is_valid() ) { $cart = $this->get_cart_instance(); $cart->remove_coupon( $coupon->get_code() ); $cart->calculate_totals(); throw new RouteException( 'woocommerce_rest_cart_coupon_error', sprintf( /* translators: %1$s coupon code, %2$s reason. */ esc_html__( 'The "%1$s" coupon has been removed from your cart: %2$s', 'woocommerce' ), $coupon->get_code(), wp_strip_all_tags( $coupon->get_error_message() ) ), 409 ); } } /** * Gets the qty of a product across line items. * * @param \WC_Product $product Product object. * @return int */ protected function get_product_quantity_in_cart( $product ) { $cart = $this->get_cart_instance(); $product_quantities = $cart->get_cart_item_quantities(); $product_id = $product->get_stock_managed_by_id(); return isset( $product_quantities[ $product_id ] ) ? $product_quantities[ $product_id ] : 0; } /** * Gets remaining stock for a product. * * @param \WC_Product $product Product object. * @return int */ protected function get_remaining_stock_for_product( $product ) { $reserve_stock = new ReserveStock(); $qty_reserved = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() ); return $product->get_stock_quantity() - $qty_reserved; } /** * Get a product object to be added to the cart. * * @throws RouteException Exception if invalid data is detected. * * @param array $request Add to cart request params. * @return \WC_Product|Error Returns a product object if purchasable. */ protected function get_product_for_cart( $request ) { $product = wc_get_product( $request['id'] ); if ( ! $product || ProductStatus::TRASH === $product->get_status() ) { throw new RouteException( 'woocommerce_rest_cart_invalid_product', sprintf( /* translators: %s: product ID */ esc_html__( 'Product with ID "%s" was not found and cannot be added to the cart.', 'woocommerce' ), esc_html( $request['id'] ) ), 400 ); } return $product; } /** * For a given product, get the product ID. * * @param \WC_Product $product Product object associated with the cart item. * @return int */ protected function get_product_id( \WC_Product $product ) { return $product->is_type( ProductType::VARIATION ) ? $product->get_parent_id() : $product->get_id(); } /** * For a given product, get the variation ID. * * @param \WC_Product $product Product object associated with the cart item. * @return int */ protected function get_variation_id( \WC_Product $product ) { return $product->is_type( ProductType::VARIATION ) ? $product->get_id() : 0; } /** * Get product name, hiding it for draft and private products. * * @param \WC_Product $product Product instance. * @return string */ protected function get_product_name( \WC_Product $product ) { if ( $product->get_status() === ProductStatus::DRAFT || $product->get_status() === ProductStatus::PRIVATE ) { return ''; } return $product->get_name(); } /** * Default exception thrown when an item cannot be added to the cart. * * @throws RouteException Exception with code woocommerce_rest_product_not_purchasable. * * @param \WC_Product $product Product object associated with the cart item. */ protected function throw_default_product_exception( \WC_Product $product ) { $product_name = $this->get_product_name( $product ); if ( empty( $product_name ) ) { $message = __( 'This item is not available for purchase.', 'woocommerce' ); } else { $message = sprintf( /* translators: %s: product name */ __( '"%s" is not available for purchase.', 'woocommerce' ), $product_name ); } throw new RouteException( 'woocommerce_rest_product_not_purchasable', esc_html( $message ), 400 ); } /** * Filter data for add to cart requests. * * @param array $request Add to cart request params. * @return array Updated request array. */ protected function filter_request_data( $request ) { $product_id = $request['id']; $variation_id = 0; $product = wc_get_product( $product_id ); if ( $product->is_type( ProductType::VARIATION ) ) { $product_id = $product->get_parent_id(); $variation_id = $product->get_id(); } /** * Filter cart item data for add to cart requests. * * @since 2.5.0 * * @internal Matches filter name in WooCommerce core. * * @param array $cart_item_data Array of other cart item data. * @param integer $product_id ID of the product added to the cart. * @param integer $variation_id Variation ID of the product added to the cart. * @param integer $quantity Quantity of the item added to the cart. * @return array */ $request['cart_item_data'] = (array) apply_filters( 'woocommerce_add_cart_item_data', $request['cart_item_data'], $product_id, $variation_id, $request['quantity'] ); if ( $product->is_sold_individually() ) { /** * Filter sold individually quantity for add to cart requests. * * @since 2.5.0 * * @internal Matches filter name in WooCommerce core. * * @param integer $sold_individually_quantity Defaults to 1. * @param integer $quantity Quantity of the item added to the cart. * @param integer $product_id ID of the product added to the cart. * @param integer $variation_id Variation ID of the product added to the cart. * @param array $cart_item_data Array of other cart item data. * @return integer */ $request['quantity'] = apply_filters( 'woocommerce_add_to_cart_sold_individually_quantity', 1, $request['quantity'], $product_id, $variation_id, $request['cart_item_data'] ); } return $request; } /** * If variations are set, validate and format the values ready to add to the cart. * * @throws RouteException Exception if invalid data is detected. * * @param array $request Add to cart request params. * @return array Updated request array. */ protected function parse_variation_data( $request ) { $product = $this->get_product_for_cart( $request ); // Remove variation request if not needed. if ( ! $product->is_type( array( ProductType::VARIATION, ProductType::VARIABLE ) ) ) { $request['variation'] = []; return $request; } // Flatten data and format posted values. $variable_product_attributes = $this->get_variable_product_attributes( $product ); $request['variation'] = $this->sanitize_variation_data( wp_list_pluck( $request['variation'], 'value', 'attribute' ), $variable_product_attributes ); // If we have a parent product, find the variation ID. if ( $product->is_type( ProductType::VARIABLE ) ) { $request['id'] = $this->get_variation_id_from_variation_data( $request, $product ); } // Now we have a variation ID, get the valid set of attributes for this variation. They will have an attribute_ prefix since they are from meta. $expected_attributes = wc_get_product_variation_attributes( $request['id'] ); $missing_attributes = []; foreach ( $variable_product_attributes as $attribute ) { if ( ! $attribute['is_variation'] ) { continue; } $prefixed_attribute_name = 'attribute_' . sanitize_title( $attribute['name'] ); $expected_value = isset( $expected_attributes[ $prefixed_attribute_name ] ) ? $expected_attributes[ $prefixed_attribute_name ] : ''; $attribute_label = wc_attribute_label( $attribute['name'] ); if ( isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) { $given_value = $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ]; if ( $expected_value === $given_value ) { continue; } // If valid values are empty, this is an 'any' variation so get all possible values. if ( '' === $expected_value && in_array( $given_value, $attribute->get_slugs(), true ) ) { continue; } throw new RouteException( 'woocommerce_rest_invalid_variation_data', sprintf( /* translators: %1$s: Attribute name, %2$s: Allowed values. */ esc_html__( 'Invalid value posted for %1$s. Allowed values: %2$s', 'woocommerce' ), esc_html( $attribute_label ), esc_html( implode( ', ', $attribute->get_slugs() ) ) ), 400 ); } // Fills request array with unspecified attributes that have default values. This ensures the variation always has full data. if ( '' !== $expected_value && ! isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) { $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] = $expected_value; } // If no attribute was posted, only error if the variation has an 'any' attribute which requires a value. if ( '' === $expected_value ) { $missing_attributes[] = $attribute_label; } } if ( ! empty( $missing_attributes ) ) { throw new RouteException( 'woocommerce_rest_missing_variation_data', esc_html__( 'Missing variation data for variable product.', 'woocommerce' ) . ' ' . esc_html( sprintf( /* translators: %s: Attribute name. */ _n( '%s is a required field', '%s are required fields', count( $missing_attributes ), 'woocommerce' ), wc_format_list_of_items( $missing_attributes ) ) ), 400 ); } ksort( $request['variation'] ); return $request; } /** * Try to match request data to a variation ID and return the ID. * * @throws RouteException Exception if variation cannot be found. * * @param array $request Add to cart request params. * @param \WC_Product $product Product being added to the cart. * @return int Matching variation ID. */ protected function get_variation_id_from_variation_data( $request, $product ) { $data_store = \WC_Data_Store::load( 'product' ); $match_attributes = $request['variation']; $variation_id = $data_store->find_matching_product_variation( $product, $match_attributes ); if ( empty( $variation_id ) ) { $required_attributes = array_filter( $product->get_attributes(), function ( $attribute ) { return $attribute->get_variation(); } ); $selected_attributes = array_filter( $match_attributes, function ( $value ) { return '' !== $value && null !== $value; } ); if ( count( $selected_attributes ) < count( $required_attributes ) ) { throw new RouteException( 'woocommerce_rest_missing_attributes', esc_html__( 'Missing attributes for variable product.', 'woocommerce' ), 400 ); } throw new RouteException( 'woocommerce_rest_variation_id_from_variation_data', esc_html__( 'No matching variation found.', 'woocommerce' ), 400 ); } return $variation_id; } /** * Format and sanitize variation data posted to the API. * * Labels are converted to names (e.g. Size to pa_size), and values are cleaned. * * @throws RouteException Exception if variation cannot be found. * * @param array $variation_data Key value pairs of attributes and values. * @param array $variable_product_attributes Product attributes we're expecting. * @return array */ protected function sanitize_variation_data( $variation_data, $variable_product_attributes ) { $return = []; foreach ( $variable_product_attributes as $attribute ) { if ( ! $attribute['is_variation'] ) { continue; } // Sanitized attribute (same as the product page) e.g. attribute_size. $variation_attribute_name = wc_variation_attribute_name( $attribute['name'] ); if ( isset( $variation_data[ $variation_attribute_name ] ) ) { $return[ $variation_attribute_name ] = $attribute['is_taxonomy'] ? sanitize_title( $variation_data[ $variation_attribute_name ] ) : html_entity_decode( wc_clean( $variation_data[ $variation_attribute_name ] ), ENT_QUOTES, get_bloginfo( 'charset' ) ); continue; } // Attribute labels e.g. Size. $attribute_label = wc_attribute_label( $attribute['name'] ); $lowercase_attribute_label = strtolower( $attribute_label ); if ( isset( $variation_data[ $attribute_label ] ) || isset( $variation_data[ $lowercase_attribute_label ] ) ) { // Check both the original and lowercase attribute label. $attribute_label = isset( $variation_data[ $attribute_label ] ) ? $attribute_label : $lowercase_attribute_label; $return[ $variation_attribute_name ] = $attribute['is_taxonomy'] ? sanitize_title( $variation_data[ $attribute_label ] ) : html_entity_decode( wc_clean( $variation_data[ $attribute_label ] ), ENT_QUOTES, get_bloginfo( 'charset' ) ); continue; } // Attribute slugs e.g. pa_size. if ( isset( $variation_data[ $attribute['name'] ] ) ) { $return[ $variation_attribute_name ] = $attribute['is_taxonomy'] ? sanitize_title( $variation_data[ $attribute['name'] ] ) : html_entity_decode( wc_clean( $variation_data[ $attribute['name'] ] ), ENT_QUOTES, get_bloginfo( 'charset' ) ); } } return $return; } /** * Get product attributes from the variable product (which may be the parent if the product object is a variation). * * @throws RouteException Exception if product is invalid. * * @param \WC_Product $product Product being added to the cart. * @return array */ protected function get_variable_product_attributes( $product ) { if ( $product->is_type( ProductType::VARIATION ) ) { $product = wc_get_product( $product->get_parent_id() ); } if ( ! $product || ProductStatus::TRASH === $product->get_status() ) { throw new RouteException( 'woocommerce_rest_cart_invalid_parent_product', esc_html__( 'This product cannot be added to the cart.', 'woocommerce' ), 400 ); } return $product->get_attributes(); } } Utilities/ValidationUtils.php 0000777 00000003352 15251730534 0012362 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; /** * ValidationUtils class. * Helper class which validates and update customer info. */ class ValidationUtils { /** * Get list of states for a country. * * @param string $country Country code. * @return array Array of state names indexed by state keys. */ public function get_states_for_country( $country ) { return $country ? array_filter( (array) \wc()->countries->get_states( $country ) ) : []; } /** * Validate provided state against a countries list of defined states. * * If there are no defined states for a country, any given state is valid. * * @param string $state State name or code (sanitized). * @param string $country Country code. * @return boolean Valid or not valid. */ public function validate_state( $state, $country ) { $states = $this->get_states_for_country( $country ); if ( count( $states ) && ! in_array( \wc_strtoupper( $state ), array_map( '\wc_strtoupper', array_keys( $states ) ), true ) ) { return false; } return true; } /** * Format a state based on the country. If country has defined states, will return a valid upper case state code. * * @param string $state State name or code (sanitized). * @param string $country Country code. * @return string */ public function format_state( $state, $country ) { $states = $this->get_states_for_country( $country ); if ( count( $states ) ) { $state = \wc_strtoupper( $state ); $state_values = array_map( '\wc_strtoupper', array_flip( array_map( '\wc_strtoupper', $states ) ) ); if ( isset( $state_values[ $state ] ) ) { // Convert to state code if a state name was provided. return $state_values[ $state ]; } } return $state; } } Utilities/NoticeHandler.php 0000777 00000004026 15251730534 0011765 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use WP_Error; /** * NoticeHandler class. * Helper class to handle notices. */ class NoticeHandler { /** * Convert queued error notices into an exception. * * For example, Payment methods may add error notices during validate_fields call to prevent checkout. * Since we're not rendering notices at all, we need to convert them to exceptions. * * This method will find the first error message and thrown an exception instead. Discards notices once complete. * * @throws RouteException If an error notice is detected, Exception is thrown. * * @param string $error_code Error code for the thrown exceptions. */ public static function convert_notices_to_exceptions( $error_code = 'unknown_server_error' ) { if ( 0 === wc_notice_count( 'error' ) ) { wc_clear_notices(); return; } $error_notices = wc_get_notices( 'error' ); // Prevent notices from being output later on. wc_clear_notices(); foreach ( $error_notices as $error_notice ) { throw new RouteException( $error_code, wp_strip_all_tags( $error_notice['notice'] ), 400 ); } } /** * Collects queued error notices into a \WP_Error. * * For example, cart validation processes may add error notices to prevent checkout. * Since we're not rendering notices at all, we need to catch them and group them in a single WP_Error instance. * * This method will discard notices once complete. * * @param string $error_code Error code for the thrown exceptions. * * @return \WP_Error The WP_Error object containing all error notices. */ public static function convert_notices_to_wp_errors( $error_code = 'unknown_server_error' ) { $errors = new WP_Error(); if ( 0 === wc_notice_count( 'error' ) ) { return $errors; } $error_notices = wc_get_notices( 'error' ); foreach ( $error_notices as $error_notice ) { $errors->add( $error_code, wp_strip_all_tags( $error_notice['notice'] ) ); } return $errors; } } Utilities/CartTokenUtils.php 0000777 00000003475 15251730534 0012170 0 ustar 00 <?php /** * Cart token utility functions. */ declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\StoreApi\Authentication; use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken; /** * Cart token utility functions. */ class CartTokenUtils { /** * Generate a cart token. * * @param string $customer_id The customer ID. * @return string */ public static function get_cart_token( string $customer_id ): string { return JsonWebToken::create( array( 'user_id' => $customer_id, 'exp' => self::get_cart_token_expiration(), 'iss' => 'store-api', ), self::get_cart_token_secret() ); } /** * Validate the cart token. * * @param string $cart_token The cart token. * @return bool */ public static function validate_cart_token( string $cart_token ): bool { return JsonWebToken::validate( $cart_token, self::get_cart_token_secret() ); } /** * Get the cart token payload. * * @param string $cart_token The cart token. * @return array */ public static function get_cart_token_payload( string $cart_token ): array { $parts = JsonWebToken::get_parts( $cart_token )->payload; return array( 'user_id' => $parts->user_id ?? '', 'exp' => $parts->exp ?? 0, 'iss' => $parts->iss ?? '', ); } /** * Get the cart token secret. * * @return string */ private static function get_cart_token_secret(): string { return '@' . wp_salt(); } /** * Gets the expiration of the cart token. Defaults to 48h. * * @return int */ private static function get_cart_token_expiration(): int { /** * Filters the session expiration. * * @since 5.0.0 * @param int $expiration Expiration in seconds. */ return time() + intval( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ); } } Utilities/LocalPickupUtils.php 0000777 00000013251 15251730534 0012475 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; /** * Util class for local pickup related functionality, this contains methods that need to be accessed from places besides * the ShippingController, i.e. the OrderController. */ class LocalPickupUtils { /** * Gets the local pickup location settings. * * @param string $context The context for the settings. Defaults to 'view'. */ public static function get_local_pickup_settings( $context = 'view' ) { $pickup_location_settings = get_option( 'woocommerce_pickup_location_settings', [ 'enabled' => 'no', 'title' => __( 'Pickup', 'woocommerce' ), 'cost' => '', 'tax_status' => 'taxable', ] ); if ( empty( $pickup_location_settings['title'] ) ) { $pickup_location_settings['title'] = __( 'Pickup', 'woocommerce' ); } if ( empty( $pickup_location_settings['enabled'] ) ) { $pickup_location_settings['enabled'] = 'no'; } if ( ! isset( $pickup_location_settings['cost'] ) ) { $pickup_location_settings['cost'] = ''; } // Return settings as is if we're editing them. if ( 'edit' === $context ) { return $pickup_location_settings; } // All consumers of this turn it into a bool eventually. Doing it here removes the need for that. $pickup_location_settings['enabled'] = wc_string_to_bool( $pickup_location_settings['enabled'] ); $pickup_location_settings['title'] = wc_clean( $pickup_location_settings['title'] ); return $pickup_location_settings; } /** * Checks if WC Blocks local pickup is enabled. * * @return bool True if local pickup is enabled. */ public static function is_local_pickup_enabled() { // Get option directly to avoid early translation function call. // See https://github.com/woocommerce/woocommerce/pull/47113. $pickup_location_settings = get_option( 'woocommerce_pickup_location_settings', [ 'enabled' => 'no', ] ); if ( empty( $pickup_location_settings['enabled'] ) ) { $pickup_location_settings['enabled'] = 'no'; } return wc_string_to_bool( $pickup_location_settings['enabled'] ); } /** * Gets a list of payment method ids that support the 'local-pickup' feature. * * @return string[] List of payment method ids that support the 'local-pickup' feature. */ public static function get_local_pickup_method_ids() { $all_methods_supporting_local_pickup = array_reduce( WC()->shipping()->get_shipping_methods(), function ( $methods, $method ) { if ( $method->supports( 'local-pickup' ) ) { $methods[] = $method->id; } return $methods; }, array( 'local_pickup' ) ); // We use array_values because this will be used in JS, so we don't need the (numerical) keys. return array_values( // This array_unique is necessary because WC()->shipping()->get_shipping_methods() can return duplicates. array_unique( $all_methods_supporting_local_pickup ) ); } /** * Checks if a method is a local pickup method. * * @param string $method_id The method id to check. * @return bool True if the method is a local pickup method. */ public static function is_local_pickup_method( $method_id ) { return in_array( $method_id, self::get_local_pickup_method_ids(), true ); } /** * Gets local pickup locations for block editor preview, including placeholder * locations for custom shipping methods that support local pickup. * * This method combines the built-in pickup_location locations with placeholder * entries for any other shipping methods that declare 'local-pickup' support. * This allows custom shipping methods to appear in the block editor preview. * * @return array Array of pickup locations with the following structure: * - 'name' (string) The location name. * - 'enabled' (bool) Whether the location is enabled. * - 'address' (array) Address array with keys: address_1, city, state, postcode, country. * - 'details' (string) Additional details about the location. * - 'method_id' (string) The shipping method ID this location belongs to. * * @since 10.5.0 */ public static function get_local_pickup_method_locations() { // Get the built-in pickup locations. $builtin_locations = get_option( 'pickup_location_pickup_locations', array() ); // Add method_id to built-in locations. foreach ( $builtin_locations as $index => $location ) { $builtin_locations[ $index ]['method_id'] = 'pickup_location'; } // Get all shipping methods that support local-pickup. $shipping_methods = WC()->shipping()->get_shipping_methods(); // Get store base address for placeholder locations. $base_country = WC()->countries->get_base_country(); $base_state = WC()->countries->get_base_state(); $custom_method_locations = array(); foreach ( $shipping_methods as $method ) { // Skip if method doesn't support local-pickup. if ( ! $method->supports( 'local-pickup' ) ) { continue; } // Skip the built-in pickup_location method (already handled above). if ( 'pickup_location' === $method->id ) { continue; } // Create a placeholder location for this custom method. $custom_method_locations[] = array( 'name' => $method->get_method_title(), 'enabled' => true, 'address' => array( 'address_1' => '123 Main Street', 'city' => 'Sample City', 'state' => $base_state, 'postcode' => '12345', 'country' => $base_country, ), 'details' => sprintf( /* translators: %s: shipping method title */ __( 'Pickup location for %s', 'woocommerce' ), $method->get_method_title() ), 'method_id' => $method->id, ); } return array_merge( $builtin_locations, $custom_method_locations ); } } Utilities/PaymentUtils.php 0000777 00000007231 15251730534 0011705 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Utilities; /** * PaymentUtils * * Utility class for payment methods. */ class PaymentUtils { /** * Callback for woocommerce_payment_methods_list_item filter to add token id * to the generated list. * * @param array $list_item The current list item for the saved payment method. * @param \WC_Token $token The token for the current list item. * * @return array The list item with the token id added. */ public static function include_token_id_with_payment_methods( $list_item, $token ) { $list_item['tokenId'] = $token->get_id(); $brand = ! empty( $list_item['method']['brand'] ) ? strtolower( $list_item['method']['brand'] ) : ''; if ( ! empty( $brand ) && esc_html__( 'Credit card', 'woocommerce' ) !== $brand ) { $list_item['method']['brand'] = wc_get_credit_card_type_label( $brand ); } return $list_item; } /** * Get enabled payment gateways. * * @return array */ public static function get_enabled_payment_gateways() { $payment_gateways = WC()->payment_gateways->payment_gateways(); return array_filter( $payment_gateways, function ( $payment_gateway ) { return 'yes' === $payment_gateway->enabled; } ); } /** * Returns enabled saved payment methods for a customer and the default method if there are multiple. * * @return array */ public static function get_saved_payment_methods() { if ( ! is_user_logged_in() ) { return; } add_filter( 'woocommerce_payment_methods_list_item', [ self::class, 'include_token_id_with_payment_methods' ], 10, 2 ); $enabled_payment_gateways = self::get_enabled_payment_gateways(); $saved_payment_methods = wc_get_customer_saved_methods_list( get_current_user_id() ); $payment_methods = [ 'enabled' => [], 'default' => null, ]; // Filter out payment methods that are not enabled. foreach ( $saved_payment_methods as $payment_method_group => $saved_payment_methods ) { $payment_methods['enabled'][ $payment_method_group ] = array_values( array_filter( $saved_payment_methods, function ( $saved_payment_method ) use ( $enabled_payment_gateways, &$payment_methods ) { if ( true === $saved_payment_method['is_default'] && null === $payment_methods['default'] ) { $payment_methods['default'] = $saved_payment_method; } return in_array( $saved_payment_method['method']['gateway'], array_keys( $enabled_payment_gateways ), true ); } ) ); } remove_filter( 'woocommerce_payment_methods_list_item', [ self::class, 'include_token_id_with_payment_methods' ], 10, 2 ); return $payment_methods; } /** * Returns the default payment method for a customer. * * @return string */ public static function get_default_payment_method() { $saved_payment_methods = self::get_saved_payment_methods(); // A saved payment method exists, set as default. if ( $saved_payment_methods && ! empty( $saved_payment_methods['default'] ) ) { return $saved_payment_methods['default']['method']['gateway'] ?? ''; } $chosen_payment_method = WC()->session->get( 'chosen_payment_method' ); // If payment method is already stored in session, use it. if ( $chosen_payment_method ) { return $chosen_payment_method; } // If no saved payment method exists, use the first enabled payment method. $enabled_payment_gateways = self::get_enabled_payment_gateways(); if ( empty( $enabled_payment_gateways ) ) { return ''; } $first_key = array_key_first( $enabled_payment_gateways ); $first_payment_method = $enabled_payment_gateways[ $first_key ]; return $first_payment_method->id ?? ''; } } Utilities/JsonWebToken.php 0000777 00000012710 15251730534 0011615 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; /** * JsonWebToken class. * * Simple Json Web Token generator & verifier static utility class, currently supporting only HS256 signatures. */ final class JsonWebToken { /** * JWT header type. * * @var string */ private static $type = 'JWT'; /** * JWT algorithm to generate signature. * * @var string */ private static $algorithm = 'HS256'; /** * Generates a token from provided data and secret. * * @param array $payload Payload data. * @param string $secret The secret used to generate the signature. * * @return string */ public static function create( array $payload, string $secret ) { $header = self::to_base_64_url( self::generate_header() ); $payload = self::to_base_64_url( self::generate_payload( $payload ) ); $signature = self::to_base_64_url( self::generate_signature( $header . '.' . $payload, $secret ) ); return $header . '.' . $payload . '.' . $signature; } /** * Validates a provided token against the provided secret. * Checks for format, valid header for our class, expiration claim validity and signature. * https://datatracker.ietf.org/doc/html/rfc7519#section-7.2 * * @param string $token Full token string. * @param string $secret The secret used to generate the signature. * * @return bool */ public static function validate( string $token, string $secret ) { if ( ! self::shallow_validate( $token ) ) { return false; } $parts = self::get_parts( $token ); /** * Check if the token is based on our secret. */ $encoded_regenerated_signature = self::to_base_64_url( self::generate_signature( $parts->header_encoded . '.' . $parts->payload_encoded, $secret ) ); return hash_equals( $encoded_regenerated_signature, $parts->signature_encoded ); } /** * Shallow validate a token, it does not check the signature or expiration, but it checks the structure and expiry. * * @param string $token Full token string. * * @return bool */ public static function shallow_validate( string $token ) { if ( ! $token ) { return false; } /** * Confirm the structure of a JSON Web Token, it has three parts separated * by dots and complies with Base64URL standards. */ if ( preg_match( '/^[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+$/', $token ) !== 1 ) { return false; } $parts = self::get_parts( $token ); /** * Check if header declares a supported JWT by this class. */ if ( ! is_object( $parts->header ) || ! property_exists( $parts->header, 'typ' ) || ! property_exists( $parts->header, 'alg' ) || self::$type !== $parts->header->typ || self::$algorithm !== $parts->header->alg ) { return false; } /** * Check if token is expired. */ if ( ! property_exists( $parts->payload, 'exp' ) || time() > (int) $parts->payload->exp ) { return false; } return true; } /** * Returns the decoded/encoded header, payload and signature from a token string. * * @param string $token Full token string. * * @return object */ public static function get_parts( string $token ) { $parts = explode( '.', $token ); return (object) array( 'header' => json_decode( self::from_base_64_url( $parts[0] ) ), 'header_encoded' => $parts[0], 'payload' => json_decode( self::from_base_64_url( $parts[1] ) ), 'payload_encoded' => $parts[1], 'signature' => self::from_base_64_url( $parts[2] ), 'signature_encoded' => $parts[2], ); } /** * Generates the json formatted header for our HS256 JWT token. * * @return string|bool */ private static function generate_header() { return wp_json_encode( array( 'alg' => self::$algorithm, 'typ' => self::$type, ) ); } /** * Generates a sha256 signature for the provided string using the provided secret. * * @param string $string Header + Payload token substring. * @param string $secret The secret used to generate the signature. * * @return false|string */ private static function generate_signature( string $string, string $secret ) { return hash_hmac( 'sha256', $string, $secret, true ); } /** * Generates the payload in json formatted string. * * @param array $payload Payload data. * * @return string|bool */ private static function generate_payload( array $payload ) { return wp_json_encode( array_merge( $payload, [ 'iat' => time() ] ) ); } /** * Encodes a string to url safe base64. * * @param string $string The string to be encoded. * * @return string */ private static function to_base_64_url( string $string ) { return str_replace( array( '+', '/', '=' ), array( '-', '_', '' ), base64_encode( $string ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode ); } /** * Decodes a string encoded using url safe base64, supporting auto padding. * * @param string $string the string to be decoded. * * @return string */ private static function from_base_64_url( string $string ) { /** * Add padding to base64 strings which require it. Some base64 URL strings * which are decoded will have missing padding which is represented by the * equals sign. */ if ( strlen( $string ) % 4 !== 0 ) { return self::from_base_64_url( $string . '=' ); } return base64_decode( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode str_replace( array( '-', '_' ), array( '+', '/' ), $string ) ); } } Utilities/ArrayUtils.php 0000777 00000002772 15251730534 0011353 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; /** * ArrayUtils class used for custom functions to operate on arrays */ class ArrayUtils { /** * Join a string with a natural language conjunction at the end. * * @param array $array The array to join together with the natural language conjunction. * @param bool $enclose_items_with_quotes Whether each item in the array should be enclosed within quotation marks. * * @return string a string containing a list of items and a natural language conjuction. */ public static function natural_language_join( $array, $enclose_items_with_quotes = false ) { if ( true === $enclose_items_with_quotes ) { $array = array_map( function ( $item ) { return '"' . $item . '"'; }, $array ); } $last = array_pop( $array ); if ( $array ) { return sprintf( /* translators: 1: The first n-1 items of a list 2: the last item in the list. */ __( '%1$s and %2$s', 'woocommerce' ), implode( ', ', $array ), $last ); } return $last; } /** * Check if a string contains any of the items in an array. * * @param string $needle The string to check. * @param array $haystack The array of items to check for. * * @return bool true if the string contains any of the items in the array, false otherwise. */ public static function string_contains_array( $needle, $haystack ) { foreach ( $haystack as $item ) { if ( false !== strpos( $needle, $item ) ) { return true; } } return false; } } Utilities/OrderController.php 0000777 00000077311 15251730534 0012374 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; use Automattic\WooCommerce\Blocks\Package; use Automattic\WooCommerce\Internal\Customers\SearchService as CustomerSearchService; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\Utilities\ArrayUtil; use Automattic\WooCommerce\Utilities\DiscountsUtil; use Automattic\WooCommerce\Utilities\ShippingUtil; use Exception; /** * OrderController class. * Helper class which creates and syncs orders with the cart. */ class OrderController { /** * Checkout fields controller. * * @var CheckoutFields */ private CheckoutFields $additional_fields_controller; /** * Constructor. */ public function __construct() { $this->additional_fields_controller = Package::container()->get( CheckoutFields::class ); } /** * Create order and set props based on global settings. * * @throws RouteException Exception if invalid data is detected. * * @return \WC_Order A new order object. */ public function create_order_from_cart() { if ( wc()->cart->is_empty() ) { throw new RouteException( 'woocommerce_rest_cart_empty', __( 'Cannot create order from empty cart.', 'woocommerce' ), 400 ); } add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); $order = new \WC_Order(); $order->set_status( 'checkout-draft' ); $order->set_created_via( 'store-api' ); $this->update_order_from_cart( $order ); remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); return $order; } /** * Update an order using data from the current cart. * * @param \WC_Order $order The order object to update. * @param boolean $update_totals Whether to update totals or not. */ public function update_order_from_cart( \WC_Order $order, $update_totals = true ) { /** * This filter ensures that local pickup locations are still used for order taxes by forcing the address used to * calculate tax for an order to match the current address of the customer. * * - The method `$customer->get_taxable_address()` runs the filter `woocommerce_customer_taxable_address`. * - While we have a session, our `ShippingController::filter_taxable_address` function uses this hook to set * the customer address to the pickup location address if local pickup is the chosen method. * * Without this code in place, `$customer->get_taxable_address()` is not used when order taxes are calculated, * resulting in the wrong taxes being applied with local pickup. * * The alternative would be to instead use `woocommerce_order_get_tax_location` to return the pickup location * address directly, however since we have the customer filter in place we don't need to duplicate effort. * * @see \WC_Abstract_Order::get_tax_location() */ add_filter( 'woocommerce_order_get_tax_location', function ( $location ) { if ( ! is_null( wc()->customer ) ) { $taxable_address = wc()->customer->get_taxable_address(); $location = array( 'country' => $taxable_address[0], 'state' => $taxable_address[1], 'postcode' => $taxable_address[2], 'city' => $taxable_address[3], ); } return $location; } ); // Ensure cart is current. if ( $update_totals ) { wc()->cart->calculate_totals(); } // Update the current order to match the current cart. $this->update_line_items_from_cart( $order ); $this->update_addresses_from_cart( $order ); $order->set_currency( get_woocommerce_currency() ); $order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) ); $order->set_customer_id( get_current_user_id() ); $order->set_customer_ip_address( \WC_Geolocation::get_ip_address() ); $order->set_customer_user_agent( wc_get_user_agent() ); $order->set_payment_method( PaymentUtils::get_default_payment_method() ); $order->update_meta_data( 'is_vat_exempt', wc_bool_to_string( wc()->cart->get_customer()->get_is_vat_exempt() ) ); $order->calculate_totals(); } /** * Copies order data to customer object (not the session), so values persist for future checkouts. * * @param \WC_Order $order Order object. */ public function sync_customer_data_with_order( \WC_Order $order ) { if ( $order->get_customer_id() ) { $customer = new \WC_Customer( $order->get_customer_id() ); $customer->set_props( array( 'billing_first_name' => $order->get_billing_first_name(), 'billing_last_name' => $order->get_billing_last_name(), 'billing_company' => $order->get_billing_company(), 'billing_address_1' => $order->get_billing_address_1(), 'billing_address_2' => $order->get_billing_address_2(), 'billing_city' => $order->get_billing_city(), 'billing_state' => $order->get_billing_state(), 'billing_postcode' => $order->get_billing_postcode(), 'billing_country' => $order->get_billing_country(), 'billing_email' => $order->get_billing_email(), 'billing_phone' => $order->get_billing_phone(), 'shipping_first_name' => $order->get_shipping_first_name(), 'shipping_last_name' => $order->get_shipping_last_name(), 'shipping_company' => $order->get_shipping_company(), 'shipping_address_1' => $order->get_shipping_address_1(), 'shipping_address_2' => $order->get_shipping_address_2(), 'shipping_city' => $order->get_shipping_city(), 'shipping_state' => $order->get_shipping_state(), 'shipping_postcode' => $order->get_shipping_postcode(), 'shipping_country' => $order->get_shipping_country(), 'shipping_phone' => $order->get_shipping_phone(), ) ); $this->additional_fields_controller->sync_customer_additional_fields_with_order( $order, $customer ); $customer->save(); } } /** * Final validation ran before payment is taken. * * By this point we have an order populated with customer data and items. * * @throws RouteException Exception if invalid data is detected. * @param \WC_Order $order Order object. */ public function validate_order_before_payment( \WC_Order $order ) { $needs_shipping = wc()->cart->needs_shipping(); $chosen_shipping_methods = wc()->session->get( 'chosen_shipping_methods', [] ); $this->validate_coupons( $order ); $this->validate_email( $order ); $this->validate_selected_shipping_methods( $needs_shipping, $chosen_shipping_methods ); $this->validate_addresses( $order, $needs_shipping ); // Perform custom validations. $this->perform_custom_order_validation( $order ); } /** * Final validation for existing orders, ran before payment is taken. * * By this point we have an order populated with customer data and items. * * Since the cart is not involved, we don't validate shipping methods and assume the order already * contains the correct shipping items. * * @throws RouteException Exception if invalid data is detected. * @param \WC_Order $order Order object. */ public function validate_existing_order_before_payment( \WC_Order $order ) { $needs_shipping = $order->needs_shipping(); $this->validate_coupons( $order, true ); $this->validate_email( $order ); $this->validate_addresses( $order, $needs_shipping ); // Perform custom validations. $this->perform_custom_order_validation( $order ); } /** * Perform custom order validation via WooCommerce hooks. * * Allows plugins to perform custom validation before payment. * * @param \WC_Order $order Order object. * @throws RouteException Exception if validation fails. */ protected function perform_custom_order_validation( \WC_Order $order ) { $validation_errors = new \WP_Error(); /** * Allow plugins to perform custom validation before payment. * * Plugins can add errors to the $validation_errors object. * * @param \WC_Order $order The order object. * @param \WP_Error $validation_errors WP_Error object to add custom errors to. * @since 9.9.0 */ do_action( 'woocommerce_checkout_validate_order_before_payment', $order, $validation_errors ); // Check if there are any errors after custom validation. if ( $validation_errors->has_errors() ) { throw new RouteException( 'woocommerce_rest_checkout_custom_validation_error', esc_html( implode( ' ', $validation_errors->get_error_messages() ) ), 400 ); } } /** * Convert a coupon code to a coupon object. * * @param string $coupon_code Coupon code. * @return \WC_Coupon Coupon object. */ protected function get_coupon( $coupon_code ) { return new \WC_Coupon( $coupon_code ); } /** * Validate coupons applied to the order and remove those that are not valid. * * @throws RouteException Exception if invalid data is detected. * @param \WC_Order $order Order object. * @param bool $use_order_data Whether to use order data or cart data. */ protected function validate_coupons( \WC_Order $order, bool $use_order_data = false ) { $coupon_codes = $order->get_coupon_codes(); $coupons = array_filter( array_map( array( $this, 'get_coupon' ), $coupon_codes ) ); $validators = array( 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' ); $coupon_errors = array(); foreach ( $coupons as $coupon ) { try { array_walk( $validators, function ( $validator, $index, $params ) { call_user_func_array( array( $this, $validator ), $params ); }, array( $coupon, $order ) ); } catch ( Exception $error ) { $coupon_errors[ $coupon->get_code() ] = $error->getMessage(); } } if ( $coupon_errors ) { // Remove all coupons that were not valid. if ( $use_order_data ) { $error_code = 'woocommerce_rest_order_coupon_errors'; foreach ( $coupon_errors as $coupon_code => $message ) { $order->remove_coupon( $coupon_code ); } // Recalculate totals. $order->calculate_totals(); } else { $error_code = 'woocommerce_rest_cart_coupon_errors'; foreach ( $coupon_errors as $coupon_code => $message ) { wc()->cart->remove_coupon( $coupon_code ); } // Recalculate totals. wc()->cart->calculate_totals(); // Re-sync order with cart. $this->update_order_from_cart( $order ); } // Return exception so customer can review before payment. if ( 1 === count( $coupon_errors ) && $use_order_data ) { $error_message = sprintf( /* translators: %1$s Coupon codes, %2$s Reason */ __( '"%1$s" was removed from the order. %2$s', 'woocommerce' ), array_keys( $coupon_errors )[0], array_values( $coupon_errors )[0], ); } elseif ( 1 === count( $coupon_errors ) ) { $error_message = sprintf( /* translators: %1$s Coupon codes, %2$s Reason */ __( '"%1$s" was removed from the cart. %2$s', 'woocommerce' ), array_keys( $coupon_errors )[0], array_values( $coupon_errors )[0], ); } elseif ( $use_order_data ) { $error_message = sprintf( /* translators: %s Coupon codes. */ __( 'Invalid coupons were removed from the order: "%s"', 'woocommerce' ), implode( '", "', array_keys( $coupon_errors ) ) ); } else { $error_message = sprintf( /* translators: %s Coupon codes. */ __( 'Invalid coupons were removed from the cart: "%s"', 'woocommerce' ), implode( '", "', array_keys( $coupon_errors ) ) ); } throw new RouteException( $error_code, $error_message, 409, array( 'removed_coupons' => $coupon_errors ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } } /** * Validates the customer email. This is a required field. * * @throws RouteException Exception if invalid data is detected. * @param \WC_Order $order Order object. */ protected function validate_email( \WC_Order $order ) { $email = $order->get_billing_email(); if ( empty( $email ) ) { throw new RouteException( 'woocommerce_rest_missing_email_address', __( 'A valid email address is required', 'woocommerce' ), 400 ); } if ( ! is_email( $email ) ) { throw new RouteException( 'woocommerce_rest_invalid_email_address', sprintf( /* translators: %s provided email. */ __( 'The provided email address (%s) is not valid—please provide a valid email address', 'woocommerce' ), esc_html( $email ) ), 400 ); } } /** * Validates customer address data based on the locale to ensure required fields are set. * * @throws RouteException Exception if invalid data is detected. * @param \WC_Order $order Order object. * @param bool $needs_shipping Whether the order needs shipping. */ protected function validate_addresses( \WC_Order $order, bool $needs_shipping ) { $errors = new \WP_Error(); $billing_country = $order->get_billing_country(); $shipping_country = $order->get_shipping_country(); if ( $needs_shipping ) { $local_pickup_method_ids = LocalPickupUtils::get_local_pickup_method_ids(); $selected_shipping_rates = ShippingUtil::get_selected_shipping_rates_from_packages( WC()->shipping()->get_packages() ); $selected_shipping_rates_are_all_local_pickup = ArrayUtil::array_all( $selected_shipping_rates, function ( $rate ) use ( $local_pickup_method_ids ) { return in_array( $rate->get_method_id(), $local_pickup_method_ids, true ); } ); // If only local pickup is selected, we don't need to validate the shipping country. if ( ! $selected_shipping_rates_are_all_local_pickup && ! $this->validate_allowed_country( $shipping_country, (array) wc()->countries->get_shipping_countries() ) ) { throw new RouteException( 'woocommerce_rest_invalid_address_country', sprintf( /* translators: %s country code. */ esc_html__( 'Sorry, we do not ship orders to the provided country (%s)', 'woocommerce' ), esc_html( $shipping_country ) ), 400, array( 'allowed_countries' => array_map( 'esc_html', array_keys( wc()->countries->get_shipping_countries() ) ), ) ); } } if ( ! $this->validate_allowed_country( $billing_country, (array) wc()->countries->get_allowed_countries() ) ) { throw new RouteException( 'woocommerce_rest_invalid_address_country', sprintf( /* translators: %s country code. */ esc_html__( 'Sorry, we do not allow orders from the provided country (%s)', 'woocommerce' ), esc_html( $billing_country ) ), 400, array( 'allowed_countries' => array_map( 'esc_html', array_keys( wc()->countries->get_allowed_countries() ) ), ) ); } if ( $needs_shipping ) { $this->validate_address_fields( $order, 'shipping', $errors ); } $this->validate_address_fields( $order, 'billing', $errors ); if ( ! $errors->has_errors() ) { return; } $errors_by_code = array(); $error_codes = $errors->get_error_codes(); foreach ( $error_codes as $code ) { $errors_by_code[ $code ] = $errors->get_error_messages( $code ); } // Surface errors from first code. foreach ( $errors_by_code as $code => $error_messages ) { throw new RouteException( 'woocommerce_rest_invalid_address', sprintf( /* translators: %s Address type. */ esc_html__( 'There was a problem with the provided %s:', 'woocommerce' ) . ' ' . esc_html( implode( ', ', $error_messages ) ), 'shipping' === $code ? esc_html__( 'shipping address', 'woocommerce' ) : esc_html__( 'billing address', 'woocommerce' ) ), 400, array( 'errors' => $errors_by_code, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped ) ); } } /** * Check all required address fields are set and return errors if not. * * @param string $country Country code. * @param array $allowed_countries List of valid country codes. * @return boolean True if valid. */ protected function validate_allowed_country( $country, array $allowed_countries ) { return array_key_exists( $country, $allowed_countries ); } /** * Check all required address fields are set and return errors if not. * * @param \WC_Order $order Order object. * @param string $address_type billing or shipping address, used in error messages. * @param \WP_Error $errors Error object. */ protected function validate_address_fields( \WC_Order $order, $address_type, \WP_Error $errors ) { $all_locales = wc()->countries->get_country_locale(); $address = $order->get_address( $address_type ); $current_locale = $all_locales[ $address['country'] ] ?? []; foreach ( $all_locales['default'] as $key => $value ) { // If $current_locale[ $key ] is not empty, merge it with locale default, otherwise just use default locale. $current_locale[ $key ] = ! empty( $current_locale[ $key ] ) ? wp_parse_args( $current_locale[ $key ], $value ) : $value; } $additional_fields = $this->additional_fields_controller->get_all_fields_from_object( $order, $address_type ); $address = array_merge( $address, $additional_fields ); foreach ( $current_locale as $address_field_key => $address_field ) { // Skip validation if field is not required or if it is hidden. if ( true !== wc_string_to_bool( $address_field['required'] ?? false ) || true === wc_string_to_bool( $address_field['hidden'] ?? false ) ) { continue; } // Check if field is not set, is an empty string, or is an empty array. $is_empty = ! isset( $address[ $address_field_key ] ) || ( is_string( $address[ $address_field_key ] ) && '' === trim( $address[ $address_field_key ] ) ) || ( is_array( $address[ $address_field_key ] ) && 0 === count( $address[ $address_field_key ] ) ); if ( $is_empty ) { /* translators: %s Field label. */ $errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key ); } } // Validate additional fields. $result = $this->additional_fields_controller->validate_fields_for_location( $address, 'address', $address_type ); if ( $result->has_errors() ) { // Add errors to main error object but ensure they maintain the billing/shipping error code. foreach ( $result->get_error_codes() as $code ) { $errors->add( $address_type, $result->get_error_message( $code ), $code ); } } } /** * Check email restrictions of a coupon against the order. * * @throws Exception Exception if invalid data is detected. * @param \WC_Coupon $coupon Coupon object applied to the cart. * @param \WC_Order $order Order object. */ protected function validate_coupon_email_restriction( \WC_Coupon $coupon, \WC_Order $order ) { $restrictions = $coupon->get_email_restrictions(); if ( empty( $restrictions ) ) { return; } $check_emails = array(); // Check the logged-in user's email. $current_user = wp_get_current_user(); if ( $current_user->exists() ) { $user_email = trim( sanitize_email( $current_user->user_email ) ); if ( ! empty( $user_email ) ) { $check_emails[] = strtolower( $user_email ); } } // Also check the billing email from the order. $billing_email = $order->get_billing_email(); if ( ! empty( $billing_email ) ) { $billing_email = trim( sanitize_email( $billing_email ) ); if ( ! empty( $billing_email ) ) { $check_emails[] = strtolower( $billing_email ); } } // Remove duplicates and empty values. $check_emails = array_unique( array_filter( $check_emails ) ); if ( ! empty( $check_emails ) && ! DiscountsUtil::is_coupon_emails_allowed( $check_emails, $restrictions ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ) ); } } /** * Check usage restrictions of a coupon against the order. * * @throws Exception Exception if invalid data is detected. * @param \WC_Coupon $coupon Coupon object applied to the cart. * @param \WC_Order $order Order object. */ protected function validate_coupon_usage_limit( \WC_Coupon $coupon, \WC_Order $order ) { $coupon_usage_limit = $coupon->get_usage_limit_per_user(); if ( 0 === $coupon_usage_limit ) { return; } // First, we check a logged in customer usage count, which happens against their user id, billing email, and account email. if ( $order->get_customer_id() ) { // We get usage per user id and associated emails. $usage_count = $this->get_usage_per_aliases( $coupon, array( $order->get_billing_email(), $order->get_customer_id(), $this->get_email_from_user_id( $order->get_customer_id() ), ) ); } else { // Otherwise we check if the email doesn't belong to an existing user. // This will get us any user ids for the given billing email. $user_ids = wc_get_container()->get( CustomerSearchService::class )->find_user_ids_by_billing_email_for_coupons_usage_lookup( array( $order->get_billing_email() ) ); // Convert all found user ids to a list of email addresses. $user_emails = array_map( array( $this, 'get_email_from_user_id' ), $user_ids ); // This matches a user against the given billing email and gets their ID/email/billing email. $found_user = get_user_by( 'email', $order->get_billing_email() ); if ( $found_user ) { $user_ids[] = $found_user->ID; $user_emails[] = $found_user->user_email; $user_emails[] = get_user_meta( $found_user->ID, 'billing_email', true ); } // Finally, grab usage count for all found IDs and emails. $usage_count = $this->get_usage_per_aliases( $coupon, array_merge( $user_emails, $user_ids, array( $order->get_billing_email() ) ) ); } if ( $usage_count >= $coupon_usage_limit ) { throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_USAGE_LIMIT_REACHED ) ); } } /** * Get user email from user id. * * @param integer $user_id User ID. * @return string Email or empty string. */ private function get_email_from_user_id( $user_id ) { $user_data = get_userdata( $user_id ); return $user_data ? $user_data->user_email : ''; } /** * Get the usage count for a coupon based on a list of aliases (ids, emails). * * @param \WC_Coupon $coupon Coupon object applied to the cart. * @param array $aliases List of aliases to check. * * @return integer */ private function get_usage_per_aliases( $coupon, $aliases ) { global $wpdb; $aliases = array_unique( array_filter( $aliases ) ); $aliases_string = "('" . implode( "','", array_map( 'esc_sql', $aliases ) ) . "')"; $usage_count = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT COUNT( meta_id ) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = '_used_by' AND meta_value IN {$aliases_string};", $coupon->get_id(), ) ); $data_store = $coupon->get_data_store(); // Coupons can be held for an x amount of time before being applied to an order, so we need to check if it's already being held in (maybe via another flow). $tentative_usage_count = $data_store->get_tentative_usages_for_user( $coupon->get_id(), $aliases ); return $tentative_usage_count + $usage_count; } /** * Check there is a shipping method if it requires shipping. * * @throws RouteException Exception if invalid data is detected. * @param boolean $needs_shipping Current order needs shipping. * @param array $chosen_shipping_methods Array of shipping methods. */ public function validate_selected_shipping_methods( $needs_shipping, $chosen_shipping_methods = array() ) { if ( ! $needs_shipping ) { return; } $exception = new RouteException( 'woocommerce_rest_invalid_shipping_option', __( 'Sorry, this order requires a shipping option.', 'woocommerce' ), 400, array() ); if ( ! is_array( $chosen_shipping_methods ) || empty( $chosen_shipping_methods ) ) { throw $exception; } // Validate that the chosen shipping methods are valid according to the returned package rates. $packages = WC()->shipping()->get_packages(); foreach ( $packages as $package_id => $package ) { $chosen_rate_for_package = $chosen_shipping_methods[ $package_id ]; $valid_rate_ids_for_package = wp_list_pluck( $package['rates'], 'id' ); if ( ! is_string( $chosen_rate_for_package ) || ! ArrayUtils::string_contains_array( $chosen_rate_for_package, $valid_rate_ids_for_package ) ) { throw $exception; } } } /** * Validate a given order key against an existing order. * * @throws RouteException Exception if invalid data is detected. * @param integer $order_id Order ID. * @param string $order_key Order key. */ public function validate_order_key( $order_id, $order_key ) { $order = wc_get_order( $order_id ); if ( ! $order || ! $order_key || $order->get_id() !== $order_id || ! hash_equals( $order->get_order_key(), $order_key ) ) { throw new RouteException( 'woocommerce_rest_invalid_order', __( 'Invalid order ID or key provided.', 'woocommerce' ), 401 ); } } /** * Get errors for order stock on failed orders. * * @throws RouteException Exception if invalid data is detected. * @param integer $order_id Order ID. */ public function get_failed_order_stock_error( $order_id ) { $order = wc_get_order( $order_id ); // Ensure order items are still stocked if paying for a failed order. Pending orders do not need this check because stock is held. if ( ! $order->has_status( wc_get_is_pending_statuses() ) ) { $quantities = array(); foreach ( $order->get_items() as $item_key => $item ) { if ( $item && is_callable( array( $item, 'get_product' ) ) ) { $product = $item->get_product(); if ( ! $product ) { continue; } $quantities[ $product->get_stock_managed_by_id() ] = isset( $quantities[ $product->get_stock_managed_by_id() ] ) ? $quantities[ $product->get_stock_managed_by_id() ] + $item->get_quantity() : $item->get_quantity(); } } // Stock levels may already have been adjusted for this order (in which case we don't need to worry about checking for low stock). if ( ! $order->get_data_store()->get_stock_reduced( $order->get_id() ) ) { foreach ( $order->get_items() as $item_key => $item ) { if ( $item && is_callable( array( $item, 'get_product' ) ) ) { $product = $item->get_product(); if ( ! $product ) { continue; } /** * Filters whether or not the product is in stock for this pay for order. * * @param boolean True if in stock. * @param \WC_Product $product Product. * @param \WC_Order $order Order. * * @since 9.8.0-dev */ if ( ! apply_filters( 'woocommerce_pay_order_product_in_stock', $product->is_in_stock(), $product, $order ) ) { return array( 'code' => 'woocommerce_rest_out_of_stock', /* translators: %s: product name */ 'message' => sprintf( __( 'Sorry, "%s" is no longer in stock so this order cannot be paid for. We apologize for any inconvenience caused.', 'woocommerce' ), $product->get_name() ), ); } // We only need to check products managing stock, with a limited stock qty. if ( ! $product->managing_stock() || $product->backorders_allowed() ) { continue; } // Check stock based on all items in the cart and consider any held stock within pending orders. $held_stock = wc_get_held_stock_quantity( $product, $order->get_id() ); $required_stock = $quantities[ $product->get_stock_managed_by_id() ]; /** * Filters whether or not the product has enough stock. * * @param boolean True if has enough stock. * @param \WC_Product $product Product. * @param \WC_Order $order Order. * * @since 9.8.0-dev */ if ( ! apply_filters( 'woocommerce_pay_order_product_has_enough_stock', ( $product->get_stock_quantity() >= ( $held_stock + $required_stock ) ), $product, $order ) ) { /* translators: 1: product name 2: quantity in stock */ return array( 'code' => 'woocommerce_rest_out_of_stock', /* translators: %s: product name */ 'message' => sprintf( __( 'Sorry, we do not have enough "%1$s" in stock to fulfill your order (%2$s available). We apologize for any inconvenience caused.', 'woocommerce' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity() - $held_stock, $product ) ), ); } } } } } return null; } /** * Changes default order status to draft for orders created via this API. * * @return string */ public function default_order_status() { return 'checkout-draft'; } /** * Create order line items. * * @param \WC_Order $order The order object to update. */ protected function update_line_items_from_cart( \WC_Order $order ) { $cart_controller = new CartController(); $cart = $cart_controller->get_cart_instance(); $cart_hashes = $cart_controller->get_cart_hashes(); if ( $order->get_cart_hash() !== $cart_hashes['line_items'] ) { $order->set_cart_hash( $cart_hashes['line_items'] ); $order->remove_order_items( 'line_item' ); wc()->checkout->create_order_line_items( $order, $cart ); } if ( $order->get_meta( '_shipping_hash' ) !== $cart_hashes['shipping'] ) { $order->update_meta_data( '_shipping_hash', $cart_hashes['shipping'] ); $order->remove_order_items( 'shipping' ); wc()->checkout->create_order_shipping_lines( $order, wc()->session->get( 'chosen_shipping_methods' ), wc()->shipping()->get_packages() ); } if ( $order->get_meta( '_coupons_hash' ) !== $cart_hashes['coupons'] ) { $order->remove_order_items( 'coupon' ); $order->update_meta_data( '_coupons_hash', $cart_hashes['coupons'] ); wc()->checkout->create_order_coupon_lines( $order, $cart ); } if ( $order->get_meta( '_fees_hash' ) !== $cart_hashes['fees'] ) { $order->update_meta_data( '_fees_hash', $cart_hashes['fees'] ); $order->remove_order_items( 'fee' ); wc()->checkout->create_order_fee_lines( $order, $cart ); } if ( $order->get_meta( '_taxes_hash' ) !== $cart_hashes['taxes'] ) { $order->update_meta_data( '_taxes_hash', $cart_hashes['taxes'] ); $order->remove_order_items( 'tax' ); wc()->checkout->create_order_tax_lines( $order, $cart ); } } /** * Update address data from cart and/or customer session data. * * @param \WC_Order $order The order object to update. */ protected function update_addresses_from_cart( \WC_Order $order ) { $order->set_props( array( 'billing_first_name' => wc()->customer->get_billing_first_name(), 'billing_last_name' => wc()->customer->get_billing_last_name(), 'billing_company' => wc()->customer->get_billing_company(), 'billing_address_1' => wc()->customer->get_billing_address_1(), 'billing_address_2' => wc()->customer->get_billing_address_2(), 'billing_city' => wc()->customer->get_billing_city(), 'billing_state' => wc()->customer->get_billing_state(), 'billing_postcode' => wc()->customer->get_billing_postcode(), 'billing_country' => wc()->customer->get_billing_country(), 'billing_email' => wc()->customer->get_billing_email(), 'billing_phone' => wc()->customer->get_billing_phone(), 'shipping_first_name' => wc()->customer->get_shipping_first_name(), 'shipping_last_name' => wc()->customer->get_shipping_last_name(), 'shipping_company' => wc()->customer->get_shipping_company(), 'shipping_address_1' => wc()->customer->get_shipping_address_1(), 'shipping_address_2' => wc()->customer->get_shipping_address_2(), 'shipping_city' => wc()->customer->get_shipping_city(), 'shipping_state' => wc()->customer->get_shipping_state(), 'shipping_postcode' => wc()->customer->get_shipping_postcode(), 'shipping_country' => wc()->customer->get_shipping_country(), 'shipping_phone' => wc()->customer->get_shipping_phone(), ) ); $this->additional_fields_controller->sync_order_additional_fields_with_customer( $order, wc()->customer ); } } Utilities/QuantityLimits.php 0000777 00000024162 15251730534 0012251 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\Checkout\Helpers\ReserveStock; use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait; use Automattic\WooCommerce\Utilities\NumberUtil; /** * QuantityLimits class. * * Returns limits for products and cart items when using the StoreAPI and supporting classes. */ final class QuantityLimits { use DraftOrderTrait; /** * Get quantity limits (min, max, step/multiple) for a product or cart item. * * @param array $cart_item A cart item array. * @return array */ public function get_cart_item_quantity_limits( $cart_item ) { $product = $cart_item['data'] ?? false; if ( ! $product instanceof \WC_Product ) { return [ 'minimum' => 1, 'maximum' => 9999, 'multiple_of' => 1, 'editable' => true, ]; } return array_merge( $this->get_add_to_cart_limits( $product, $cart_item ), [ 'editable' => $this->filter_boolean_value( ! $product->is_sold_individually(), 'editable', $product, $cart_item ), ] ); } /** * Get limits for product add to cart forms. * * @param \WC_Product $product Product instance. * @param array|null $cart_item Optional cart item associated with the product. * @return array */ public function get_add_to_cart_limits( \WC_Product $product, $cart_item = null ) { // Compatibility with the woocommerce_quantity_input_args filter. Gets initial values to match classic quantity input. $args = wc_get_quantity_input_args( [], $product ); $minimum = $this->filter_numeric_value( $args['min_value'], 'minimum', $product, $cart_item ); $maximum = $this->filter_numeric_value( $this->adjust_product_quantity_limit( $args['max_value'], $product, $cart_item ), 'maximum', $product, $cart_item ); $multiple_of = $this->filter_numeric_value( $args['step'], 'multiple_of', $product, $cart_item ); // Ensure values are compatible with each other. $minimum = max( $multiple_of, $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ) ); $maximum = max( $minimum, $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ) ); return [ 'minimum' => $minimum, 'maximum' => $maximum, 'multiple_of' => $multiple_of, ]; } /** * Fix a quantity violation by adjusting it to the nearest valid quantity. * * @param int|float $quantity Quantity. * @param array $cart_item Cart item. * @return int|float */ public function normalize_cart_item_quantity( $quantity, array $cart_item ) { $product = $cart_item['data'] ?? false; if ( ! $product instanceof \WC_Product ) { return wc_stock_amount( $quantity ); } $quantity = NumberUtil::normalize( $quantity ); if ( 0 >= $quantity ) { return wc_stock_amount( 0 ); } $limits = $this->get_cart_item_quantity_limits( $cart_item ); $new_quantity = $this->limit_to_multiple( $quantity, $limits['multiple_of'], 'round' ); if ( $new_quantity < $limits['minimum'] ) { $new_quantity = $limits['minimum']; } if ( $new_quantity > $limits['maximum'] ) { $new_quantity = $limits['maximum']; } return wc_stock_amount( $new_quantity ); } /** * Return a number using the closest multiple of another number. Used to enforce step/multiple values. * * @param int|float $number Number to round. * @param int|float $multiple_of The multiple. * @param string $rounding_function ceil, floor, or round. * @return int|float */ public function limit_to_multiple( $number, $multiple_of, string $rounding_function = 'round' ) { // Handle edge cases. $number = NumberUtil::normalize( $number, null ); $multiple_of = NumberUtil::normalize( $multiple_of, null ); if ( is_null( $multiple_of ) || is_null( $number ) ) { return 0; } if ( 0 >= $multiple_of || $this->is_multiple_of( $number, $multiple_of ) ) { return $number; } // Ensure valid rounding function. $rounding_function = in_array( $rounding_function, [ 'ceil', 'floor', 'round' ], true ) ? $rounding_function : 'round'; return NumberUtil::normalize( $rounding_function( $number / $multiple_of ) * $multiple_of ); } /** * Checks if a number is a multiple of another number. * * @param int|float $number The number to check. * @param int|float $multiple_of The multiple. * @return bool */ protected function is_multiple_of( $number, $multiple_of ) { if ( 0 >= $multiple_of ) { return false; } $division_result = $number / $multiple_of; // Use tolerance for floating-point comparison to handle precision errors. // Example: 0.3 / 0.1 = 2.9999999999999996 instead of exactly 3.0 due to floating-point precision. return abs( $division_result - round( $division_result ) ) < 0.0001; } /** * Check that a given quantity is valid according to any limits in place. * * @param int|float $quantity Quantity to validate. * @param array $cart_item Cart item. * @return \WP_Error|true */ public function validate_cart_item_quantity( $quantity, $cart_item ) { $limits = $this->get_cart_item_quantity_limits( $cart_item ); $product = $cart_item['data'] ?? false; $quantity = wc_stock_amount( $quantity ); if ( ! $product instanceof \WC_Product ) { return true; } if ( ! $limits['editable'] && $quantity > $limits['maximum'] ) { /* translators: 1: product name */ return new \WP_Error( 'readonly_quantity', sprintf( __( 'The quantity of "%1$s" cannot be changed', 'woocommerce' ), $product->get_name() ) ); } if ( $quantity < $limits['minimum'] ) { /* translators: 1: product name 2: minimum quantity */ return new \WP_Error( 'invalid_quantity', sprintf( __( 'The minimum quantity of "%1$s" allowed in the cart is %2$s', 'woocommerce' ), $product->get_name(), $limits['minimum'] ) ); } if ( $quantity > $limits['maximum'] ) { /* translators: 1: product name 2: maximum quantity */ return new \WP_Error( 'invalid_quantity', sprintf( __( 'The maximum quantity of "%1$s" allowed in the cart is %2$s', 'woocommerce' ), $product->get_name(), $limits['maximum'] ) ); } if ( ! $this->is_multiple_of( $quantity, NumberUtil::normalize( $limits['multiple_of'] ) ) ) { /* translators: 1: product name 2: multiple of */ return new \WP_Error( 'invalid_quantity', sprintf( __( 'The quantity of "%1$s" must be a multiple of %2$s', 'woocommerce' ), $product->get_name(), $limits['multiple_of'] ) ); } return true; } /** * Get the limit for the total number of a product allowed in the cart. * * This is based on product properties, including remaining stock, and defaults to a maximum of 9999 of any product * in the cart at once. * * @param int|float $purchase_limit The purchase limit from the product. Usually maps to `get_max_purchase_quantity`. * @param \WC_Product $product Product instance. * @param array|null $cart_item Optional cart item associated with the product. * @return int|float */ protected function adjust_product_quantity_limit( $purchase_limit, \WC_Product $product, $cart_item = null ) { $limits = [ $purchase_limit > 0 ? $purchase_limit : 9999 ]; // If managing stock and backorders are not allowed, get the remaining stock considering active carts. if ( $product->managing_stock() && ! $product->backorders_allowed() ) { $limits[] = $this->get_remaining_stock( $product ); } return $this->filter_numeric_value( min( array_filter( $limits ) ), 'limit', $product, $cart_item ); } /** * Returns the remaining stock for a product if it has stock. * * This also factors in draft orders. * * @param \WC_Product $product Product instance. * @return int|float|null */ protected function get_remaining_stock( \WC_Product $product ) { if ( is_null( $product->get_stock_quantity() ) ) { return null; } $reserve_stock = new ReserveStock(); $reserved_stock = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() ); return wc_stock_amount( $product->get_stock_quantity() - $reserved_stock ); } /** * Get a numeric value while running it through a filter hook. * * @param int|float $value Value to filter. * @param string $value_type Type of value. Used for filter suffix. * @param \WC_Product $product Product instance. * @param array|null $cart_item Optional cart item associated with the product. * @return int|float */ protected function filter_numeric_value( $value, string $value_type, \WC_Product $product, $cart_item = null ) { /** * Filters a quantity for a cart item in Store API. This allows extensions to control the qty of items. * * The suffix of the hook will vary depending on the value being filtered. * For example, minimum, maximum, multiple_of, editable. * * @since 6.8.0 * * @param mixed $value The value being filtered. * @param \WC_Product $product The product object. * @param array|null $cart_item The cart item if the product exists in the cart, or null. * @return mixed */ $filtered_value = apply_filters( 'woocommerce_store_api_product_quantity_' . $value_type, $value, $product, $cart_item ); return wc_stock_amount( NumberUtil::normalize( $filtered_value, $value ) ); } /** * Get a boolean value while running it through a filter hook. * * @param bool $value Value to filter. * @param string $value_type Type of value. Used for filter suffix. * @param \WC_Product $product Product instance. * @param array|null $cart_item Optional cart item associated with the product. * @return bool */ protected function filter_boolean_value( $value, string $value_type, \WC_Product $product, $cart_item = null ) { /** * Filters boolean data for a cart item in Store API. * * The suffix of the hook will vary depending on the value being filtered. For example, editable. * * @since 6.8.0 * * @param mixed $value The value being filtered. * @param \WC_Product $product The product object. * @param array|null $cart_item The cart item if the product exists in the cart, or null. * @return mixed */ $filtered_value = apply_filters( 'woocommerce_store_api_product_quantity_' . $value_type, $value, $product, $cart_item ); return is_bool( $filtered_value ) ? $filtered_value : (bool) $value; } } Utilities/OrderAuthorizationTrait.php 0000777 00000006447 15251730534 0014117 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; /** * OrderAuthorizationTrait * * Shared functionality for getting order authorization. */ trait OrderAuthorizationTrait { /** * Check if authorized to get the order. * * @throws RouteException If the order is not found or the order key is invalid. * * @param \WP_REST_Request $request Request object. * @return boolean|\WP_Error */ public function is_authorized( \WP_REST_Request $request ) { $order_id = absint( $request['id'] ); $order_key = sanitize_text_field( wp_unslash( $request->get_param( 'key' ) ) ); $billing_email = sanitize_text_field( wp_unslash( $request->get_param( 'billing_email' ) ) ); try { $order = wc_get_order( $order_id ); if ( ! $order ) { throw new RouteException( 'woocommerce_rest_invalid_order', esc_html__( 'Invalid order ID.', 'woocommerce' ), 404 ); } $order_customer_id = $order->get_customer_id(); // If the order belongs to a registered customer, check if the current user is the owner. if ( $order_customer_id ) { // If current user is the order owner, allow access, otherwise reject with an error. if ( get_current_user_id() === $order_customer_id ) { return true; } else { throw new RouteException( 'woocommerce_rest_invalid_user', esc_html__( 'This order belongs to a different customer.', 'woocommerce' ), 403 ); } } // Guest order: require order key and billing email validation for all visitors (logged-in or not). $this->order_controller->validate_order_key( $order_id, $order_key ); $this->validate_billing_email_matches_order( $order_id, $billing_email ); } catch ( RouteException $error ) { return new \WP_Error( $error->getErrorCode(), $error->getMessage(), array( 'status' => $error->getCode() ) ); } return true; } /** * Validate a given billing email against an existing order. * * @throws RouteException Exception if invalid data is detected. * @param integer $order_id Order ID. * @param string $billing_email Billing email. */ public function validate_billing_email_matches_order( $order_id, $billing_email ) { $order = wc_get_order( $order_id ); if ( ! $order ) { throw new RouteException( 'woocommerce_rest_invalid_order', esc_html__( 'Invalid order ID.', 'woocommerce' ), 404 ); } $order_billing_email = $order->get_billing_email(); // If the order doesn't have an email, then allowing an empty billing_email param is acceptable. It will still be compared to order email below. if ( ! $billing_email && ! empty( $order_billing_email ) ) { throw new RouteException( 'woocommerce_rest_invalid_billing_email', esc_html__( 'No billing email provided.', 'woocommerce' ), 401 ); } // For Store API authorization, the provided billing email must exactly match the order's billing email. We use // direct comparison rather than Users::should_user_verify_order_email() because that function has a grace // period for newly created orders which is inappropriate for use when querying orders on the API. if ( 0 !== strcasecmp( $order_billing_email, $billing_email ) ) { throw new RouteException( 'woocommerce_rest_invalid_billing_email', esc_html__( 'Invalid billing email provided.', 'woocommerce' ), 401 ); } } } Utilities/AgenticCheckoutUtils.php 0000777 00000040716 15251730534 0013335 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\CheckoutSessionStatus; use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\ErrorCode; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\SessionKey; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Errors\Error; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Error as AgenticError; use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\AgenticCheckoutSession; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages\MessageError; use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Messages\Messages; /** * AgenticCheckoutUtils class. * * Utility class for shared Agentic Checkout API functionality. */ class AgenticCheckoutUtils { /** * Get the shared parameters schema for checkout session requests. * * @return array Parameters array. */ public static function get_shared_params() { return [ 'items' => [ 'description' => __( 'Line items to add to the cart.', 'woocommerce' ), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'Product ID.', 'woocommerce' ), 'type' => 'string', ], 'quantity' => [ 'description' => __( 'Quantity.', 'woocommerce' ), 'type' => 'integer', 'minimum' => 1, ], ], 'required' => [ 'id', 'quantity' ], ], ], 'buyer' => [ 'description' => __( 'Buyer information.', 'woocommerce' ), 'type' => 'object', 'properties' => [ 'first_name' => [ 'description' => __( 'First name.', 'woocommerce' ), 'type' => 'string', ], 'last_name' => [ 'description' => __( 'Last name.', 'woocommerce' ), 'type' => 'string', ], 'email' => [ 'description' => __( 'Email address.', 'woocommerce' ), 'type' => 'string', 'format' => 'email', ], 'phone_number' => [ 'description' => __( 'Phone number.', 'woocommerce' ), 'type' => 'string', ], ], ], 'fulfillment_address' => [ 'description' => __( 'Fulfillment/shipping address.', 'woocommerce' ), 'type' => 'object', 'properties' => [ 'name' => [ 'description' => __( 'Full name.', 'woocommerce' ), 'type' => 'string', ], 'line_one' => [ 'description' => __( 'Address line 1.', 'woocommerce' ), 'type' => 'string', ], 'line_two' => [ 'description' => __( 'Address line 2.', 'woocommerce' ), 'type' => 'string', ], 'city' => [ 'description' => __( 'City.', 'woocommerce' ), 'type' => 'string', ], 'state' => [ 'description' => __( 'State/province.', 'woocommerce' ), 'type' => 'string', ], 'country' => [ 'description' => __( 'Country code (ISO 3166-1 alpha-2).', 'woocommerce' ), 'type' => 'string', ], 'postal_code' => [ 'description' => __( 'Postal/ZIP code.', 'woocommerce' ), 'type' => 'string', ], ], 'required' => [ 'line_one', 'city', 'country', 'postal_code' ], ], ]; } /** * Add items to cart from request. * * @param array $items Items array from request. * @param CartController $cart_controller Cart controller instance. * @param Messages $messages Error messages instance. * @return Error|null Returns error response on failure, null on success. */ public static function add_items_to_cart( $items, $cart_controller, $messages ) { foreach ( $items as $item_index => $item ) { if ( ! ctype_digit( $item['id'] ) ) { return AgenticError::invalid_request( 'invalid_product_id', __( 'Product ID must be numeric.', 'woocommerce' ), '$.items[' . $item_index . '].id' ); } $product_id = (int) $item['id']; $quantity = (int) $item['quantity']; try { $cart_controller->add_to_cart( [ 'id' => $product_id, 'quantity' => $quantity, ] ); } catch ( RouteException $exception ) { $message = wp_specialchars_decode( $exception->getMessage(), ENT_QUOTES ); $param = '$.items[' . $item_index . ']'; $message_error = null; // Map WooCommerce error codes to Agentic Commerce Protocol error codes. switch ( $exception->getErrorCode() ) { case 'woocommerce_rest_product_out_of_stock': case 'woocommerce_rest_product_partially_out_of_stock': $message_error = MessageError::out_of_stock( $message, $param ); break; } if ( null !== $message_error ) { $messages->add( $message_error ); } else { // The error code is generally applicable only to MessageErrors, but we can use it here as well. return AgenticError::invalid_request( ErrorCode::INVALID, $message, $param ); } } } return null; } /** * Set buyer data on customer. * * @param array $buyer Buyer data. * @param \WC_Customer $customer Customer instance. */ public static function set_buyer_data( $buyer, $customer ) { if ( isset( $buyer['first_name'] ) ) { $first_name = wc_clean( wp_unslash( $buyer['first_name'] ) ); $customer->set_billing_first_name( $first_name ); $customer->set_shipping_first_name( $first_name ); } if ( isset( $buyer['last_name'] ) ) { $last_name = wc_clean( wp_unslash( $buyer['last_name'] ) ); $customer->set_billing_last_name( $last_name ); $customer->set_shipping_last_name( $last_name ); } if ( isset( $buyer['email'] ) ) { $email = sanitize_email( wp_unslash( $buyer['email'] ) ); if ( is_email( $email ) ) { $customer->set_billing_email( $email ); } } if ( isset( $buyer['phone_number'] ) ) { $phone = wc_clean( wp_unslash( $buyer['phone_number'] ) ); $customer->set_billing_phone( $phone ); } $customer->save(); } /** * Set fulfillment address on customer. * * @param array $address Address data. * @param \WC_Customer $customer Customer instance. */ public static function set_fulfillment_address( $address, $customer ) { // Only parse and set name if provided and non-empty. if ( ! empty( $address['name'] ) ) { $name = wc_clean( wp_unslash( $address['name'] ) ); $name_parts = explode( ' ', $name, 2 ); $first_name = $name_parts[0]; $last_name = isset( $name_parts[1] ) ? $name_parts[1] : ''; // Set shipping names. $customer->set_shipping_first_name( $first_name ); $customer->set_shipping_last_name( $last_name ); } else { // Preserve existing shipping names. $first_name = $customer->get_shipping_first_name(); $last_name = $customer->get_shipping_last_name(); } // Sanitize all address fields. $line_one = wc_clean( wp_unslash( $address['line_one'] ?? '' ) ); $line_two = wc_clean( wp_unslash( $address['line_two'] ?? '' ) ); $city = wc_clean( wp_unslash( $address['city'] ?? '' ) ); $state = wc_clean( wp_unslash( $address['state'] ?? '' ) ); $postal_code = wc_clean( wp_unslash( $address['postal_code'] ?? '' ) ); $country = wc_clean( wp_unslash( $address['country'] ?? '' ) ); // Set shipping address fields. $customer->set_shipping_address_1( $line_one ); $customer->set_shipping_address_2( $line_two ); $customer->set_shipping_city( $city ); $customer->set_shipping_state( $state ); $customer->set_shipping_postcode( $postal_code ); $customer->set_shipping_country( $country ); // Also set as billing address if not already set. if ( ! $customer->get_billing_address_1() ) { // For billing, only set names if provided or use existing billing names. if ( ! empty( $address['name'] ) ) { $customer->set_billing_first_name( $first_name ); $customer->set_billing_last_name( $last_name ); } $customer->set_billing_address_1( $line_one ); $customer->set_billing_address_2( $line_two ); $customer->set_billing_city( $city ); $customer->set_billing_state( $state ); $customer->set_billing_postcode( $postal_code ); $customer->set_billing_country( $country ); } $customer->save(); } /** * Clear fulfillment address from customer. * * @param \WC_Customer $customer Customer instance. */ public static function clear_fulfillment_address( $customer ) { // Clear shipping address. $customer->set_shipping_first_name( '' ); $customer->set_shipping_last_name( '' ); $customer->set_shipping_address_1( '' ); $customer->set_shipping_address_2( '' ); $customer->set_shipping_city( '' ); $customer->set_shipping_state( '' ); $customer->set_shipping_postcode( '' ); $customer->set_shipping_country( '' ); $customer->save(); } /** * Set billing address on customer. * * @param array $address Address data. * @param \WC_Customer $customer Customer instance. */ public static function set_billing_address( $address, $customer ) { // Only parse and set name if provided and non-empty. if ( ! empty( $address['name'] ) ) { $name = wc_clean( wp_unslash( $address['name'] ) ); $name_parts = explode( ' ', $name, 2 ); $first_name = $name_parts[0]; $last_name = isset( $name_parts[1] ) ? $name_parts[1] : ''; // Set billing names. $customer->set_billing_first_name( $first_name ); $customer->set_billing_last_name( $last_name ); } // Sanitize all address fields. $line_one = wc_clean( wp_unslash( $address['line_one'] ?? '' ) ); $line_two = wc_clean( wp_unslash( $address['line_two'] ?? '' ) ); $city = wc_clean( wp_unslash( $address['city'] ?? '' ) ); $state = wc_clean( wp_unslash( $address['state'] ?? '' ) ); $postal_code = wc_clean( wp_unslash( $address['postal_code'] ?? '' ) ); $country = wc_clean( wp_unslash( $address['country'] ?? '' ) ); // Set billing address fields. $customer->set_billing_address_1( $line_one ); $customer->set_billing_address_2( $line_two ); $customer->set_billing_city( $city ); $customer->set_billing_state( $state ); $customer->set_billing_postcode( $postal_code ); $customer->set_billing_country( $country ); $customer->save(); } /** * Add Agentic Commerce Protocol headers to response. * * @param \WP_REST_Response $response Response object. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response with headers. */ public static function add_protocol_headers( \WP_REST_Response $response, \WP_REST_Request $request ) { // Echo Idempotency-Key header if provided. $idempotency_key = $request->get_header( 'Idempotency-Key' ); if ( $idempotency_key ) { $response->header( 'Idempotency-Key', $idempotency_key ); } // Echo Request-Id header if provided. $request_id = $request->get_header( 'Request-Id' ); if ( $request_id ) { $response->header( 'Request-Id', $request_id ); } return $response; } /** * Check if the Agentic Checkout feature is enabled and request is authorized. * * Validates bearer token against registered agents in the agent registry. * * @param \WP_REST_Request $request Request object. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public static function is_authorized( $request = null ) { if ( null === $request ) { return new \WP_Error( 'invalid_request', __( 'Invalid request object.', 'woocommerce' ), array( 'status' => 400, 'type' => 'invalid_request', 'code' => 'invalid_request', ) ); } $auth_header = $request->get_header( 'Authorization' ); if ( empty( $auth_header ) || 0 !== stripos( $auth_header, 'Bearer ' ) ) { return new \WP_Error( 'invalid_request', __( 'Invalid authorization.', 'woocommerce' ), array( 'status' => 400, 'type' => 'invalid_request', 'code' => 'invalid_authorization_format', ) ); } $provided_token = trim( substr( $auth_header, 7 ) ); // "Bearer " is 7 characters if ( empty( $provided_token ) ) { return new \WP_Error( 'invalid_request', __( 'Invalid authorization.', 'woocommerce' ), array( 'status' => 400, 'type' => 'invalid_request', 'code' => 'invalid_authorization_format', ) ); } $registry = get_option( \Automattic\WooCommerce\Internal\Admin\Agentic\AgenticSettingsPage::REGISTRY_OPTION, array() ); $authenticated_provider = null; // Check each provider's bearer token. foreach ( $registry as $provider_id => $provider_config ) { if ( ! is_array( $provider_config ) || empty( $provider_config['bearer_token'] ) ) { continue; } if ( wp_check_password( $provided_token, $provider_config['bearer_token'] ) ) { // Store and continue checking to minimize timing attack. $authenticated_provider = $provider_id; } } if ( null !== $authenticated_provider ) { if ( WC()->session ) { WC()->session->set( SessionKey::AGENTIC_CHECKOUT_PROVIDER_ID, $authenticated_provider ); } return true; } return new \WP_Error( 'invalid_request', __( 'Invalid authorization.', 'woocommerce' ), array( 'status' => 400, 'type' => 'invalid_request', 'code' => 'authentication_failed', ) ); } /** * Validates a session. * * @param AgenticCheckoutSession $checkout_session Checkout session object. * @return void */ public static function validate( AgenticCheckoutSession $checkout_session ): void { $messages = $checkout_session->get_messages(); // Check if ready for payment. $needs_shipping = $checkout_session->get_cart()->needs_shipping(); $has_address = WC()->customer && WC()->customer->get_shipping_address_1(); // Add info message if shipping is needed. if ( $needs_shipping && ! $has_address ) { $messages->add( MessageError::missing( __( 'Shipping address required.', 'woocommerce' ), '$.fulfillment_address' ) ); } // Check if valid shipping method is selected (not just empty strings). $chosen_methods = WC()->session ? WC()->session->get( SessionKey::CHOSEN_SHIPPING_METHODS ) : null; $has_shipping = ! empty( $chosen_methods ) && ! empty( array_filter( $chosen_methods ) ); if ( $needs_shipping && ! $has_shipping ) { $messages->add( MessageError::missing( __( 'No shipping method selected.', 'woocommerce' ), '$.fulfillment_option_id' ) ); } } /** * Calculate the status of the checkout session. * * @param AgenticCheckoutSession $checkout_session Checkout session object. * * @return string Status value. */ public static function calculate_status( AgenticCheckoutSession $checkout_session ): string { $wc_session = WC()->session; if ( null === $wc_session ) { return CheckoutSessionStatus::CANCELED; } if ( $wc_session->get( SessionKey::AGENTIC_CHECKOUT_COMPLETED_ORDER_ID ) ) { return CheckoutSessionStatus::COMPLETED; } if ( $wc_session->get( SessionKey::AGENTIC_CHECKOUT_PAYMENT_IN_PROGRESS ) ) { return CheckoutSessionStatus::IN_PROGRESS; } // Check for validation errors. if ( $checkout_session->get_messages()->has_errors() // Once we switch to using the CartController everywhere, there should be no notices and need for this. || ! empty( wc_get_notices( 'error' ) ) ) { return CheckoutSessionStatus::NOT_READY_FOR_PAYMENT; } return CheckoutSessionStatus::READY_FOR_PAYMENT; } /** * Get the agentic commerce payment gateway from available gateways. * * Finds the first gateway that supports agentic commerce and has the required methods. * * @param array $available_gateways Array of available payment gateways. * @return \WC_Payment_Gateway|null The agentic commerce gateway or null if not found. */ public static function get_agentic_commerce_gateway( $available_gateways ) { if ( empty( $available_gateways ) ) { return null; } foreach ( $available_gateways as $gateway ) { if ( $gateway->supports( \Automattic\WooCommerce\Enums\PaymentGatewayFeature::AGENTIC_COMMERCE ) && method_exists( $gateway, 'get_agentic_commerce_provider' ) && method_exists( $gateway, 'get_agentic_commerce_payment_methods' ) ) { return $gateway; } } return null; } /** * Whether the current request is within Agentic Commerce session. * * @return bool */ public static function is_agentic_commerce_session(): bool { $wc_session = WC()->session; if ( null === $wc_session ) { return false; } return ! empty( $wc_session->get( SessionKey::AGENTIC_CHECKOUT_SESSION_ID ) ); } } Utilities/SanitizationUtils.php 0000777 00000001432 15251730534 0012741 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; /** * SanitizationUtils class. * Helper class which sanitizes customer info. */ class SanitizationUtils { /** * Runs wp_kses on an array. This function runs wp_kses on strings in the array and recurses into arrays. * * @param array $array The array to run wp_kses on. * @return mixed The array, all string keys will have been run through wp_kses. */ public function wp_kses_array( array $array ) { foreach ( $array as $key => $value ) { if ( empty( $value ) ) { $array[ $key ] = $value; continue; } if ( is_array( $value ) ) { $array[ $key ] = $this->wp_kses_array( $value ); } if ( is_string( $value ) ) { $array[ $key ] = wp_kses( $value, [] ); } } return $array; } } Utilities/ProductQueryFilters.php 0000777 00000022375 15251730534 0013254 0 ustar 00 <?php namespace Automattic\WooCommerce\StoreApi\Utilities; use Automattic\WooCommerce\Enums\ProductStockStatus; use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery; /** * Product Query filters class. */ class ProductQueryFilters { /** * Get filtered min price for current products. * * @param \WP_REST_Request $request The request object. * @return object */ public function get_filtered_price( $request ) { global $wpdb; // Regenerate the products query without min/max price request params. unset( $request['min_price'], $request['max_price'] ); // Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products. $product_query = new ProductQuery(); add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 ); add_filter( 'posts_pre_query', '__return_empty_array' ); $query_args = $product_query->prepare_objects_query( $request ); $query_args['no_found_rows'] = true; $query_args['posts_per_page'] = -1; $query = new \WP_Query(); $result = $query->query( $query_args ); $product_query_sql = $query->request; remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 ); remove_filter( 'posts_pre_query', '__return_empty_array' ); $price_filter_sql = " SELECT min( min_price ) as min_price, MAX( max_price ) as max_price FROM {$wpdb->wc_product_meta_lookup} WHERE product_id IN ( {$product_query_sql} ) "; return $wpdb->get_row( $price_filter_sql ); // phpcs:ignore } /** * Get stock status counts for the current products. * * @param \WP_REST_Request $request The request object. * @return array status=>count pairs. */ public function get_stock_status_counts( $request ) { global $wpdb; $product_query = new ProductQuery(); $stock_status_options = array_map( 'esc_sql', array_keys( wc_get_product_stock_status_options() ) ); $hide_outofstock_items = get_option( 'woocommerce_hide_out_of_stock_items' ); if ( 'yes' === $hide_outofstock_items ) { unset( $stock_status_options[ ProductStockStatus::OUT_OF_STOCK ] ); } add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 ); add_filter( 'posts_pre_query', '__return_empty_array' ); $query_args = $product_query->prepare_objects_query( $request ); unset( $query_args['stock_status'] ); $query_args['no_found_rows'] = true; $query_args['posts_per_page'] = -1; $query = new \WP_Query(); $result = $query->query( $query_args ); $product_query_sql = $query->request; remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 ); remove_filter( 'posts_pre_query', '__return_empty_array' ); $stock_status_counts = array(); foreach ( $stock_status_options as $status ) { $stock_status_count_sql = $this->generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options ); $result = $wpdb->get_row( $stock_status_count_sql ); // phpcs:ignore $stock_status_counts[ $status ] = $result->status_count; } return $stock_status_counts; } /** * Generate calculate query by stock status. * * @param string $status status to calculate. * @param string $product_query_sql product query for current filter state. * @param array $stock_status_options available stock status options. * * @return false|string */ private function generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options ) { if ( ! in_array( $status, $stock_status_options, true ) ) { return false; } global $wpdb; $status = esc_sql( $status ); return " SELECT COUNT( DISTINCT posts.ID ) as status_count FROM {$wpdb->posts} as posts INNER JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id AND postmeta.meta_key = '_stock_status' AND postmeta.meta_value = '{$status}' WHERE posts.ID IN ( {$product_query_sql} ) "; } /** * Get attribute counts for the current products. * * @param \WP_REST_Request $request The request object. * @param array $attributes Attributes to count, either names or ids. * @return array termId=>count pairs. */ public function get_attribute_counts( $request, $attributes = [] ) { global $wpdb; // Remove paging and sorting params from the request. $request->set_param( 'page', null ); $request->set_param( 'per_page', null ); $request->set_param( 'order', null ); $request->set_param( 'orderby', null ); // Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products. $product_query = new ProductQuery(); add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 ); add_filter( 'posts_pre_query', '__return_empty_array' ); $query_args = $product_query->prepare_objects_query( $request ); $query_args['no_found_rows'] = true; $query_args['posts_per_page'] = -1; $query = new \WP_Query(); $result = $query->query( $query_args ); $product_query_sql = $query->request; remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 ); remove_filter( 'posts_pre_query', '__return_empty_array' ); if ( count( $attributes ) === count( array_filter( $attributes, 'is_numeric' ) ) ) { $attributes = array_map( 'wc_attribute_taxonomy_name_by_id', wp_parse_id_list( $attributes ) ); } $attributes_to_count = array_map( function ( $attribute ) { $attribute = wc_sanitize_taxonomy_name( $attribute ); return esc_sql( $attribute ); }, $attributes ); $attributes_to_count_sql = 'AND term_taxonomy.taxonomy IN (\'' . implode( '\',\'', $attributes_to_count ) . '\')'; $attribute_count_sql = " SELECT COUNT( DISTINCT posts.ID ) as term_count, terms.term_id as term_count_id FROM {$wpdb->posts} AS posts INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = term_relationships.object_id INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id ) INNER JOIN {$wpdb->terms} AS terms USING( term_id ) WHERE posts.ID IN ( {$product_query_sql} ) {$attributes_to_count_sql} GROUP BY terms.term_id "; $results = $wpdb->get_results( $attribute_count_sql ); // phpcs:ignore return array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) ); } /** * Get rating counts for the current products. * * @param \WP_REST_Request $request The request object. * @return array rating=>count pairs. */ public function get_rating_counts( $request ) { global $wpdb; // Regenerate the products query without rating request params. unset( $request['rating'] ); // Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products. $product_query = new ProductQuery(); add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 ); add_filter( 'posts_pre_query', '__return_empty_array' ); $query_args = $product_query->prepare_objects_query( $request ); $query_args['no_found_rows'] = true; $query_args['posts_per_page'] = -1; $query = new \WP_Query(); $result = $query->query( $query_args ); $product_query_sql = $query->request; remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 ); remove_filter( 'posts_pre_query', '__return_empty_array' ); $rating_count_sql = " SELECT COUNT( DISTINCT product_id ) as product_count, ROUND( average_rating, 0 ) as rounded_average_rating FROM {$wpdb->wc_product_meta_lookup} WHERE product_id IN ( {$product_query_sql} ) AND average_rating > 0 GROUP BY rounded_average_rating ORDER BY rounded_average_rating ASC "; $results = $wpdb->get_results( $rating_count_sql ); // phpcs:ignore return array_map( 'absint', wp_list_pluck( $results, 'product_count', 'rounded_average_rating' ) ); } /** * Get taxonomy counts for the current products. * * @param \WP_REST_Request $request The request object. * @param array $taxonomies Taxonomies to count. * @return array termId=>count pairs. */ public function get_taxonomy_counts( $request, $taxonomies = [] ) { // Remove paging and sorting params from the request. $request->set_param( 'page', null ); $request->set_param( 'per_page', null ); $request->set_param( 'order', null ); $request->set_param( 'orderby', null ); // Convert request to query_vars for FilterData. $product_query = new ProductQuery(); $query_vars = $product_query->prepare_objects_query( $request ); // Use FilterData with ProductQuery as QueryClausesGenerator. $container = wc_get_container(); $filter_data_provider = $container->get( \Automattic\WooCommerce\Internal\ProductFilters\FilterDataProvider::class ); $filter_data = $filter_data_provider->with( $product_query ); $all_counts = array(); // Get counts for each taxonomy individually. foreach ( $taxonomies as $taxonomy ) { $counts = $filter_data->get_taxonomy_counts( $query_vars, $taxonomy ); $all_counts = $all_counts + $counts; // Use + operator to preserve keys. } return $all_counts; } }
| ver. 1.6 |
Github
|
.
| PHP 8.1.34 | Генерация страницы: 0 |
proxy
|
phpinfo
|
Настройка