first commit

This commit is contained in:
Rachit Bhargava
2023-07-21 17:12:10 -04:00
parent d0fe47dde4
commit 5d0f0734d8
14003 changed files with 2829464 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
<?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\StoreApi\Utilities\RateLimits;
/**
* Authentication class.
*/
class Authentication {
/**
* Hook into WP lifecycle events.
*/
public function init() {
add_filter( 'rest_authentication_errors', array( $this, 'check_authentication' ) );
add_action( 'set_logged_in_cookie', array( $this, 'set_logged_in_cookie' ) );
}
/**
* 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 ) {
if ( ! $this->is_request_to_store_api() ) {
return $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' ) || ! $this->is_request_to_store_api() ) {
return;
}
$_COOKIE[ LOGGED_IN_COOKIE ] = $logged_in_cookie;
}
/**
* 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_';
if ( is_user_logged_in() ) {
$action_id .= get_current_user_id();
} else {
$ip_address = self::get_ip_address( $rate_limiting_options->proxy_support );
$action_id .= md5( $ip_address );
}
$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-Retry-After', $retry );
$server->send_header( 'RateLimit-Remaining', 0 );
$server->send_header( 'RateLimit-Reset', time() + $retry );
$ip_address = $ip_address ?? self::get_ip_address( $rate_limiting_options->proxy_support );
/**
* Fires when the rate limit is exceeded.
*
* @since 8.9.0
*
* @param string $ip_address The IP address of the request.
*/
do_action( 'woocommerce_store_api_rate_limit_exceeded', $ip_address );
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;
}
/**
* 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/' );
}
/**
* 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';
}
}

View File

@@ -0,0 +1,74 @@
<?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;
}
}

View File

@@ -0,0 +1,73 @@
<?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;
}
}

View File

@@ -0,0 +1,9 @@
<?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 {}

View File

@@ -0,0 +1,9 @@
<?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 {}

View File

@@ -0,0 +1,9 @@
<?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 {}

View File

@@ -0,0 +1,53 @@
<?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;
}
}

View File

@@ -0,0 +1,72 @@
<?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;
}
}

View File

@@ -0,0 +1,9 @@
<?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 {}

View File

@@ -0,0 +1,47 @@
<?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();
}
}

View File

@@ -0,0 +1,51 @@
<?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,
]
);
}
}

View File

@@ -0,0 +1,18 @@
<?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;
}
}

View File

@@ -0,0 +1,16 @@
<?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 = [] );
}

View File

@@ -0,0 +1,28 @@
<?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;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Formatters;
/**
* Money Formatter.
*
* Formats monetary values using store settings.
*/
class MoneyFormatter 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 = [] ) {
$options = wp_parse_args(
$options,
[
'decimals' => wc_get_price_decimals(),
'rounding_mode' => PHP_ROUND_HALF_UP,
]
);
return (string) intval(
round(
( (float) wc_format_decimal( $value ) ) * ( 10 ** absint( $options['decimals'] ) ),
0,
absint( $options['rounding_mode'] )
)
);
}
}

View File

@@ -0,0 +1,70 @@
<?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;
/**
* 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.
*/
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 `process_payment` added notices, clear them. Notices are not displayed from the API -- payment should fail,
// and a generic notice will be shown instead if payment failed.
wc_clear_notices();
// Handle result.
$result->set_status( isset( $gateway_result['result'] ) && 'success' === $gateway_result['result'] ? 'success' : 'failure' );
// set payment_details from result.
$result->set_payment_details( array_merge( $result->payment_details, $gateway_result ) );
$result->set_redirect_url( $gateway_result['redirect'] );
}
}

View File

@@ -0,0 +1,84 @@
<?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;
}
}
}

View File

@@ -0,0 +1,94 @@
<?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 );
}
}

View File

@@ -0,0 +1,21 @@
<?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();
}

View File

@@ -0,0 +1,341 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
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\JsonWebToken;
use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
/**
* 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;
/**
* 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->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 );
$this->cart_controller->calculate_totals();
$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 );
}
}
if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response );
}
if ( $this->is_update_request( $request ) ) {
$this->cart_updated( $request );
}
return $this->add_response_headers( $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() );
// The following headers are deprecated and should be removed in a future version.
$response->header( 'X-WC-Store-API-Nonce', $nonce );
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 ) {
$cart_token = $request->get_header( 'Cart-Token' );
if ( $cart_token && JsonWebToken::validate( $cart_token, $this->get_cart_token_secret() ) ) {
// Overrides the core session class.
add_filter(
'woocommerce_session_handler',
function () {
return SessionHandler::class;
}
);
}
$this->cart_controller->load_cart();
}
/**
* Generates a cart token for the response headers.
*
* Current namespace is used as the token Issuer.
* *
*
* @return string
*/
protected function get_cart_token() {
return JsonWebToken::create(
[
'user_id' => wc()->session->get_customer_id(),
'exp' => $this->get_cart_token_expiration(),
'iss' => $this->namespace,
],
$this->get_cart_token_secret()
);
}
/**
* Gets the secret for the cart token using wp_salt.
*
* @return string
*/
protected function get_cart_token_secret() {
return '@' . wp_salt();
}
/**
* Gets the expiration of the cart token. Defaults to 48h.
*
* @return int
*/
protected function get_cart_token_expiration() {
/**
* Filters the session expiration.
*
* @since 8.7.0
*
* @param int $expiration Expiration in seconds.
*/
return time() + intval( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) );
}
/**
* 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 );
}
/**
* 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->order_controller->update_order_from_cart( $draft_order );
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' );
} elseif ( $request->get_header( 'X-WC-Store-API-Nonce' ) ) {
$nonce = $request->get_header( 'X-WC-Store-API-Nonce' );
// @todo Remove handling and sending of deprecated X-WC-Store-API-Nonce Header (Blocks 7.5.0)
wc_deprecated_argument( 'X-WC-Store-API-Nonce', '7.2.0', 'Use the "Nonce" Header instead. This header will be removed after Blocks release 7.5' );
rest_handle_deprecated_argument( 'X-WC-Store-API-Nonce', 'Use the "Nonce" Header instead. This header will be removed after Blocks release 7.5', '7.2.0' );
}
/**
* 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 = [] ) {
switch ( $http_status_code ) {
case 409:
// If there was a conflict, return the cart so the client can resolve it.
$cart = $this->cart_controller->get_cart_instance();
return new \WP_Error(
$error_code,
$error_message,
array_merge(
$additional_data,
[
'status' => $http_status_code,
'cart' => $this->cart_schema->get_item_response( $cart ),
]
)
);
}
return new \WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );
}
}

View File

@@ -0,0 +1,326 @@
<?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.
*/
protected function get_route_response( \WP_REST_Request $request ) {
throw new RouteException( '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.
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
throw new RouteException( '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.
*/
protected function get_route_update_response( \WP_REST_Request $request ) {
throw new RouteException( '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.
*/
protected function get_route_delete_response( \WP_REST_Request $request ) {
throw new RouteException( '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(),
);
}
}

View File

@@ -0,0 +1,157 @@
<?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,
);
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'],
);
$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 );
}
}

