initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,47 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi;
use \Exception;
use Automattic\WooCommerce\Blocks\StoreApi\Formatters\DefaultFormatter;
/**
* Formatters class.
*
* Allows formatter classes to be registered. Formatters are exposed to extensions via the ExtendRestApi 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,53 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Formatters;
/**
* Currency Formatter.
*
* Formats an array of monetary values by inserting currency data.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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,20 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Formatters;
/**
* Default Formatter.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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,18 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Formatters;
/**
* FormatterInterface.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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\Blocks\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,36 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Formatters;
/**
* Money Formatter.
*
* Formats monetary values using store settings.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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,186 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartSchema;
/**
* Abstract Cart Route
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
abstract class AbstractCartRoute extends AbstractRoute {
/**
* Schema class for this route's response.
*
* @var AbstractSchema|CartSchema
*/
protected $schema;
/**
* Schema class for the cart.
*
* @var CartSchema
*/
protected $cart_schema;
/**
* Cart controller class instance.
*
* @var CartController
*/
protected $cart_controller;
/**
* Constructor accepts two types of schema; one for the item being returned, and one for the cart as a whole. These
* may be the same depending on the route.
*
* @param CartSchema $cart_schema Schema class for the cart.
* @param AbstractSchema $item_schema Schema class for this route's items if it differs from the cart schema.
* @param CartController $cart_controller Cart controller class.
*/
public function __construct( CartSchema $cart_schema, AbstractSchema $item_schema = null, CartController $cart_controller ) {
$this->schema = is_null( $item_schema ) ? $cart_schema : $item_schema;
$this->cart_schema = $cart_schema;
$this->cart_controller = $cart_controller;
}
/**
* Get the route response based on the type of request.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_Error|\WP_REST_Response
*/
public function get_response( \WP_REST_Request $request ) {
$this->cart_controller->load_cart();
$this->calculate_totals();
if ( $this->requires_nonce( $request ) ) {
$nonce_check = $this->check_nonce( $request );
if ( is_wp_error( $nonce_check ) ) {
return $this->add_nonce_headers( $this->error_to_response( $nonce_check ) );
}
}
try {
$response = parent::get_response( $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( 'unknown_server_error', $error->getMessage(), 500 );
}
if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response );
}
return $this->add_nonce_headers( $response );
}
/**
* Add nonce headers to a response object.
*
* @param \WP_REST_Response $response The response object.
* @return \WP_REST_Response
*/
protected function add_nonce_headers( \WP_REST_Response $response ) {
$response->header( 'X-WC-Store-API-Nonce', wp_create_nonce( 'wc_store_api' ) );
$response->header( 'X-WC-Store-API-Nonce-Timestamp', time() );
$response->header( 'X-WC-Store-API-User', get_current_user_id() );
return $response;
}
/**
* 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 'GET' !== $request->get_method();
}
/**
* Ensures the cart totals are calculated before an API response is generated.
*/
protected function calculate_totals() {
wc()->cart->get_cart();
wc()->cart->calculate_fees();
wc()->cart->calculate_shipping();
wc()->cart->calculate_totals();
}
/**
* If there is a draft order, releases stock.
*
* @return void
*/
protected function maybe_release_stock() {
$draft_order = wc()->session->get( 'store_api_draft_order', 0 );
if ( ! $draft_order ) {
return;
}
wc_release_stock_for_order( $draft_order );
}
/**
* 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 = $request->get_header( 'X-WC-Store-API-Nonce' );
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 X-WC-Store-API-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', __( 'X-WC-Store-API-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,284 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\InvalidStockLevelsInCartException;
use WP_Error;
/**
* AbstractRoute class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
abstract class AbstractRoute implements RouteInterface {
/**
* Schema class instance.
*
* @var AbstractSchema
*/
protected $schema;
/**
* Constructor.
*
* @param AbstractSchema $schema Schema class for this route.
*/
public function __construct( AbstractSchema $schema ) {
$this->schema = $schema;
}
/**
* Get the namespace for this route.
*
* @return string
*/
public function get_namespace() {
return 'wc/store';
}
/**
* 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 {
switch ( $request->get_method() ) {
case 'POST':
$response = $this->get_route_post_response( $request );
break;
case 'PUT':
case 'PATCH':
$response = $this->get_route_update_response( $request );
break;
case 'DELETE':
$response = $this->get_route_delete_response( $request );
break;
default:
$response = $this->get_route_response( $request );
break;
}
} catch ( RouteException $error ) {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( InvalidStockLevelsInCartException $error ) {
$response = $this->get_route_error_response_from_object( $error->getError(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'unknown_server_error', $error->getMessage(), 500 );
}
if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response );
}
return $response;
}
/**
* 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,152 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\Pagination;
use WP_Term_Query;
/**
* AbstractTermsRoute class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
abstract class AbstractTermsRoute extends AbstractRoute {
/**
* 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,117 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
/**
* Batch Route class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class Batch extends AbstractRoute implements RouteInterface {
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/batch';
}
/**
* Constructor.
*/
public function __construct() {}
/**
* 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( 'unknown_server_error', $error->getMessage(), 500 );
}
if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response );
}
$response->header( 'X-WC-Store-API-Nonce', wp_create_nonce( 'wc_store_api' ) );
$response->header( 'X-WC-Store-API-Nonce-Timestamp', time() );
$response->header( 'X-WC-Store-API-User', get_current_user_id() );
return $response;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* Cart class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class Cart extends AbstractCartRoute {
/**
* 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.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
return rest_ensure_response( $this->schema->get_item_response( $this->cart_controller->get_cart_instance() ) );
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartAddItem class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartAddItem extends AbstractCartRoute {
/**
* 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' ],
'arg_options' => [
'sanitize_callback' => 'absint',
],
],
'quantity' => [
'description' => __( 'Quantity of this item in 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();
$result = $this->cart_controller->add_to_cart(
[
'id' => $request['id'],
'quantity' => $request['quantity'],
'variation' => $request['variation'],
]
);
$response = rest_ensure_response( $this->schema->get_item_response( $cart ) );
$response->set_status( 201 );
return $response;
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartApplyCoupon class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartApplyCoupon extends AbstractCartRoute {
/**
* 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,133 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartCoupons class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartCoupons extends AbstractCartRoute {
/**
* 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,84 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartCouponsByCode class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartCouponsByCode extends AbstractCartRoute {
/**
* 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,60 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartExtensions class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartExtensions extends AbstractCartRoute {
/**
* 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,128 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartItems class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartItems extends AbstractCartRoute {
/**
* 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,132 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartItemsByKey class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartItemsByKey extends AbstractCartRoute {
/**
* 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' ), 404 );
}
$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' ), 404 );
}
$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,72 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartRemoveCoupon class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartRemoveCoupon extends AbstractCartRoute {
/**
* 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,62 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartRemoveItem class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartRemoveItem extends AbstractCartRoute {
/**
* 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 ) );
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartSelectShippingRate class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartSelectShippingRate extends AbstractCartRoute {
/**
* 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.', 'woocommerce' ),
'type' => [ 'integer', 'string' ],
'required' => true,
),
'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['package_id'] ) ) {
throw new RouteException( 'woocommerce_rest_cart_missing_package_id', __( 'Invalid Package ID.', 'woocommerce' ), 400 );
}
$cart = $this->cart_controller->get_cart_instance();
$package_id = wc_clean( wp_unslash( $request['package_id'] ) );
$rate_id = wc_clean( wp_unslash( $request['rate_id'] ) );
try {
$this->cart_controller->select_shipping_rate( $package_id, $rate_id );
} catch ( \WC_Rest_Exception $e ) {
throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() );
}
$cart->calculate_shipping();
$cart->calculate_totals();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
}

View File

@ -0,0 +1,186 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\BillingAddressSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ShippingAddressSchema;
/**
* CartUpdateCustomer class.
*
* Updates the customer billing and shipping address and returns an updated cart--things such as taxes may be recalculated.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartUpdateCustomer extends AbstractCartRoute {
/**
* Get the namespace for this route.
*
* @return string
*/
public function get_namespace() {
return 'wc/store';
}
/**
* 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' => [ $this->schema->billing_address_schema, 'sanitize_callback' ],
'validate_callback' => [ $this->schema->billing_address_schema, 'validate_callback' ],
],
'shipping_address' => [
'description' => __( 'Shipping address.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->schema->shipping_address_schema->get_properties(),
'sanitize_callback' => [ $this->schema->shipping_address_schema, 'sanitize_callback' ],
'validate_callback' => [ $this->schema->shipping_address_schema, 'validate_callback' ],
],
],
],
'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();
$billing = isset( $request['billing_address'] ) ? $request['billing_address'] : [];
$shipping = isset( $request['shipping_address'] ) ? $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 = isset( $request['billing_address'] ) ? $request['billing_address'] : [
'first_name' => wc()->customer->get_billing_first_name(),
'last_name' => wc()->customer->get_billing_last_name(),
'company' => wc()->customer->get_billing_company(),
'address_1' => wc()->customer->get_billing_address_1(),
'address_2' => wc()->customer->get_billing_address_2(),
'city' => wc()->customer->get_billing_city(),
'state' => wc()->customer->get_billing_state(),
'postcode' => wc()->customer->get_billing_postcode(),
'country' => wc()->customer->get_billing_country(),
'phone' => wc()->customer->get_billing_phone(),
];
}
wc()->customer->set_props(
array(
'billing_first_name' => isset( $billing['first_name'] ) ? $billing['first_name'] : null,
'billing_last_name' => isset( $billing['last_name'] ) ? $billing['last_name'] : null,
'billing_company' => isset( $billing['company'] ) ? $billing['company'] : null,
'billing_address_1' => isset( $billing['address_1'] ) ? $billing['address_1'] : null,
'billing_address_2' => isset( $billing['address_2'] ) ? $billing['address_2'] : null,
'billing_city' => isset( $billing['city'] ) ? $billing['city'] : null,
'billing_state' => isset( $billing['state'] ) ? $billing['state'] : null,
'billing_postcode' => isset( $billing['postcode'] ) ? $billing['postcode'] : null,
'billing_country' => isset( $billing['country'] ) ? $billing['country'] : null,
'billing_phone' => isset( $billing['phone'] ) ? $billing['phone'] : null,
'billing_email' => isset( $request['billing_address'], $request['billing_address']['email'] ) ? $request['billing_address']['email'] : null,
'shipping_first_name' => isset( $shipping['first_name'] ) ? $shipping['first_name'] : null,
'shipping_last_name' => isset( $shipping['last_name'] ) ? $shipping['last_name'] : null,
'shipping_company' => isset( $shipping['company'] ) ? $shipping['company'] : null,
'shipping_address_1' => isset( $shipping['address_1'] ) ? $shipping['address_1'] : null,
'shipping_address_2' => isset( $shipping['address_2'] ) ? $shipping['address_2'] : null,
'shipping_city' => isset( $shipping['city'] ) ? $shipping['city'] : null,
'shipping_state' => isset( $shipping['state'] ) ? $shipping['state'] : null,
'shipping_postcode' => isset( $shipping['postcode'] ) ? $shipping['postcode'] : null,
'shipping_country' => isset( $shipping['country'] ) ? $shipping['country'] : null,
)
);
$shipping_phone_value = isset( $shipping['phone'] ) ? $shipping['phone'] : null;
// @todo Remove custom shipping_phone handling (requires WC 5.6+)
if ( is_callable( [ wc()->customer, 'set_shipping_phone' ] ) ) {
wc()->customer->set_shipping_phone( $shipping_phone_value );
} else {
wc()->customer->update_meta_data( 'shipping_phone', $shipping_phone_value );
}
wc()->customer->save();
$this->calculate_totals();
$this->maybe_update_order();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
/**
* If there is a draft order, update customer data there also.
*
* @return void
*/
protected function maybe_update_order() {
$draft_order_id = wc()->session->get( 'store_api_draft_order', 0 );
$draft_order = $draft_order_id ? wc_get_order( $draft_order_id ) : false;
if ( ! $draft_order ) {
return;
}
$draft_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_value = is_callable( [ wc()->customer, 'get_shipping_phone' ] ) ? wc()->customer->get_shipping_phone() : wc()->customer->get_meta( 'shipping_phone', true );
if ( is_callable( [ $draft_order, 'set_shipping_phone' ] ) ) {
$draft_order->set_shipping_phone( $shipping_phone_value );
} else {
$draft_order->update_meta_data( '_shipping_phone', $shipping_phone_value );
}
$draft_order->save();
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* CartUpdateItem class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class CartUpdateItem extends AbstractCartRoute {
/**
* 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.
*
* @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();
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,635 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\InvalidStockLevelsInCartException;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\OrderController;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
use Automattic\WooCommerce\Blocks\Payments\PaymentResult;
use Automattic\WooCommerce\Blocks\Payments\PaymentContext;
/**
* Checkout class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class Checkout extends AbstractCartRoute {
/**
* Holds the current order being processed.
*
* @var \WC_Order
*/
private $order = null;
/**
* Order controller class instance.
*
* @var OrderController
*/
protected $order_controller;
/**
* Constructor accepts two types of schema; one for the item being returned, and one for the cart as a whole. These
* may be the same depending on the route.
*
* @param CartSchema $cart_schema Schema class for the cart.
* @param AbstractSchema $item_schema Schema class for this route's items if it differs from the cart schema.
* @param CartController $cart_controller Cart controller class.
* @param OrderController $order_controller Order controller class.
*/
public function __construct( CartSchema $cart_schema, AbstractSchema $item_schema = null, CartController $cart_controller, OrderController $order_controller ) {
$this->schema = is_null( $item_schema ) ? $cart_schema : $item_schema;
$this->cart_schema = $cart_schema;
$this->cart_controller = $cart_controller;
$this->order_controller = $order_controller;
}
/**
* 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 )
),
],
[
'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 ),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* 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();
return $this->prepare_item_for_response(
(object) [
'order' => $this->order,
'payment_result' => new PaymentResult(),
],
$request
);
}
/**
* Update the current order.
*
* @internal Customer data is updated first so OrderController::update_addresses_from_cart uses up to date data.
*
* @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 ) {
$this->update_customer_from_request( $request );
$this->create_or_update_draft_order();
$this->update_order_from_request( $request );
return $this->prepare_item_for_response(
(object) [
'order' => $this->order,
'payment_result' => new PaymentResult(),
],
$request
);
}
/**
* Update and 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_items();
$this->cart_controller->validate_cart_coupons();
/**
* 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();
$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 );
/**
* WooCommerce Blocks Checkout Order Processed (experimental).
*
* 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.
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3238
* @internal This Hook is experimental and may change or be removed.
*
* @param \WC_Order $order Order object.
*/
do_action( '__experimental_woocommerce_blocks_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/cart' ) ] );
}
$error->add_data( $data );
return $error;
}
/**
* Gets draft order data from the customer session.
*
* @return array
*/
private function get_draft_order_id() {
return wc()->session->get( 'store_api_draft_order', 0 );
}
/**
* Updates draft order data in the customer session.
*
* @param integer $order_id Draft order ID.
*/
private function set_draft_order_id( $order_id ) {
wc()->session->set( 'store_api_draft_order', $order_id );
}
/**
* 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.
*/
private 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;
}
/**
* Create or update a draft order based on the cart.
*
* @throws RouteException On error.
*/
private function create_or_update_draft_order() {
$this->order = $this->get_draft_order_id() ? wc_get_order( $this->get_draft_order_id() ) : null;
if ( ! $this->is_valid_draft_order( $this->order ) ) {
$this->order = $this->order_controller->create_order_from_cart();
} else {
$this->order_controller->update_order_from_cart( $this->order );
}
/**
* WooCommerce Blocks Checkout Update Order Meta (experimental).
*
* This hook gives extensions the chance to add or update meta data on the $order.
*
* 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.
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3686
* @internal This Hook is experimental and may change or be removed.
*
* @param \WC_Order $order Order object.
*/
do_action( '__experimental_woocommerce_blocks_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 10 mins, if available.
try {
$reserve_stock = new ReserveStock();
$reserve_stock->reserve_stock_for_order( $this->order, 10 );
} catch ( ReserveStockException $e ) {
$error_data = $e->getErrorData();
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;
if ( isset( $request['billing_address'] ) ) {
foreach ( $request['billing_address'] as $key => $value ) {
if ( is_callable( [ $customer, "set_billing_$key" ] ) ) {
$customer->{"set_billing_$key"}( $value );
}
}
}
if ( isset( $request['shipping_address'] ) ) {
foreach ( $request['shipping_address'] 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 );
}
}
}
$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( $request ) );
/**
* WooCommerce Blocks Checkout Update Order From Request (experimental).
*
* This hook gives extensions the chance to update orders based on the data in the request. This can be used in
* conjunction with the ExtendRestAPI class to post custom data and then process it.
*
* @internal This Hook is experimental and may change or be removed.
*
* @param \WC_Order $order Order object.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( '__experimental_woocommerce_blocks_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(), 400 );
}
}
/**
* 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_id = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) );
if ( empty( $payment_method_id ) ) {
throw new RouteException(
'woocommerce_rest_checkout_missing_payment_method',
__( 'No payment method provided.', 'woocommerce' ),
400
);
}
return $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
*/
private function get_request_payment_method( \WP_REST_Request $request ) {
$payment_method_id = $this->get_request_payment_method_id( $request );
$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
if ( ! isset( $available_gateways[ $payment_method_id ] ) ) {
throw new RouteException(
'woocommerce_rest_checkout_payment_method_disabled',
__( 'This payment gateway is not available.', 'woocommerce' ),
400
);
}
return $available_gateways[ $payment_method_id ];
}
/**
* Gets and formats payment request data.
*
* @param \WP_REST_Request $request Request object.
* @return array
*/
private function get_request_payment_data( \WP_REST_Request $request ) {
static $payment_data = [];
if ( ! empty( $payment_data ) ) {
return $payment_data;
}
if ( ! empty( $request['payment_data'] ) ) {
foreach ( $request['payment_data'] as $data ) {
$payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] );
}
}
return $payment_data;
}
/**
* 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 {
$create_account = Package::container()->get( CreateAccount::class );
$create_account->from_order_request( $request );
$this->order->set_customer_id( get_current_user_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 );
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* ProductAttributeTerms class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductAttributeTerms extends AbstractTermsRoute {
/**
* 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,55 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* ProductAttributes class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductAttributes extends AbstractRoute {
/**
* 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.
*
* @throws RouteException On error.
* @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,67 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* ProductAttributesById class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductAttributesById extends AbstractRoute {
/**
* 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,46 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* ProductCategories class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductCategories extends AbstractTermsRoute {
/**
* 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.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
return $this->get_terms_response( 'product_cat', $request );
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* ProductCategoriesById class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductCategoriesById extends AbstractRoute {
/**
* 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,204 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\ProductQueryFilters;
/**
* ProductCollectionData route.
* Get aggregate data from a collection of products.
*
* Supports the same parameters as /products, but returns a different response.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductCollectionData extends AbstractRoute {
/**
* 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.
*
* @throws RouteException On error.
* @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 ) )->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' => __( 'Query type 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,220 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use WP_Comment_Query;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\Pagination;
/**
* ProductReviews class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductReviews extends AbstractRoute {
/**
* 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.
*
* @throws RouteException On error.
* @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,46 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* ProductTags class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductTags extends AbstractTermsRoute {
/**
* 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.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
return $this->get_terms_response( 'product_tag', $request );
}
}

View File

@ -0,0 +1,389 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\Pagination;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\ProductQuery;
/**
* Products class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class Products extends AbstractRoute {
/**
* 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.
*
* @throws RouteException On error.
* @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',
);
$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,64 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* ProductsById class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ProductsById extends AbstractRoute {
/**
* 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,55 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* ReserveStockRouteExceptionException class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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,30 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
/**
* RouteInterface.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
interface RouteInterface {
/**
* Get the namespace for this route.
*
* @return string
*/
public function get_namespace();
/**
* 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,108 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi;
use Routes\AbstractRoute;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\OrderController;
/**
* RoutesController class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class RoutesController {
/**
* Stores schemas.
*
* @var SchemaController
*/
protected $schemas;
/**
* Stores routes.
*
* @var AbstractRoute[]
*/
protected $routes = [];
/**
* Constructor.
*
* @param SchemaController $schemas Schema controller class passed to each route.
*/
public function __construct( SchemaController $schemas ) {
$this->schemas = $schemas;
$this->initialize();
}
/**
* Get a route class instance.
*
* @throws Exception If the schema does not exist.
*
* @param string $name Name of schema.
* @return AbstractRoute
*/
public function get( $name ) {
if ( ! isset( $this->routes[ $name ] ) ) {
throw new Exception( $name . ' route does not exist' );
}
return $this->routes[ $name ];
}
/**
* Register defined list of routes with WordPress.
*/
public function register_routes() {
foreach ( $this->routes as $route ) {
register_rest_route(
$route->get_namespace(),
$route->get_path(),
$route->get_args()
);
}
}
/**
* Load route class instances.
*/
protected function initialize() {
global $wp_version;
$cart_controller = new CartController();
$order_controller = new OrderController();
$this->routes = [
'cart' => new Routes\Cart( $this->schemas->get( 'cart' ), null, $cart_controller ),
'cart-add-item' => new Routes\CartAddItem( $this->schemas->get( 'cart' ), null, $cart_controller ),
'cart-apply-coupon' => new Routes\CartApplyCoupon( $this->schemas->get( 'cart' ), null, $cart_controller ),
'cart-coupons' => new Routes\CartCoupons( $this->schemas->get( 'cart' ), $this->schemas->get( 'cart-coupon' ), $cart_controller ),
'cart-coupons-by-code' => new Routes\CartCouponsByCode( $this->schemas->get( 'cart' ), $this->schemas->get( 'cart-coupon' ), $cart_controller ),
'cart-extensions' => new Routes\CartExtensions( $this->schemas->get( 'cart' ), $this->schemas->get( 'cart-extensions' ), $cart_controller ),
'cart-items' => new Routes\CartItems( $this->schemas->get( 'cart' ), $this->schemas->get( 'cart-item' ), $cart_controller ),
'cart-items-by-key' => new Routes\CartItemsByKey( $this->schemas->get( 'cart' ), $this->schemas->get( 'cart-item' ), $cart_controller ),
'cart-remove-coupon' => new Routes\CartRemoveCoupon( $this->schemas->get( 'cart' ), null, $cart_controller ),
'cart-remove-item' => new Routes\CartRemoveItem( $this->schemas->get( 'cart' ), null, $cart_controller ),
'cart-select-shipping-rate' => new Routes\CartSelectShippingRate( $this->schemas->get( 'cart' ), null, $cart_controller ),
'cart-update-item' => new Routes\CartUpdateItem( $this->schemas->get( 'cart' ), null, $cart_controller ),
'cart-update-customer' => new Routes\CartUpdateCustomer( $this->schemas->get( 'cart' ), null, $cart_controller ),
'checkout' => new Routes\Checkout( $this->schemas->get( 'cart' ), $this->schemas->get( 'checkout' ), $cart_controller, $order_controller ),
'product-attributes' => new Routes\ProductAttributes( $this->schemas->get( 'product-attribute' ) ),
'product-attributes-by-id' => new Routes\ProductAttributesById( $this->schemas->get( 'product-attribute' ) ),
'product-attribute-terms' => new Routes\ProductAttributeTerms( $this->schemas->get( 'term' ) ),
'product-categories' => new Routes\ProductCategories( $this->schemas->get( 'product-category' ) ),
'product-categories-by-id' => new Routes\ProductCategoriesById( $this->schemas->get( 'product-category' ) ),
'product-collection-data' => new Routes\ProductCollectionData( $this->schemas->get( 'product-collection-data' ) ),
'product-reviews' => new Routes\ProductReviews( $this->schemas->get( 'product-review' ) ),
'product-tags' => new Routes\ProductTags( $this->schemas->get( 'term' ) ),
'products' => new Routes\Products( $this->schemas->get( 'product' ) ),
'products-by-id' => new Routes\ProductsById( $this->schemas->get( 'product' ) ),
];
// Batching requires WP 5.6.
if ( version_compare( $wp_version, '5.6', '>=' ) ) {
$this->routes['batch'] = new Routes\Batch();
}
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi;
use Exception;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\BillingAddressSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ShippingAddressSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartShippingRateSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartItemSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartCouponSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartExtensionsSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartFeeSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ErrorSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CheckoutSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ImageAttachmentSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductAttributeSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductCategorySchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductCollectionDataSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductReviewSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\TermSchema;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
/**
* SchemaController class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class SchemaController {
/**
* Stores schema class instances.
*
* @var AbstractSchema[]
*/
protected $schemas = [];
/**
* Stores Rest Extending instance
*
* @var ExtendRestApi
*/
private $extend;
/**
* Constructor.
*
* @param ExtendRestApi $extend Rest Extending instance.
*/
public function __construct( ExtendRestApi $extend ) {
$this->extend = $extend;
$this->initialize();
}
/**
* Get a schema class instance.
*
* @throws Exception If the schema does not exist.
*
* @param string $name Name of schema.
* @return AbstractSchema
*/
public function get( $name ) {
if ( ! isset( $this->schemas[ $name ] ) ) {
throw new Exception( $name . ' schema does not exist' );
}
return $this->schemas[ $name ];
}
/**
* Load schema class instances.
*/
protected function initialize() {
$this->schemas = [];
$this->schemas[ ErrorSchema::IDENTIFIER ] = new ErrorSchema( $this->extend );
$this->schemas[ ImageAttachmentSchema::IDENTIFIER ] = new ImageAttachmentSchema( $this->extend );
$this->schemas[ TermSchema::IDENTIFIER ] = new TermSchema( $this->extend );
$this->schemas[ BillingAddressSchema::IDENTIFIER ] = new BillingAddressSchema( $this->extend );
$this->schemas[ ShippingAddressSchema::IDENTIFIER ] = new ShippingAddressSchema( $this->extend );
$this->schemas[ CartShippingRateSchema::IDENTIFIER ] = new CartShippingRateSchema( $this->extend );
$this->schemas[ CartCouponSchema::IDENTIFIER ] = new CartCouponSchema( $this->extend );
$this->schemas[ CartFeeSchema::IDENTIFIER ] = new CartFeeSchema( $this->extend );
$this->schemas[ CartItemSchema::IDENTIFIER ] = new CartItemSchema(
$this->extend,
$this->schemas[ ImageAttachmentSchema::IDENTIFIER ]
);
$this->schemas[ CartSchema::IDENTIFIER ] = new CartSchema(
$this->extend,
$this->schemas[ CartItemSchema::IDENTIFIER ],
$this->schemas[ CartCouponSchema::IDENTIFIER ],
$this->schemas[ CartFeeSchema::IDENTIFIER ],
$this->schemas[ CartShippingRateSchema::IDENTIFIER ],
$this->schemas[ ShippingAddressSchema::IDENTIFIER ],
$this->schemas[ BillingAddressSchema::IDENTIFIER ],
$this->schemas[ ErrorSchema::IDENTIFIER ]
);
$this->schemas[ CartExtensionsSchema::IDENTIFIER ] = new CartExtensionsSchema(
$this->extend
);
$this->schemas[ CheckoutSchema::IDENTIFIER ] = new CheckoutSchema(
$this->extend,
$this->schemas[ BillingAddressSchema::IDENTIFIER ],
$this->schemas[ ShippingAddressSchema::IDENTIFIER ]
);
$this->schemas[ ProductSchema::IDENTIFIER ] = new ProductSchema(
$this->extend,
$this->schemas[ ImageAttachmentSchema::IDENTIFIER ]
);
$this->schemas[ ProductAttributeSchema::IDENTIFIER ] = new ProductAttributeSchema( $this->extend );
$this->schemas[ ProductCategorySchema::IDENTIFIER ] = new ProductCategorySchema(
$this->extend,
$this->schemas[ ImageAttachmentSchema::IDENTIFIER ]
);
$this->schemas[ ProductCollectionDataSchema::IDENTIFIER ] = new ProductCollectionDataSchema( $this->extend );
$this->schemas[ ProductReviewSchema::IDENTIFIER ] = new ProductReviewSchema(
$this->extend,
$this->schemas[ ImageAttachmentSchema::IDENTIFIER ]
);
}
}