View File

@@ -0,0 +1,128 @@
<?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 '/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',
'enum' => 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( 'X-WC-Store-API-Nonce', $nonce );
$response->header( 'Nonce-Timestamp', time() );
$response->header( 'User-ID', get_current_user_id() );
return $response;
}
}

View File

@@ -0,0 +1,52 @@
<?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 '/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' ],
];
}
/**
* 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_instance() ) );
}
}

View File

@@ -0,0 +1,126 @@
<?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 '/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' => 'integer',
'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', __( 'Cannot create an existing cart item.', 'woocommerce' ), 400 );
}
$cart = $this->cart_controller->get_cart_instance();
/**
* 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 $customer_data An array of customer (user) 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( $cart ) );
$response->set_status( 201 );
return $response;
}
}

View File

@@ -0,0 +1,72 @@
<?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 '/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', __( 'Coupons are disabled.', 'woocommerce' ), 404 );
}
$cart = $this->cart_controller->get_cart_instance();
$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() );
}
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
}

View File

@@ -0,0 +1,147 @@
<?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 '/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;
}
}

View File

@@ -0,0 +1,98 @@
<?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 '/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', __( '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', __( 'Coupon does not exist in the cart.', 'woocommerce' ), 404 );
}
$cart = $this->cart_controller->get_cart_instance();
$cart->remove_coupon( $request['code'] );
$cart->calculate_totals();
return new \WP_REST_Response( null, 204 );
}
}

View File

@@ -0,0 +1,74 @@
<?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 '/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() );
}
}
}

View File

@@ -0,0 +1,142 @@
<?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 '/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;
}
}

View File

@@ -0,0 +1,146 @@
<?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 '/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;
}
}

View File

@@ -0,0 +1,79 @@
<?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 '/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', __( '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 );
if ( $coupon->get_code() !== $coupon_code || ! $coupon->is_valid() ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_error', __( 'Invalid coupon code.', 'woocommerce' ), 400 );
}
if ( ! $this->cart_controller->has_coupon( $coupon_code ) ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_invalid_code', __( '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 );
$cart->calculate_totals();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
}

View File

@@ -0,0 +1,87 @@
<?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 '/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 );
}
}

View File

@@ -0,0 +1,106 @@
<?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 '/cart/select-shipping-rate';
}
/**
* 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' => [
'package_id' => array(
'description' => __( 'The ID of the package being shipped. Leave blank to apply to all packages.', 'woocommerce' ),
'type' => [ 'integer', 'string' ],
'required' => false,
),
'rate_id' => [
'description' => __( 'The chosen rate ID for the package.', 'woocommerce' ),
'type' => 'string',
'required' => true,
],
],
],
'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_shipping_enabled() ) {
throw new RouteException( 'woocommerce_rest_shipping_disabled', __( 'Shipping is disabled.', 'woocommerce' ), 404 );
}
if ( ! isset( $request['rate_id'] ) ) {
throw new RouteException( 'woocommerce_rest_cart_missing_rate_id', __( 'Invalid Rate ID.', 'woocommerce' ), 400 );
}
$cart = $this->cart_controller->get_cart_instance();
$package_id = isset( $request['package_id'] ) ? wc_clean( wp_unslash( $request['package_id'] ) ) : null;
$rate_id = wc_clean( 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() );
}
/**
* 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_shipping();
$cart->calculate_totals();
return rest_ensure_response( $this->cart_schema->get_item_response( $cart ) );
}
}

View File

@@ -0,0 +1,269 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils;
/**
* CartUpdateCustomer class.
*
* Updates the customer billing and shipping address and returns an updated cart--things such as taxes may be recalculated.
*/
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 '/cart/update-customer';
}
/**
* 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' => [
'billing_address' => [
'description' => __( 'Billing address.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->schema->billing_address_schema->get_properties(),
'sanitize_callback' => null,
],
'shipping_address' => [
'description' => __( 'Shipping address.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->schema->shipping_address_schema->get_properties(),
'sanitize_callback' => null,
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ '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 ) ) ),
[
'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, then sanitize.
$billing = $this->schema->billing_address_schema->sanitize_callback(
wp_parse_args(
$request['billing_address'] ?? [],
$this->get_customer_billing_address( $customer )
),
$request,
'billing_address'
);
$shipping = $this->schema->billing_address_schema->sanitize_callback(
wp_parse_args(
$request['shipping_address'] ?? [],
$this->get_customer_shipping_address( $customer )
),
$request,
'shipping_address'
);
// 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,
)
);
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();
$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 ) {
$validation_util = new ValidationUtils();
$billing_country = $customer->get_billing_country();
$billing_state = $customer->get_billing_state();
/**
* There's a bug in WooCommerce core in which not having a state ("") would result in us validating against the store's state.
* This resets the state to an empty string if it doesn't match the country.
*
* @todo Removing this handling once we fix the issue with the state value always being the store one.
*/
if ( ! $validation_util->validate_state( $billing_state, $billing_country ) ) {
$billing_state = '';
}
return [
'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' => $billing_state,
'postcode' => $customer->get_billing_postcode(),
'country' => $billing_country,
'phone' => $customer->get_billing_phone(),
'email' => $customer->get_billing_email(),
];
}
/**
* Get full customer shipping address.
*
* @param \WC_Customer $customer Customer object.
* @return array
*/
protected function get_customer_shipping_address( \WC_Customer $customer ) {
return [
'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(),
];
}
}

View File

@@ -0,0 +1,67 @@
<?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 '/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' => 'integer',
],
],
],
'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 ) );
}
}

View File

@@ -0,0 +1,861 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidStockLevelsInCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
/**
* Checkout class.
*/
class Checkout extends AbstractCartRoute {
use DraftOrderTrait;
/**
* 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 '/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 true;
}
/**
* 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' ],
],
],
],
],
],
$this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE )
),
],
'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 );
$this->cart_controller->calculate_totals();
$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 ( 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 );
}
return $this->add_response_headers( $response );
}
/**
* 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;
}
/**
* 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
);
}
/**
* Process an order.
*
* 1. Obtain Draft Order
* 2. Process Request
* 3. Process Customer
* 4. Validate Order
* 5. 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 ) {
/**
* Validate items etc are allowed in the order before the order is processed. This will fix violations and tell
* the customer.
*/
$this->cart_controller->validate_cart();
/**
* Obtain Draft Order and 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_customer_from_request( $request );
$this->create_or_update_draft_order( $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_order_before_payment( $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.'
);
/**
* 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
);
}
/**
* 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' => wc()->api->get_endpoint_data( '/wc/store/v1/cart' ) ] );
}
$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();
} else {
$this->order_controller->update_order_from_cart( $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',
__( 'Unable to create order', 'woocommerce' ),
500
);
}
// Store order ID to session.
$this->set_draft_order_id( $this->order->get_id() );
/**
* Try to reserve stock for the order.
*
* If creating a draft order on checkout entry, set the timeout to 10 mins.
* If POSTing to the checkout (attempting to pay), set the timeout to 60 mins (using the woocommerce_hold_stock_minutes option).
*/
try {
$reserve_stock = new ReserveStock();
$duration = $request->get_method() === 'POST' ? (int) get_option( 'woocommerce_hold_stock_minutes', 60 ) : 10;
$reserve_stock->reserve_stock_for_order( $this->order, $duration );
} catch ( ReserveStockException $e ) {
throw new RouteException(
$e->getErrorCode(),
$e->getMessage(),
$e->getCode()
);
}
}
/**
* 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;
// Billing address is a required field.
foreach ( $request['billing_address'] 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 = $request['shipping_address'] ?? $request['billing_address'];
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();
}
/**
* 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( $request['customer_note'] ?? '' );
$this->order->set_payment_method( $this->get_request_payment_method_id( $request ) );
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();
}
/**
* 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 ) {
// Transition the order to pending, and then completed. This ensures transactional emails fire for pending_to_complete events.
$this->order->update_status( 'pending' );
$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 {
// Transition the order to pending before making payment.
$this->order->update_status( 'pending' );
// 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 ) {
throw new RouteException( 'woocommerce_rest_checkout_process_payment_error', $e->getMessage(), 402 );
}
}
/**
* 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 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 ];
}
/**
* 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;
}
/**
* 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 ) {
try {
if ( $this->should_create_customer_account( $request ) ) {
$customer_id = $this->create_customer_account(
$request['billing_address']['email'],
$request['billing_address']['first_name'],
$request['billing_address']['last_name']
);
// Log the customer in.
wc_set_customer_auth_cookie( $customer_id );
// Associate customer with the order.
$this->order->set_customer_id( $customer_id );
$this->order->save();
}
} catch ( \Exception $error ) {
switch ( $error->getMessage() ) {
case 'registration-error-invalid-email':
throw new RouteException(
'registration-error-invalid-email',
__( 'Please provide a valid email address.', 'woocommerce' ),
400
);
case 'registration-error-email-exists':
throw new RouteException(
'registration-error-email-exists',
__( 'An account is already registered with your email address. Please log in before proceeding.', 'woocommerce' ),
400
);
}
}
// Persist customer address data to account.
$this->order_controller->sync_customer_data_with_order( $this->order );
}
/**
* 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;
}
/**
* Create a new account for a customer.
*
* The account is created with a generated username. The customer is sent
* an email notifying them about the account and containing a link to set
* their (initial) password.
*
* Intended as a replacement for wc_create_new_customer in WC core.
*
* @throws \Exception If an error is encountered when creating the user account.
*
* @param string $user_email The email address to use for the new account.
* @param string $first_name The first name to use for the new account.
* @param string $last_name The last name to use for the new account.
*
* @return int User id if successful
*/
private function create_customer_account( $user_email, $first_name, $last_name ) {
if ( empty( $user_email ) || ! is_email( $user_email ) ) {
throw new \Exception( 'registration-error-invalid-email' );
}
if ( email_exists( $user_email ) ) {
throw new \Exception( 'registration-error-email-exists' );
}
$username = wc_create_new_customer_username( $user_email );
// Handle password creation.
$password = wp_generate_password();
$password_generated = true;
// Use WP_Error to handle registration errors.
$errors = new \WP_Error();
/**
* Fires before a customer account is registered.
*
* This hook fires before customer accounts are created and passes the form data (username, email) and an array
* of errors.
*
* This could be used to add extra validation logic and append errors to the array.
*
* @since 7.2.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param string $username Customer username.
* @param string $user_email Customer email address.
* @param \WP_Error $errors Error object.
*/
do_action( 'woocommerce_register_post', $username, $user_email, $errors );
/**
* Filters registration errors before a customer account is registered.
*
* This hook filters registration errors. This can be used to manipulate the array of errors before
* they are displayed.
*
* @since 7.2.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param \WP_Error $errors Error object.
* @param string $username Customer username.
* @param string $user_email Customer email address.
* @return \WP_Error
*/
$errors = apply_filters( 'woocommerce_registration_errors', $errors, $username, $user_email );
if ( is_wp_error( $errors ) && $errors->get_error_code() ) {
throw new \Exception( $errors->get_error_code() );
}
/**
* Filters customer data before a customer account is registered.
*
* This hook filters customer data. It allows user data to be changed, for example, username, password, email,
* first name, last name, and role.
*
* @since 7.2.0
*
* @param array $customer_data An array of customer (user) data.
* @return array
*/
$new_customer_data = apply_filters(
'woocommerce_new_customer_data',
array(
'user_login' => $username,
'user_pass' => $password,
'user_email' => $user_email,
'first_name' => $first_name,
'last_name' => $last_name,
'role' => 'customer',
'source' => 'store-api,',
)
);
$customer_id = wp_insert_user( $new_customer_data );
if ( is_wp_error( $customer_id ) ) {
throw $this->map_create_account_error( $customer_id );
}
// Set account flag to remind customer to update generated password.
update_user_option( $customer_id, 'default_password_nag', true, true );
/**
* Fires after a customer account has been registered.
*
* This hook fires after customer accounts are created and passes the customer data.
*
* @since 7.2.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param integer $customer_id New customer (user) ID.
* @param array $new_customer_data Array of customer (user) data.
* @param string $password_generated The generated password for the account.
*/
do_action( 'woocommerce_created_customer', $customer_id, $new_customer_data, $password_generated );
return $customer_id;
}
/**
* Convert an account creation error to an exception.
*
* @param \WP_Error $error An error object.
* @return \Exception.
*/
private function map_create_account_error( \WP_Error $error ) {
switch ( $error->get_error_code() ) {
// WordPress core error codes.
case 'empty_username':
case 'invalid_username':
case 'empty_email':
case 'invalid_email':
case 'email_exists':
case 'registerfail':
return new \Exception( 'woocommerce_rest_checkout_create_account_failure' );
}
return new \Exception( 'woocommerce_rest_checkout_create_account_failure' );
}
}

View File

@@ -0,0 +1,65 @@
<?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 '/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(),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* 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 );
}
}

View File

@@ -0,0 +1,66 @@
<?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 '/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(),
],
'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 );
}
}

View File

@@ -0,0 +1,81 @@
<?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 '/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',
)
),
),
],
'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;
}
}

View File

@@ -0,0 +1,57 @@
<?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 '/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(),
],
'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 );
}
}