View File

@ -0,0 +1,195 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\RestApi\Routes;
/**
* AddressSchema class.
*
* Provides a generic address schema for composition in other schemas.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 4.1.0
*/
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 ) {
$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'] = $this->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;
}
/**
* Format a state based on the country. If country has defined states, will return an upper case state code.
*
* @param string $state State name or code (sanitized).
* @param string $country Country code.
* @return string
*/
protected function format_state( $state, $country ) {
$states = $country ? array_filter( (array) wc()->countries->get_states( $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;
}
/**
* 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 );
if ( empty( $address['country'] ) ) {
$errors->add(
'missing_country',
__( 'Country is required', 'woocommerce' )
);
return $errors;
}
if ( ! 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;
}
$states = array_filter( array_keys( (array) wc()->countries->get_states( $address['country'] ) ) );
if ( ! empty( $address['state'] ) && count( $states ) && ! in_array( $address['state'], $states, true ) ) {
$errors->add(
'invalid_state',
sprintf(
/* translators: %s valid states */
__( 'The provided state is not valid. Must be one of: %s', 'woocommerce' ),
implode( ', ', $states )
)
);
}
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,369 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
/**
* AbstractSchema class.
*
* For REST Route Schemas
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
abstract class AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'Schema';
/**
* Rest extend instance
*
* @var ExtendRestApi
*/
protected $extend;
/**
* Extending key that gets added to endpoint.
*
* @var string
*/
const EXTENDING_KEY = 'extensions';
/**
* Constructor.
*
* @param ExtendRestApi $extend Rest Extending instance.
*/
public function __construct( ExtendRestApi $extend ) {
$this->extend = $extend;
}
/**
* 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(),
);
}
/**
* 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 identifer.
* @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 ) {
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 ( is_wp_error( $current_value ) ) {
return $current_value;
}
if ( isset( $property_value['properties'] ) ) {
$sanitize_callback = $this->get_recursive_sanitize_callback( $property_value['properties'] );
return $sanitize_callback( $current_value, $request, $param . ' > ' . $property_key );
}
}
return true;
};
}
/**
* 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,124 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\RestApi\Routes;
/**
* BillingAddressSchema class.
*
* Provides a generic billing address schema for composition in other schemas.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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 ) {
if ( ( $address instanceof \WC_Customer || $address instanceof \WC_Order ) ) {
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' => $address->get_billing_state(),
'postcode' => $address->get_billing_postcode(),
'country' => $address->get_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,112 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\CartController;
/**
* CartCouponSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
* @since 3.9.0 Coupon type (`discount_type`) added.
*/
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,55 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use AutomateWoo\Exception;
use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException;
/**
* Class CartExtensionsSchema
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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 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 ( RouteException $e ) {
throw $e;
}
if ( is_callable( $callback ) ) {
$callback( $request['data'] );
}
return rest_ensure_response( wc()->api->get_endpoint_data( '/wc/store/cart' ) );
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
/**
* CartFeeSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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,447 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
/**
* CartItemSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
class CartItemSchema extends ProductSchema {
/**
* 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' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'quantity_limit' => [
'description' => __( 'The maximum quantity than can be added to the cart at once.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => 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'];
return [
'key' => $cart_item['key'],
'id' => $product->get_id(),
'quantity' => wc_stock_amount( $cart_item['quantity'] ),
'quantity_limit' => $this->get_product_quantity_limit( $product ),
'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->get_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;
}
/**
* 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;
}
$draft_order = wc()->session->get( 'store_api_draft_order', 0 );
$reserve_stock = new ReserveStock();
$reserved_stock = $reserve_stock->get_reserved_stock( $product, $draft_order );
return $product->get_stock_quantity() - $reserved_stock;
}
/**
* 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 {
// If this is a custom option slug, get the options name.
$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 ) {
$item_data = apply_filters( 'woocommerce_get_item_data', array(), $cart_item );
return array_map( [ $this, 'format_item_data_element' ], $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,424 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
use WC_Tax;
use WP_Error;
/**
* CartSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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;
/**
* 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 ExtendRestApi $extend Rest Extending instance.
* @param CartItemSchema $item_schema Item schema instance.
* @param CartCouponSchema $coupon_schema Coupon schema instance.
* @param CartFeeSchema $fee_schema Fee schema instance.
* @param CartShippingRateSchema $shipping_rate_schema Shipping rates schema instance.
* @param ShippingAddressSchema $shipping_address_schema Shipping address schema instance.
* @param BillingAddressSchema $billing_address_schema Billing address schema instance.
* @param ErrorSchema $error_schema Error schema instance.
*/
public function __construct(
ExtendRestApi $extend,
CartItemSchema $item_schema,
CartCouponSchema $coupon_schema,
CartFeeSchema $fee_schema,
CartShippingRateSchema $shipping_rate_schema,
ShippingAddressSchema $shipping_address_schema,
BillingAddressSchema $billing_address_schema,
ErrorSchema $error_schema
) {
$this->item_schema = $item_schema;
$this->coupon_schema = $coupon_schema;
$this->fee_schema = $fee_schema;
$this->shipping_rate_schema = $shipping_rate_schema;
$this->shipping_address_schema = $shipping_address_schema;
$this->billing_address_schema = $billing_address_schema;
$this->error_schema = $error_schema;
parent::__construct( $extend );
}
/**
* 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,
],
'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_requirements' => [
'description' => __( 'List of required payment gateway features to process the order.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'generated_timestamp' => [
'description' => __( 'The time at which this cart data was prepared', 'woocommerce' ),
'type' => 'number',
'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() : [];
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' ),
'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_requirements' => $this->extend->get_payment_requirements(),
'generated_timestamp' => time(),
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 ) {
$cart_tax_totals = $cart->get_tax_totals();
$tax_lines = [];
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();
$item_errors = array_filter(
$controller->get_cart_item_errors(),
function ( WP_Error $error ) {
return $error->has_errors();
}
);
$coupon_errors = $controller->get_cart_coupon_errors();
return array_values( array_map( [ $this->error_schema, 'get_item_response' ], array_merge( $item_errors, $coupon_errors ) ) );
}
}

View File

@ -0,0 +1,354 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use WC_Shipping_Rate as ShippingRate;
/**
* CartShippingRateSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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 ) {
return (object) $this->prepare_html_response(
[
'address_1' => $package['destination']['address_1'],
'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 $item_id => $values ) {
$items[] = [
'key' => $item_id,
'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( $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,219 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\Payments\PaymentResult;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
/**
* CheckoutSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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;
/**
* Constructor.
*
* @param ExtendRestApi $extend Rest Extending instance.
* @param BillingAddressSchema $billing_address_schema Billing address schema instance.
* @param ShippingAddressSchema $shipping_address_schema Shipping address schema instance.
*/
public function __construct( ExtendRestApi $extend, BillingAddressSchema $billing_address_schema, ShippingAddressSchema $shipping_address_schema ) {
$this->billing_address_schema = $billing_address_schema;
$this->shipping_address_schema = $shipping_address_schema;
parent::__construct( $extend );
}
/**
* 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' ],
],
'required' => true,
],
'payment_method' => [
'description' => __( 'The ID of the payment method being used to process the payment.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'enum' => wc()->payment_gateways->get_payment_gateway_ids(),
],
'create_account' => [
'description' => __( 'Whether to create a new user account as part of order processing.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
],
'payment_result' => [
'description' => __( 'Result of payment processing, or 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,60 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
/**
* ErrorSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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,101 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
/**
* ImageAttachmentSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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,80 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
/**
* OrderCouponSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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,94 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
/**
* ProductAttributeSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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,127 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
/**
* ProductCategorySchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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 ExtendRestApi $extend Rest Extending instance.
* @param ImageAttachmentSchema $image_attachment_schema Image attachment schema instance.
*/
public function __construct( ExtendRestApi $extend, ImageAttachmentSchema $image_attachment_schema ) {
$this->image_attachment_schema = $image_attachment_schema;
parent::__construct( $extend );
}
/**
* 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 );
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$products_of_category_sql = $wpdb->prepare(
"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 ({$terms_to_count_str})"
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$review_count = $wpdb->get_var( $products_of_category_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return (int) $review_count;
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
/**
* ProductCollectionDataSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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,188 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
/**
* ProductReviewSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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 ExtendRestApi $extend Rest Extending instance.
* @param ImageAttachmentSchema $image_attachment_schema Image attachment schema instance.
*/
public function __construct( ExtendRestApi $extend, ImageAttachmentSchema $image_attachment_schema ) {
$this->image_attachment_schema = $image_attachment_schema;
parent::__construct( $extend );
}
/**
* 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,824 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
/**
* ProductSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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 ExtendRestApi $extend Rest Extending instance.
* @param ImageAttachmentSchema $image_attachment_schema Image attachment schema instance.
*/
public function __construct( ExtendRestApi $extend, ImageAttachmentSchema $image_attachment_schema ) {
$this->image_attachment_schema = $image_attachment_schema;
parent::__construct( $extend );
}
/**
* 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 assigned to the product/variation that are visible or used for variations.', '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,
],
'quantity_limit' => [
'description' => __( 'The maximum quantity than can be added to the cart at once.', 'woocommerce' ),
'type' => 'integer',
'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,
],
],
],
];
}
/**
* 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(),
'quantity_limit' => $this->get_product_quantity_limit( $product ),
'add_to_cart' => (object) $this->prepare_html_response(
[
'text' => $product->add_to_cart_text(),
'description' => $product->add_to_cart_description(),
'url' => $product->add_to_cart_url(),
]
),
];
}
/**
* 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_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 );
if ( ! is_null( $remaining_stock ) && $remaining_stock <= wc_get_low_stock_amount( $product ) ) {
return max( $remaining_stock, 0 );
}
return null;
}
/**
* Get the quantity limit for an item in the cart.
*
* @param \WC_Product $product Product instance.
* @return int
*/
protected function get_product_quantity_limit( \WC_Product $product ) {
$limits = [ 99 ];
if ( $product->is_sold_individually() ) {
$limits[] = 1;
} elseif ( ! $product->backorders_allowed() ) {
$limits[] = $this->get_remaining_stock( $product );
}
return apply_filters( 'woocommerce_store_api_product_quantity_limit', max( min( array_filter( $limits ) ), 1 ), $product );
}
/**
* 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 ) {
if ( ! $product->is_type( 'variable' ) ) {
return [];
}
global $wpdb;
$variation_ids = $product->get_visible_children();
if ( ! count( $variation_ids ) ) {
return [];
}
$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;
},
[]
);
// 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', array_keys( $default_variation_meta_data ) ) ) . "')
"
);
// phpcs:enable
$attributes_by_variation = array_reduce(
$variation_meta_data,
function( $values, $data ) {
$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 and 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\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\RestApi\Routes;
/**
* ShippingAddressSchema class.
*
* Provides a generic shipping address schema for composition in other schemas.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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 ) {
if ( ( $address instanceof \WC_Customer || $address instanceof \WC_Order ) ) {
if ( is_callable( [ $address, 'get_shipping_phone' ] ) ) {
$shipping_phone = $address->get_shipping_phone();
} else {
$shipping_phone = $address->get_meta( $address instanceof \WC_Customer ? 'shipping_phone' : '_shipping_phone', true );
}
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' => $address->get_shipping_state(),
'postcode' => $address->get_shipping_postcode(),
'country' => $address->get_shipping_country(),
'phone' => $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,87 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
/**
* TermSchema class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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,
];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,75 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use WP_Error;
/**
* InvalidStockLevelsInCartException class.
*
* @internal This API is used internally by Blocks, 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,12 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* NotPurchasableException class.
*
* @internal This API is used internally by Blocks, 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,40 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException;
/**
* NoticeHandler class.
* Helper class to convert notices to exceptions.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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' ) ) {
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 );
}
}
}

View File

@ -0,0 +1,526 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use \Exception;
use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException;
/**
* OrderController class.
* Helper class which creates and syncs orders with the cart.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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 ) {
// 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_value = is_callable( [ $order, 'get_shipping_phone' ] ) ? $order->get_shipping_phone() : $order->get_meta( '_shipping_phone', true );
if ( is_callable( [ $customer, 'set_shipping_phone' ] ) ) {
$customer->set_shipping_phone( $shipping_phone_value );
} else {
$customer->update_meta_data( 'shipping_phone', $shipping_phone_value );
}
$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_value = is_callable( [ wc()->customer, 'get_shipping_phone' ] ) ? wc()->customer->get_shipping_phone() : wc()->customer->get_meta( 'shipping_phone', true );
if ( is_callable( [ $order, 'set_shipping_phone' ] ) ) {
$order->set_shipping_phone( $shipping_phone_value );
} else {
$order->update_meta_data( '_shipping_phone', $shipping_phone_value );
}
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* OutOfStockException class.
*
* @internal This API is used internally by Blocks, 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,74 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* Pagination class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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,12 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* PartialOutOfStockException class.
*
* @internal This API is used internally by Blocks, 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,476 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use WC_Tax;
/**
* Product Query class.
* Helper class to handle product queries for the API.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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',
];
// Map between taxonomy name and arg key.
$taxonomies = [
'product_cat' => 'category',
'product_tag' => 'tag',
];
// 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 ) {
$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,217 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\ProductQuery;
/**
* Product Query filters class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
class ProductQueryFilters {
/**
* Get filtered min price for current products.
*
* @param \WP_REST_Request $request The request object.
* @return array
*/
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' ) );
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* StockAvailabilityException class.
*
* @internal This API is used internally by Blocks, 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,12 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* TooManyInCartException class.
*
* @internal This API is used internally by Blocks, 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 {
}