View File

@@ -0,0 +1,79 @@
<?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 '/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',
)
),
),
],
'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 );
}
}

View File

@@ -0,0 +1,215 @@
<?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 '/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(),
],
'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,
];
$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,
];
}
}
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,
];
return $params;
}
}

View File

@@ -0,0 +1,231 @@
<?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 '/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(),
],
'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;
}
}

View File

@@ -0,0 +1,50 @@
<?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 '/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(),
],
'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 );
}
}

View File

@@ -0,0 +1,422 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
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 '/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(),
],
'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'] );
$response->header( 'Last-Modified', $product_query->get_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['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() ), [ '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 specific category ID.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wp_parse_id_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',
);
// 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 ( str_starts_with( $param, '_unstable_tax_' ) && ! str_ends_with( $param, '_operator' ) ) {
$params[ $param ] = array(
'description' => __( 'Limit result set to products assigned a specific category ID.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wp_parse_id_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 category 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 specific tag ID.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wp_parse_id_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', 'visible', 'catalog', 'search', '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;
}
}

View File

@@ -0,0 +1,78 @@
<?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 '/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',
)
),
),
],
'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 ) );
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Exception;
use Routes\AbstractRoute;
/**
* RoutesController class.
*/
class RoutesController {
/**
* Stores schema_controller.
*
* @var SchemaController
*/
protected $schema_controller;
/**
* Stores routes.
*
* @var array
*/
protected $routes = [];
/**
* 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\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\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,
],
];
}
/**
* Register all Store API routes. This includes routes under specific version namespaces.
*/
public function register_all_routes() {
$this->register_routes( 'v1', 'wc/store' );
$this->register_routes( 'v1', 'wc/store/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 )
);
}
/**
* 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()
);
}
}
}

View File

@@ -0,0 +1,74 @@
<?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\CheckoutSchema::IDENTIFIER => Schemas\V1\CheckoutSchema::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\ProductCollectionDataSchema::IDENTIFIER => Schemas\V1\ProductCollectionDataSchema::class,
Schemas\V1\ProductReviewSchema::IDENTIFIER => Schemas\V1\ProductReviewSchema::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 );
}
}

View File

@@ -0,0 +1,349 @@
<?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',
'context' => [ 'view', 'edit' ],
'items' => $schema,
];
}
return [
/* translators: %s: extension namespace */
'description' => sprintf( __( 'Extension data registered by %s', 'woocommerce' ), $namespace ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $schema,
];
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils;
/**
* AddressSchema class.
*
* Provides a generic address schema for composition in other schemas.
*/
abstract class AbstractAddressSchema extends AbstractSchema {
/**
* 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 [
'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,
],
];
}
/**
* 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();
$address = array_merge( array_fill_keys( array_keys( $this->get_properties() ), '' ), (array) $address );
$address['country'] = wc_strtoupper( wc_clean( wp_unslash( $address['country'] ) ) );
$address['first_name'] = wc_clean( wp_unslash( $address['first_name'] ) );
$address['last_name'] = wc_clean( wp_unslash( $address['last_name'] ) );
$address['company'] = wc_clean( wp_unslash( $address['company'] ) );
$address['address_1'] = wc_clean( wp_unslash( $address['address_1'] ) );
$address['address_2'] = wc_clean( wp_unslash( $address['address_2'] ) );
$address['city'] = wc_clean( wp_unslash( $address['city'] ) );
$address['state'] = $validation_util->format_state( wc_clean( wp_unslash( $address['state'] ) ), $address['country'] );
$address['postcode'] = $address['postcode'] ? wc_format_postcode( wc_clean( wp_unslash( $address['postcode'] ) ), $address['country'] ) : '';
$address['phone'] = wc_clean( wp_unslash( $address['phone'] ) );
return $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 = $this->sanitize_callback( $address, $request, $param );
$validation_util = new ValidationUtils();
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'] ) && ! \WC_Validation::is_phone( $address['phone'] ) ) {
$errors->add(
'invalid_phone',
__( 'The provided phone number is not valid', 'woocommerce' )
);
}
return $errors->has_errors( $errors ) ? $errors : true;
}
}

View File

@@ -0,0 +1,388 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* 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(),
);
}
/**
* 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;
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 ) ) {
return $result;
}
if ( isset( $property_value['properties'] ) ) {
$validate_callback = $this->get_recursive_validate_callback( $property_value['properties'] );
return $validate_callback( $current_value, $request, $param . ' > ' . $property_key );
}
}
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 );
}
}

View File

@@ -0,0 +1,30 @@
<?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 [];
}
}

View File

@@ -0,0 +1,130 @@
<?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 );
$address['email'] = wc_clean( wp_unslash( $address['email'] ) );
return $address;
}
/**
* Validate 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 true|\WP_Error
*/
public function validate_callback( $address, $request, $param ) {
$errors = parent::validate_callback( $address, $request, $param );
$address = $this->sanitize_callback( $address, $request, $param );
$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 stdClass
*/
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 = '';
}
return (object) $this->prepare_html_response(
[
'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(),
]
);
}
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
);
}
}

View File

@@ -0,0 +1,108 @@
<?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();
$total_discounts = $cart->get_coupon_discount_totals();
$total_discount_taxes = $cart->get_coupon_discount_tax_totals();
$coupon = new \WC_Coupon( $coupon_code );
return [
'code' => $coupon_code,
'discount_type' => $coupon->get_discount_type(),
'totals' => (object) $this->prepare_currency_response(
[
'total_discount' => $this->prepare_money_response( isset( $total_discounts[ $coupon_code ] ) ? $total_discounts[ $coupon_code ] : 0, wc_get_price_decimals() ),
'total_discount_tax' => $this->prepare_money_response( isset( $total_discount_taxes[ $coupon_code ] ) ? $total_discount_taxes[ $coupon_code ] : 0, wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ),
]
),
];
}
}

View File

@@ -0,0 +1,85 @@
<?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',
$e->getMessage(),
400
);
}
$controller = new CartController();
if ( is_callable( $callback ) ) {
$callback( $request['data'] );
// We recalculate the cart if we had something to run.
$controller->calculate_totals();
}
$cart = $controller->get_cart_instance();
return rest_ensure_response( $this->cart_schema->get_item_response( $cart ) );
}
}

View File

@@ -0,0 +1,85 @@
<?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 ),
]
),
];
}
}

View File

@@ -0,0 +1,507 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
/**
* CartItemSchema class.
*/
class CartItemSchema extends ProductSchema {
use DraftOrderTrait;
/**
* The schema item name.
*
* @var string
*/
protected $title = 'cart_item';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-item';
/**
* Cart schema properties.
*
* @return array
*/
public function get_properties() {
return [
'key' => [
'description' => __( 'Unique identifier for the item within the cart.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'id' => [
'description' => __( 'The cart item product or variation ID.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'quantity' => [
'description' => __( 'Quantity of this item in the cart.', '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 in the cart for this line item.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'maximum' => [
'description' => __( 'The maximum quantity allowed in the cart for this line item.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'multiple_of' => [
'description' => __( 'The amount that quantities increment by. Quantity must be an multiple of this value.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'default' => 1,
],
'editable' => [
'description' => __( 'If the quantity in the cart 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' => [ 'integer', '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' => [
'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 cart 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 ),
];
}
/**
* 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'];
/**
* 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(),
'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_images( $product ),
'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 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'] = [
'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, [ 'price' => $product->get_regular_price() ] ), wc_get_rounding_precision() ),
'sale_price' => $this->prepare_money_response( $price_function( $product, [ '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 = [];
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[] = [
'attribute' => $this->prepare_html_response( $label ),
'value' => $this->prepare_html_response( $value ),
];
}
return $return;
}
/**
* 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_strip_all_tags', $item_data_element );
}
}

View File

@@ -0,0 +1,437 @@
<?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 );
// The core cart class will not include shipping in the cart totals if `show_shipping()` returns false. This can
// happen if an address is required, or through the use of hooks. This tracks if shipping has actually been
// calculated so we can avoid returning costs and rates prematurely.
$has_calculated_shipping = $cart->show_shipping();
// Get shipping packages to return in the response from the cart.
$shipping_packages = $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 [
'coupons' => $this->get_item_responses_from_schema( $this->coupon_schema, $cart->get_applied_coupons() ),
'shipping_rates' => $this->get_item_responses_from_schema( $this->shipping_rate_schema, $shipping_packages ),
'shipping_address' => $this->shipping_address_schema->get_item_response( wc()->customer ),
'billing_address' => $this->billing_address_schema->get_item_response( wc()->customer ),
'items' => $this->get_item_responses_from_schema( $this->item_schema, $cart->get_cart() ),
'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 ),
'needs_payment' => $cart->needs_payment(),
'needs_shipping' => $cart->needs_shipping(),
'has_calculated_shipping' => $has_calculated_shipping,
'fees' => $this->get_item_responses_from_schema( $this->fee_schema, $cart->get_fees() ),
'totals' => (object) $this->prepare_currency_response(
[
'total_items' => $this->prepare_money_response( $cart->get_subtotal(), wc_get_price_decimals() ),
'total_items_tax' => $this->prepare_money_response( $cart->get_subtotal_tax(), wc_get_price_decimals() ),
'total_fees' => $this->prepare_money_response( $cart->get_fee_total(), wc_get_price_decimals() ),
'total_fees_tax' => $this->prepare_money_response( $cart->get_fee_tax(), wc_get_price_decimals() ),
'total_discount' => $this->prepare_money_response( $cart->get_discount_total(), wc_get_price_decimals() ),
'total_discount_tax' => $this->prepare_money_response( $cart->get_discount_tax(), wc_get_price_decimals() ),
'total_shipping' => $has_calculated_shipping ? $this->prepare_money_response( $cart->get_shipping_total(), wc_get_price_decimals() ) : null,
'total_shipping_tax' => $has_calculated_shipping ? $this->prepare_money_response( $cart->get_shipping_tax(), wc_get_price_decimals() ) : null,
// Explicitly request context='edit'; default ('view') will render total as markup.
'total_price' => $this->prepare_money_response( $cart->get_total( 'edit' ), wc_get_price_decimals() ),
'total_tax' => $this->prepare_money_response( $cart->get_total_tax(), wc_get_price_decimals() ),
'tax_lines' => $this->get_tax_lines( $cart ),
]
),
'errors' => $cart_errors,
'payment_methods' => array_values( wp_list_pluck( WC()->payment_gateways->get_available_payment_gateways(), 'id' ) ),
'payment_requirements' => $this->extend->get_payment_requirements(),
self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ),
];
}
/**
* 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();
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, wc_get_price_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 ) );
}
}

View File

@@ -0,0 +1,354 @@
<?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;
},
[]
);
}
}

View File

@@ -0,0 +1,224 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* 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;
/**
* 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 );
}
/**
* Checkout schema properties.
*
* @return array
*/
public function get_properties() {
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,
],
'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' ],
'enum' => array_values( wp_list_pluck( WC()->payment_gateways->get_available_payment_gateways(), 'id' ) ),
],
'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 false if not yet processed.', 'woocommerce' ),
'type' => 'object',
'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',
],
],
],
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 ) {
return $this->get_checkout_response( $item->order, $item->payment_result );
}
/**
* Get the checkout response based on the current order and any payments.
*
* @param \WC_Order $order Order object.
* @param PaymentResult $payment_result Payment result object.
* @return array
*/
protected function get_checkout_response( \WC_Order $order, PaymentResult $payment_result = null ) {
return [
'order_id' => $order->get_id(),
'status' => $order->get_status(),
'order_key' => $order->get_order_key(),
'customer_note' => $order->get_customer_note(),
'customer_id' => $order->get_customer_id(),
'billing_address' => $this->billing_address_schema->get_item_response( $order ),
'shipping_address' => $this->shipping_address_schema->get_item_response( $order ),
'payment_method' => $order->get_payment_method(),
'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,
],
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
);
}
}

View File

@@ -0,0 +1,57 @@
<?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( \WP_Error $error ) {
return [
'code' => $this->prepare_html_response( $error->get_error_code() ),
'message' => $this->prepare_html_response( $error->get_error_message() ),
];
}
}

View File

@@ -0,0 +1,99 @@
<?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 array|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 [];
}
$thumbnail = wp_get_attachment_image_src( $attachment_id, 'woocommerce_thumbnail' );
return [
'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 ),
];
}
}

View File

@@ -0,0 +1,78 @@
<?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,
],
'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 array.
* @return array
*/
public function get_item_response( \WC_Order_Item_Coupon $coupon ) {
return [
'code' => $coupon->get_code(),
'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 ),
]
),
];
}
}

View File

@@ -0,0 +1,91 @@
<?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 ),
];
}
}

View File

@@ -0,0 +1,124 @@
<?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;
}
}

View File

@@ -0,0 +1,143 @@
<?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,
],
],
],
],
];
}
/**
* 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'],
];
}
}

View File

@@ -0,0 +1,186 @@
<?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( \WP_Comment $review ) {
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$rating = get_comment_meta( $review->comment_ID, 'rating', true ) === '' ? null : (int) get_comment_meta( $review->comment_ID, 'rating', true );
$data = [
'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' => $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 ),
];
if ( 'view' === $context ) {
$data['review'] = wpautop( $data['review'] );
}
return $data;
}
}

View File

@@ -0,0 +1,857 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
/**
* 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' ],
],
'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,
],
],
],
],
'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,
],
],
],
],
],
],
],
'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,
],
'low_stock_remaining' => [
'description' => __( 'Quantity left in stock if stock is low, or null if not applicable.', 'woocommerce' ),
'type' => [ 'integer', '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' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'maximum' => [
'description' => __( 'The maximum quantity that can be added to the cart.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'multiple_of' => [
'description' => __( 'The amount that quantities increment by. Quantity must be an multiple of this value.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'default' => 1,
],
],
],
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 ) {
return [
'id' => $product->get_id(),
'name' => $this->prepare_html_response( $product->get_title() ),
'parent' => $product->get_parent_id(),
'type' => $product->get_type(),
'variation' => $this->prepare_html_response( $product->is_type( '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' ),
'attributes' => $this->get_attributes( $product ),
'variations' => $this->get_variations( $product ),
'has_options' => $product->has_options(),
'is_purchasable' => $product->is_purchasable(),
'is_in_stock' => $product->is_in_stock(),
'is_on_backorder' => 'onbackorder' === $product->get_stock_status(),
'low_stock_remaining' => $this->get_low_stock_remaining( $product ),
'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() ),
],
( 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 integer|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 integer|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( '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 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( '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( '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( 'grouped' ) ) {
$children = array_filter( array_map( 'wc_get_product', $product->get_children() ), 'wc_products_array_filter_visible_grouped' );
$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;
}
}

View File

@@ -0,0 +1,72 @@
<?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 stdClass
*/
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 = '';
}
return (object) $this->prepare_html_response(
[
'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(),
]
);
}
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
);
}
}

View File

@@ -0,0 +1,84 @@
<?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,
];
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
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;
/**
* 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 = JsonWebToken::get_parts( $this->token )->payload;
$this->_customer_id = $payload->user_id;
$this->session_expiration = $payload->exp;
$this->_data = (array) $this->get_session( $this->_customer_id, array() );
}
/**
* Returns the session.
*
* @param string $customer_id Customer ID.
* @param mixed $default Default session value.
*
* @return string|array|bool
*/
public function get_session( $customer_id, $default = 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 false;
}
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT session_value FROM $this->table WHERE session_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$customer_id
)
);
if ( is_null( $value ) ) {
$value = $default;
}
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 $this->table (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d) ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->_customer_id,
maybe_serialize( $this->_data ),
$this->session_expiration
)
);
$this->_dirty = false;
}
}
}

View File

@@ -0,0 +1,102 @@
<?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() {
self::container()->get( Authentication::class )->init();
self::container()->get( Legacy::class )->init();
self::container()->get( RoutesController::class )->register_all_routes();
}
);
}
/**
* 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;
}
}

View File

@@ -0,0 +1,36 @@
<?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;
}
}

View File

@@ -0,0 +1,70 @@
<?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;
}
}

View File

@@ -0,0 +1,196 @@
<?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 ) {
/**
* 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;
}
/**
* 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 );
}
/**
* 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
)
);
}
}

View File

@@ -0,0 +1,34 @@
<?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 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()
);
// 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
)
);
}
}

View File

@@ -0,0 +1,72 @@
<?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' ) ) {
wc_clear_notices();
return $errors;
}
$error_notices = wc_get_notices( 'error' );
// Prevent notices from being output later on.
wc_clear_notices();
foreach ( $error_notices as $error_notice ) {
$errors->add( $error_code, wp_strip_all_tags( $error_notice['notice'] ) );
}
return $errors;
}
}

View File

@@ -0,0 +1,546 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use \Exception;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* OrderController class.
* Helper class which creates and syncs orders with the cart.
*/
class OrderController {
/**
* 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.
*/
public function update_order_from_cart( \WC_Order $order ) {
/**
* 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.
wc()->cart->calculate_shipping();
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->update_meta_data( 'is_vat_exempt', wc()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' );
$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(
[
'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(),
]
);
$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 );
}
/**
* 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.
*/
protected function validate_coupons( \WC_Order $order ) {
$coupon_codes = $order->get_coupon_codes();
$coupons = array_filter( array_map( [ $this, 'get_coupon' ], $coupon_codes ) );
$validators = [ 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' ];
$coupon_errors = [];
foreach ( $coupons as $coupon ) {
try {
array_walk(
$validators,
function( $validator, $index, $params ) {
call_user_func_array( [ $this, $validator ], $params );
},
[ $coupon, $order ]
);
} catch ( Exception $error ) {
$coupon_errors[ $coupon->get_code() ] = $error->getMessage();
}
}
if ( $coupon_errors ) {
// Remove all coupons that were not valid.
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.
throw new RouteException(
'woocommerce_rest_cart_coupon_errors',
sprintf(
/* translators: %s Coupon codes. */
__( 'Invalid coupons were removed from the cart: "%s"', 'woocommerce' ),
implode( '", "', array_keys( $coupon_errors ) )
),
409,
[
'removed_coupons' => $coupon_errors,
]
);
}
}
/**
* 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.
*/
protected function validate_addresses( \WC_Order $order ) {
$errors = new \WP_Error();
$needs_shipping = wc()->cart->needs_shipping();
$billing_address = $order->get_address( 'billing' );
$shipping_address = $order->get_address( 'shipping' );
if ( $needs_shipping && ! $this->validate_allowed_country( $shipping_address['country'], (array) wc()->countries->get_shipping_countries() ) ) {
throw new RouteException(
'woocommerce_rest_invalid_address_country',
sprintf(
/* translators: %s country code. */
__( 'Sorry, we do not ship orders to the provided country (%s)', 'woocommerce' ),
$shipping_address['country']
),
400,
[
'allowed_countries' => array_keys( wc()->countries->get_shipping_countries() ),
]
);
}
if ( ! $this->validate_allowed_country( $billing_address['country'], (array) wc()->countries->get_allowed_countries() ) ) {
throw new RouteException(
'woocommerce_rest_invalid_address_country',
sprintf(
/* translators: %s country code. */
__( 'Sorry, we do not allow orders from the provided country (%s)', 'woocommerce' ),
$billing_address['country']
),
400,
[
'allowed_countries' => array_keys( wc()->countries->get_allowed_countries() ),
]
);
}
if ( $needs_shipping ) {
$this->validate_address_fields( $shipping_address, 'shipping', $errors );
}
$this->validate_address_fields( $billing_address, 'billing', $errors );
if ( ! $errors->has_errors() ) {
return;
}
$errors_by_code = [];
$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. */
__( 'There was a problem with the provided %s:', 'woocommerce' ) . ' ' . implode( ', ', $error_messages ),
'shipping' === $code ? __( 'shipping address', 'woocommerce' ) : __( 'billing address', 'woocommerce' )
),
400,
[
'errors' => $errors_by_code,
]
);
}
}
/**
* 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 array $address Address array.
* @param string $address_type billing or shipping address, used in error messages.
* @param \WP_Error $errors Error object.
*/
protected function validate_address_fields( $address, $address_type, \WP_Error $errors ) {
$all_locales = wc()->countries->get_country_locale();
$current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : [];
/**
* We are not using wc()->counties->get_default_address_fields() here because that is filtered. Instead, this array
* is based on assets/js/base/components/cart-checkout/address-form/default-address-fields.js
*/
$address_fields = [
'first_name' => [
'label' => __( 'First name', 'woocommerce' ),
'required' => true,
],
'last_name' => [
'label' => __( 'Last name', 'woocommerce' ),
'required' => true,
],
'company' => [
'label' => __( 'Company', 'woocommerce' ),
'required' => false,
],
'address_1' => [
'label' => __( 'Address', 'woocommerce' ),
'required' => true,
],
'address_2' => [
'label' => __( 'Apartment, suite, etc.', 'woocommerce' ),
'required' => false,
],
'country' => [
'label' => __( 'Country/Region', 'woocommerce' ),
'required' => true,
],
'city' => [
'label' => __( 'City', 'woocommerce' ),
'required' => true,
],
'state' => [
'label' => __( 'State/County', 'woocommerce' ),
'required' => true,
],
'postcode' => [
'label' => __( 'Postal code', 'woocommerce' ),
'required' => true,
],
];
if ( $current_locale ) {
foreach ( $current_locale as $key => $field ) {
if ( isset( $address_fields[ $key ] ) ) {
$address_fields[ $key ]['label'] = isset( $field['label'] ) ? $field['label'] : $address_fields[ $key ]['label'];
$address_fields[ $key ]['required'] = isset( $field['required'] ) ? $field['required'] : $address_fields[ $key ]['required'];
}
}
}
foreach ( $address_fields as $address_field_key => $address_field ) {
if ( empty( $address[ $address_field_key ] ) && $address_field['required'] ) {
/* translators: %s Field label. */
$errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key );
}
}
}
/**
* 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 ) && $order->get_billing_email() && ! wc()->cart->is_coupon_emails_allowed( [ $order->get_billing_email() ], $restrictions ) ) {
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 ( $coupon_usage_limit > 0 ) {
$data_store = $coupon->get_data_store();
$usage_count = $order->get_customer_id() ? $data_store->get_usage_by_user_id( $coupon, $order->get_customer_id() ) : $data_store->get_usage_by_email( $coupon, $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 ) );
}
}
}
/**
* 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 || ! is_array( $chosen_shipping_methods ) ) {
return;
}
foreach ( $chosen_shipping_methods as $chosen_shipping_method ) {
if ( false === $chosen_shipping_method ) {
throw new RouteException(
'woocommerce_rest_invalid_shipping_option',
__( 'Sorry, this order requires a shipping option.', 'woocommerce' ),
400,
[]
);
}
}
}
/**
* 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_data( '_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_data( '_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_data( '_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_data( '_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(
[
'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(),
]
);
}
}

View File

@@ -0,0 +1,71 @@
<?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 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 ) );
}
}

View File

@@ -0,0 +1,500 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use WC_Tax;
/**
* Product Query class.
*
* Helper class to handle product queries for the API.
*/
class ProductQuery {
/**
* 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 = [
'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.
'fields' => 'ids',
'ignore_sticky_posts' => true,
'post_status' => 'publish',
'date_query' => [],
'post_type' => 'product',
];
// If searching for a specific SKU, allow any post type.
if ( ! empty( $request['sku'] ) ) {
$args['post_type'] = [ 'product', 'product_variation' ];
}
// Taxonomy query to filter products by type, category, tag, shipping class, and attribute.
$tax_query = [];
// Filter product type by slug.
if ( ! empty( $request['type'] ) ) {
if ( 'variation' === $request['type'] ) {
$args['post_type'] = 'product_variation';
} else {
$args['post_type'] = 'product';
$tax_query[] = [
'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 = [
'sku',
'min_price',
'max_price',
'stock_status',
];
foreach ( $custom_keys as $key ) {
if ( ! empty( $request[ $key ] ) ) {
$args[ $key ] = $request[ $key ];
}
}
$operator_mapping = [
'in' => 'IN',
'not_in' => 'NOT IN',
'and' => 'AND',
];
// Gets all registered product taxonomies and prefixes them with `tax_`.
// This is neeeded to avoid situations where a users 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 = [
'product_cat' => 'category',
'product_tag' => 'tag',
];
$taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies );
// Set tax_query for each passed arg.
foreach ( $taxonomies as $taxonomy => $key ) {
if ( ! empty( $request[ $key ] ) ) {
$operator = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN';
$tax_query[] = [
'taxonomy' => $taxonomy,
'field' => 'term_id',
'terms' => $request[ $key ],
'operator' => $operator,
];
}
}
// Filter by attributes.
if ( ! empty( $request['attributes'] ) ) {
$att_queries = [];
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[] = [
'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[] = [
'relation' => $relation,
$att_queries,
];
} else {
$tax_query = array_merge( $tax_query, $att_queries );
}
}
// Build tax_query if taxonomies are set.
if ( ! empty( $tax_query ) ) {
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
}
}
// Filter featured.
if ( is_bool( $request['featured'] ) ) {
$args['tax_query'][] = [
'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 ) ? [ 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 = 'search' === $catalog_visibility ? '' : 'exclude-from-catalog';
$exclude_from_search = 'catalog' === $catalog_visibility ? '' : 'exclude-from-search';
$args['tax_query'][] = [
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => [ $exclude_from_catalog, $exclude_from_search ],
'operator' => 'hidden' === $catalog_visibility ? 'AND' : 'NOT IN',
'rating_filter' => true,
];
}
if ( $rating ) {
$rating_terms = [];
foreach ( $rating as $value ) {
$rating_terms[] = 'rated-' . $value;
}
$args['tax_query'][] = [
'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;
}
/**
* 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', [ $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', [ $this, 'add_query_clauses' ], 10 );
return [
'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 );
return [
'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;
return strtotime( $wpdb->get_var( "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' );" ) );
}
/**
* Add in conditional search filters for products.
*
* @param array $args Query args.
* @param \WC_Query $wp_query WC_Query object.
* @return array
*/
public function add_query_clauses( $args, $wp_query ) {
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( '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, 'min_price', '>=' );
} else {
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_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, 'max_price', '<=' );
} else {
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_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 = [];
// 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;
}
}

View File

@@ -0,0 +1,316 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
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['outofstock'] );
}
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' ) );
}
/**
* Gets product by metas.
*
* @since TBD
* @param array $metas Array of metas to query.
* @return array $results
*/
public function get_product_by_metas( $metas = array() ) {
global $wpdb;
if ( empty( $metas ) ) {
return array();
}
$where = array();
$results = array();
$params = array();
foreach ( $metas as $column => $value ) {
if ( 'min_price' === $column ) {
$where[] = "{$column} >= %f";
$params[] = (float) $value;
continue;
}
if ( 'max_price' === $column ) {
$where[] = "{$column} <= %f";
$params[] = (float) $value;
continue;
}
$where[] = "{$column} = %s";
$params[] = $value;
}
if ( ! empty( $where ) ) {
$where_clause = implode( ' AND ', $where );
// Use a parameterized query.
$results = $wpdb->get_col(
$wpdb->prepare( "SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE {$where_clause}", // phpcs:ignore
$params
)
);
}
return $results;
}
/**
* Gets product by filtered terms.
*
* @since TBD
* @param string $taxonomy Taxonomy name.
* @param array $term_ids Term IDs.
* @param string $query_type or | and.
* @return array Product IDs.
*/
public function get_product_by_filtered_terms( $taxonomy = '', $term_ids = array(), $query_type = 'or' ) {
global $wpdb;
$term_count = count( $term_ids );
$results = array();
$term_ids = implode( ',', array_map( 'intval', $term_ids ) );
if ( 'or' === $query_type ) {
// phpcs:disable
$results = $wpdb->get_col(
$wpdb->prepare(
"
SELECT DISTINCT `product_or_parent_id`
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE `taxonomy` = %s
AND `term_id` IN ({$term_ids})
",
$taxonomy
)
);
// phpcs:enable
}
if ( 'and' === $query_type ) {
// phpcs:disable
$results = $wpdb->get_col(
$wpdb->prepare(
"
SELECT DISTINCT `product_or_parent_id`
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE `taxonomy` = %s
AND `term_id` IN ({$term_ids})
GROUP BY `product_or_parent_id`
HAVING COUNT( DISTINCT `term_id` ) >= %d
",
$taxonomy,
$term_count
)
);
// phpcs:enable
}
return $results;
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
/**
* 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,
];
}
$multiple_of = (int) $this->filter_value( 1, 'multiple_of', $cart_item );
$minimum = (int) $this->filter_value( 1, 'minimum', $cart_item );
$maximum = (int) $this->filter_value( $this->get_product_quantity_limit( $product ), 'maximum', $cart_item );
$editable = (bool) $this->filter_value( ! $product->is_sold_individually(), 'editable', $cart_item );
return [
'minimum' => $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ),
'maximum' => $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ),
'multiple_of' => $multiple_of,
'editable' => $editable,
];
}
/**
* Get limits for product add to cart forms.
*
* @param \WC_Product $product Product instance.
* @return array
*/
public function get_add_to_cart_limits( \WC_Product $product ) {
$multiple_of = $this->filter_value( 1, 'multiple_of', $product );
$minimum = $this->filter_value( 1, 'minimum', $product );
$maximum = $this->filter_value( $this->get_product_quantity_limit( $product ), 'maximum', $product );
return [
'minimum' => $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ),
'maximum' => $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ),
'multiple_of' => $multiple_of,
];
}
/**
* Return a number using the closest multiple of another number. Used to enforce step/multiple values.
*
* @param int $number Number to round.
* @param int $multiple_of The multiple.
* @param string $rounding_function ceil, floor, or round.
* @return int
*/
public function limit_to_multiple( int $number, int $multiple_of, string $rounding_function = 'round' ) {
if ( $multiple_of <= 1 ) {
return $number;
}
$rounding_function = in_array( $rounding_function, [ 'ceil', 'floor', 'round' ], true ) ? $rounding_function : 'round';
return $rounding_function( $number / $multiple_of ) * $multiple_of;
}
/**
* Check that a given quantity is valid according to any limits in place.
*
* @param integer $quantity Quantity to validate.
* @param \WC_Product|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 );
if ( ! $limits['editable'] ) {
return new \WP_Error(
'readonly_quantity',
__( 'This item is already in the cart and its quantity cannot be edited', 'woocommerce' )
);
}
if ( $quantity < $limits['minimum'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The minimum quantity that can be added to the cart is %s', 'woocommerce' ),
$limits['minimum']
)
);
}
if ( $quantity > $limits['maximum'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The maximum quantity that can be added to the cart is %s', 'woocommerce' ),
$limits['maximum']
)
);
}
if ( $quantity % $limits['multiple_of'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The quantity added to the cart must be a multiple of %s', 'woocommerce' ),
$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 \WC_Product $product Product instance.
* @return int
*/
protected function get_product_quantity_limit( \WC_Product $product ) {
$limits = [ 9999 ];
if ( $product->is_sold_individually() ) {
$limits[] = 1;
} elseif ( ! $product->backorders_allowed() ) {
$limits[] = $this->get_remaining_stock( $product );
}
/**
* Filters the quantity limit for a product being added to the cart via the Store API.
*
* Filters the variation option name for custom option slugs.
*
* @since 6.8.0
*
* @param integer $quantity_limit Quantity limit which defaults to 9999 unless sold individually.
* @param \WC_Product $product Product instance.
* @return integer
*/
return apply_filters( 'woocommerce_store_api_product_quantity_limit', max( min( array_filter( $limits ) ), 1 ), $product );
}
/**
* Returns the remaining stock for a product if it has stock.
*
* This also factors in draft orders.
*
* @param \WC_Product $product Product instance.
* @return integer|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 $product->get_stock_quantity() - $reserved_stock;
}
/**
* Get a quantity for a product or cart item by running it through a filter hook.
*
* @param int|null $value Value to filter.
* @param string $value_type Type of value. Used for filter suffix.
* @param \WC_Product|array $cart_item_or_product Either a cart item or a product instance.
* @return mixed
*/
protected function filter_value( $value, string $value_type, $cart_item_or_product ) {
$is_product = $cart_item_or_product instanceof \WC_Product;
$product = $is_product ? $cart_item_or_product : $cart_item_or_product['data'];
$cart_item = $is_product ? null : $cart_item_or_product;
/**
* Filters the quantity minimum for a cart item in Store API. This allows extensions to control the minimum qty
* of items already within the cart.
*
* 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
*/
return apply_filters( "woocommerce_store_api_product_quantity_{$value_type}", $value, $product, $cart_item );
}
}

View File

@@ -0,0 +1,247 @@
<?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 ) {
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( $action_id ) {
global $wpdb;
$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( $action_id ) {
$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( $action_id ) {
$current_limit = self::get_rate_limit( $action_id );
// Before the next run is allowed, retry forbidden.
if ( time() <= $current_limit->reset && 0 === $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( $action_id ) {
global $wpdb;
$options = self::get_options();
$rate_limit_expiry = time() + $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,
$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 bool|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 ) {
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() {
$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 ];
}
}

View File

@@ -0,0 +1,61 @@
<?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;
}
}

View File

@@ -0,0 +1,77 @@
<?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 );

View File

@@ -0,0 +1,87 @@
<?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 );
}
}