diff --git a/includes/admin/class-wc-admin-profile.php b/includes/admin/class-wc-admin-profile.php index 37850320b8b..ebeb709c8a5 100644 --- a/includes/admin/class-wc-admin-profile.php +++ b/includes/admin/class-wc-admin-profile.php @@ -26,6 +26,12 @@ class WC_Admin_Profile { add_action( 'personal_options_update', array( $this, 'save_customer_meta_fields' ) ); add_action( 'edit_user_profile_update', array( $this, 'save_customer_meta_fields' ) ); + + add_action( 'show_user_profile', array( $this, 'add_api_key_field' ) ); + add_action( 'edit_user_profile', array( $this, 'add_api_key_field' ) ); + + add_action( 'personal_options_update', array( $this, 'generate_api_key' ) ); + add_action( 'edit_user_profile_update', array( $this, 'generate_api_key' ) ); } /** @@ -176,8 +182,116 @@ class WC_Admin_Profile { update_user_meta( $user_id, $key, woocommerce_clean( $_POST[ $key ] ) ); } + /** + * Display the API key info for a user + * + * @since 2.1 + * @param WP_User $user + */ + public function add_api_key_field( $user ) { + + if ( ! current_user_can( 'manage_woocommerce' ) ) + return; + + $permissions = array( + 'read' => __( 'Read', 'woocommerce' ), + 'write' => __( 'Write', 'woocommerce' ), + 'read_write' => __( 'Read/Write', 'woocommerce' ), + ); + + if ( current_user_can( 'edit_user', $user->ID ) ) { + ?> + + + + + + + +
+ woocommerce_api_consumer_key ) ) : ?> + + + +  woocommerce_api_consumer_key ?>
+  woocommerce_api_consumer_secret; ?>
+  
+ + + +
+ woocommerce_api_consumer_key ) ) { + + $consumer_key = 'ck_' . hash( 'md5', $user->user_login . date( 'U' ) . mt_rand() ); + + update_user_meta( $user_id, 'woocommerce_api_consumer_key', $consumer_key ); + + } else { + + delete_user_meta( $user_id, 'woocommerce_api_consumer_key' ); + } + + // consumer secret + if ( empty( $user->woocommerce_api_consumer_secret ) ) { + + $consumer_secret = 'cs_' . hash( 'md5', $user->ID . date( 'U' ) . mt_rand() ); + + update_user_meta( $user_id, 'woocommerce_api_consumer_secret', $consumer_secret ); + + } else { + + delete_user_meta( $user_id, 'woocommerce_api_consumer_secret' ); + } + + // permissions + if ( empty( $user->woocommerce_api_key_permissions ) ) { + + $permissions = ( ! in_array( $_POST['woocommerce_api_key_permissions'], array( 'read', 'write', 'read_write' ) ) ) ? 'read' : $_POST['woocommerce_api_key_permissions']; + + update_user_meta( $user_id, 'woocommerce_api_key_permissions', $permissions ); + + } else { + + delete_user_meta( $user_id, 'woocommerce_api_key_permissions' ); + } + + } else { + + // updating permissions for key + if ( ! empty( $_POST['woocommerce_api_key_permissions'] ) && $user->woocommerce_api_key_permissions !== $_POST['woocommerce_api_key_permissions'] ) { + + $permissions = ( ! in_array( $_POST['woocommerce_api_key_permissions'], array( 'read', 'write', 'read_write' ) ) ) ? 'read' : $_POST['woocommerce_api_key_permissions']; + + update_user_meta( $user_id, 'woocommerce_api_key_permissions', $permissions ); + } + } + } + } + } endif; -return new WC_Admin_Profile(); \ No newline at end of file +return new WC_Admin_Profile(); diff --git a/includes/admin/class-wc-admin-settings.php b/includes/admin/class-wc-admin-settings.php index 94b8a7f7703..3748c84af65 100644 --- a/includes/admin/class-wc-admin-settings.php +++ b/includes/admin/class-wc-admin-settings.php @@ -770,4 +770,4 @@ class WC_Admin_Settings { } } -endif; \ No newline at end of file +endif; diff --git a/includes/admin/settings/class-wc-settings-general.php b/includes/admin/settings/class-wc-settings-general.php index 4503dd57765..7470bda99be 100644 --- a/includes/admin/settings/class-wc-settings-general.php +++ b/includes/admin/settings/class-wc-settings-general.php @@ -99,6 +99,14 @@ class WC_Settings_General extends WC_Settings_Page { 'autoload' => false ), + array( + 'title' => __( 'API', 'woocommerce' ), + 'desc' => __( 'Enable the REST API', 'woocommerce' ), + 'id' => 'woocommerce_api_enabled', + 'type' => 'checkbox', + 'default' => 'yes', + ), + array( 'type' => 'sectionend', 'id' => 'general_options'), array( 'title' => __( 'Currency Options', 'woocommerce' ), 'type' => 'title', 'desc' => __( 'The following options affect how prices are displayed on the frontend.', 'woocommerce' ), 'id' => 'pricing_options' ), @@ -293,4 +301,4 @@ class WC_Settings_General extends WC_Settings_Page { endif; -return new WC_Settings_General(); \ No newline at end of file +return new WC_Settings_General(); diff --git a/includes/api/class-wc-api-authentication.php b/includes/api/class-wc-api-authentication.php new file mode 100644 index 00000000000..c40e006f3ee --- /dev/null +++ b/includes/api/class-wc-api-authentication.php @@ -0,0 +1,298 @@ +api->server->path ) + return new WP_User(0); + + try { + + if ( is_ssl() ) + $user = $this->perform_ssl_authentication(); + else + $user = $this->perform_oauth_authentication(); + + // check API key-specific permission + $this->check_api_key_permissions( $user ); + + } catch ( Exception $e ) { + + $user = new WP_Error( 'woocommerce_api_authentication_error', $e->getMessage(), array( 'status' => $e->getCode() ) ); + } + + return $user; + } + + /** + * SSL-encrypted requests are not subject to sniffing or man-in-the-middle attacks, so the request can be authenticated + * by simply looking up the user associated with the given consumer key and confirming the consumer secret provided is valid + * + * @since 2.1 + * @return WP_User + * @throws Exception + */ + private function perform_ssl_authentication() { + + if ( empty( $_SERVER['PHP_AUTH_USER'] ) ) + throw new Exception( __( 'Consumer Key is missing', 'woocommerce' ), 404 ); + + if ( empty( $_SERVER['PHP_AUTH_PW'] ) ) + throw new Exception( __( 'Consumer Secret is missing', 'woocommerce' ), 404 ); + + $consumer_key = $_SERVER['PHP_AUTH_USER']; + $consumer_secret = $_SERVER['PHP_AUTH_PW']; + + $user = $this->get_user_by_consumer_key( $consumer_key ); + + if ( ! $this->is_consumer_secret_valid( $user, $consumer_secret ) ) + throw new Exception( __( 'Consumer Secret is invalid', 'woocommerce'), 401 ); + + return $user; + } + + /** + * Perform OAuth 1.0a "one-legged" (http://oauthbible.com/#oauth-10a-one-legged) authentication for non-SSL requests + * + * This is required so API credentials cannot be sniffed or intercepted when making API requests over plain HTTP + * + * This follows the spec for simple OAuth 1.0a authentication (RFC 5849) as closely as possible, with two exceptions: + * + * 1) There is no token associated with request/responses, only consumer keys/secrets are used + * + * 2) The OAuth parameters are included as part of the request query string instead of part of the Authorization header, + * This is because there is no cross-OS function within PHP to get the raw Authorization header + * + * @link http://tools.ietf.org/html/rfc5849 for the full spec + * @since 2.1 + * @return WP_User + * @throws Exception + */ + private function perform_oauth_authentication() { + + $params = WC()->api->server->params['GET']; + + $param_names = array( 'oauth_consumer_key', 'oauth_timestamp', 'oauth_nonce', 'oauth_signature', 'oauth_signature_method' ); + + // check for required OAuth parameters + foreach ( $param_names as $param_name ) { + + if ( empty( $params ) ) + throw new Exception( sprintf( __( '%s parameter is missing', 'woocommerce' ), $param_name ) ); + } + + // fetch WP user by consumer key + $user = $this->get_user_by_consumer_key( $params['oauth_consumer_key'] ); + + // perform OAuth validation + $this->check_oauth_signature( $user, $params ); + $this->check_oauth_timestamp_and_nonce( $user, $params['oauth_timestamp'], $params['oauth_nonce'] ); + + // remove oauth params before further parsing + foreach( $param_names as $param_name ) { + unset( WC()->api->server->params[ $param_name ] ); + } + + // authentication successful, return user + return $user; + } + + /** + * Return the user for the given consumer key + * + * @since 2.1 + * @param string $consumer_key + * @return WP_User + * @throws Exception + */ + private function get_user_by_consumer_key( $consumer_key ) { + + $user_query = new WP_User_Query( + array( + 'meta_key' => 'woocommerce_api_consumer_key', + 'meta_value' => $consumer_key, + ) + ); + + $users = $user_query->get_results(); + + if ( empty( $users[0] ) ) + throw new Exception( __( 'Consumer Key is invalid', 'woocommerce' ), 401 ); + + return $users[0]; + } + + /** + * Check if the consumer secret provided for the given user is valid + * + * @since 2.1 + * @param WP_User $user + * @param string $consumer_secret + * @return bool + */ + private function is_consumer_secret_valid( WP_User $user, $consumer_secret ) { + + return $user->woocommerce_api_consumer_secret === $consumer_secret; + } + + /** + * Verify that the consumer-provided request signature matches our generated signature, this ensures the consumer + * has a valid key/secret + * + * @param WP_User $user + * @param array $params the request parameters + * @throws Exception + */ + private function check_oauth_signature( $user, $params ) { + + $http_method = strtoupper( WC()->api->server->method ); + + $base_request_uri = rawurlencode( get_home_url( null, parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ), 'http' ) ); + + // get the signature provided by the consumer and remove it from the parameters prior to checking the signature + $consumer_signature = rawurldecode( $params['oauth_signature'] ); + unset( $params['oauth_signature'] ); + + // normalize parameter key/values + array_walk( $params, array( $this, 'normalize_parameters' ) ); + + // sort parameters + if ( ! uksort( $params, 'strcmp' ) ) + throw new Exception( __( 'Invalid Signature - failed to sort parameters', 'woocommerce' ), 401 ); + + // form query string + $query_params = array(); + foreach ( $params as $param_key => $param_value ) { + + $query_params[] = $param_key . '%3D' . $param_value; // join with equals sign + } + $query_string = implode( '%26', $query_params ); // join with ampersand + + $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string; + + if ( $params['oauth_signature_method'] !== 'HMAC-SHA1' && $params['oauth_signature_method'] !== 'HMAC-SHA256' ) + throw new Exception( __( 'Invalid Signature - signature method is invalid', 'woocommerce' ), 401 ); + + $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) ); + + $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $user->woocommerce_api_consumer_secret, true ) ); + + if ( $signature !== $consumer_signature ) + throw new Exception( __( 'Invalid Signature - provided signature does not match', 'woocommerce' ), 401 ); + } + + /** + * Normalize each parameter by assuming each parameter may have already been encoded, so attempt to decode, and then + * re-encode according to RFC 3986 + * + * @since 2.1 + * @see rawurlencode() + * @param string $key + * @param string $value + */ + private function normalize_parameters( &$key, &$value ) { + + $key = rawurlencode( rawurldecode( $key ) ); + $value = rawurlencode( rawurldecode( $value ) ); + } + + /** + * Verify that the timestamp and nonce provided with the request are valid. This prevents replay attacks where + * an attacker could attempt to re-send an intercepted request at a later time. + * + * - A timestamp is valid if it is within 15 minutes of now + * - A nonce is valid if it has not been used within the last 15 minutes + * + * @param WP_User $user + * @param int $timestamp the unix timestamp for when the request was made + * @param string $nonce a unique (for the given user) 32 alphanumeric string, consumer-generated + * @throws Exception + */ + private function check_oauth_timestamp_and_nonce( $user, $timestamp, $nonce ) { + + $valid_window = 15 * 60; // 15 minute window + + if ( ( $timestamp < time() - $valid_window ) || ( $timestamp > time() + $valid_window ) ) + throw new Exception( __( 'Invalid timestamp', 'woocommerce' ) ); + + $used_nonces = $user->woocommerce_api_nonces; + + if ( empty( $used_nonces ) ) + $used_nonces = array(); + + if ( in_array( $nonce, $used_nonces ) ) + throw new Exception( __( 'Invalid nonce - nonce has already been used', 'woocommerce' ), 401 ); + + $used_nonces[ $timestamp ] = $nonce; + + // remove expired nonces + foreach( $used_nonces as $nonce_timestamp => $nonce ) { + + if ( $nonce_timestamp < $valid_window ) + unset( $used_nonces[ $nonce_timestamp ] ); + } + + update_user_meta( $user->ID, 'woocommerce_api_nonces', $used_nonces ); + } + + /** + * Check that the API keys provided have the proper key-specific permissions to either read or write API resources + * + * @param WP_User $user + * @throws Exception if the permission check fails + */ + public function check_api_key_permissions( $user ) { + + $key_permissions = $user->woocommerce_api_key_permissions; + + switch ( WC()->api->server->method ) { + + case 'HEAD': + case 'GET': + if ( 'read' !== $key_permissions && 'read_write' !== $key_permissions ) { + throw new Exception( __( 'The API key provided does not have read permissions', 'woocommerce' ), 401 ); + } + break; + + case 'POST': + case 'PUT': + case 'PATCH': + case 'DELETE': + if ( 'write' !== $key_permissions && 'read_write' !== $key_permissions ) { + throw new Exception( __( 'The API key provided does not have write permissions', 'woocommerce' ), 401 ); + } + break; + } + } +} diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php new file mode 100644 index 00000000000..52341fdd4ab --- /dev/null +++ b/includes/api/class-wc-api-coupons.php @@ -0,0 +1,253 @@ + + * + * @since 2.1 + * @param array $routes + * @return array + */ + public function register_routes( $routes ) { + + # GET|POST /coupons + $routes[ $this->base ] = array( + array( array( $this, 'get_coupons' ), WC_API_Server::READABLE ), + array( array( $this, 'create_coupon' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), + ); + + # GET /coupons/count + $routes[ $this->base . '/count'] = array( + array( array( $this, 'get_coupons_count' ), WC_API_Server::READABLE ), + ); + + # GET|PUT|DELETE /coupons/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'get_coupon' ), WC_API_Server::READABLE ), + array( array( $this, 'edit_coupon' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'delete_coupon' ), WC_API_Server::DELETABLE ), + ); + + # GET /coupons/code/, note that coupon codes can contain spaces, dashes and underscores + $routes[ $this->base . '/code/(?P\w[\w\s\-]*)' ] = array( + array( array( $this, 'get_coupon_by_code' ), WC_API_Server::READABLE ), + ); + + return $routes; + } + + /** + * Get all coupons + * + * @since 2.1 + * @param string $fields + * @param array $filter + * @param int $page + * @return array + */ + public function get_coupons( $fields = null, $filter = array(), $page = 1 ) { + + $filter['page'] = $page; + + $query = $this->query_coupons( $filter ); + + $coupons = array(); + + foreach( $query->posts as $coupon_id ) { + + if ( ! $this->is_readable( $coupon_id ) ) + continue; + + $coupons[] = $this->get_coupon( $coupon_id, $fields ); + } + + $this->server->add_pagination_headers( $query ); + + return array( 'coupons' => $coupons ); + } + + /** + * Get the coupon for the given ID + * + * @since 2.1 + * @param int $id the coupon ID + * @param string $fields fields to include in response + * @return array|WP_Error + */ + public function get_coupon( $id, $fields = null ) { + global $wpdb; + + $id = $this->validate_request( $id, 'shop_coupon', 'read' ); + + if ( is_wp_error( $id ) ) + return $id; + + // get the coupon code + $code = $wpdb->get_var( $wpdb->prepare( "SELECT post_title FROM $wpdb->posts WHERE id = %s AND post_type = 'shop_coupon' AND post_status = 'publish'", $id ) ); + + if ( is_null( $code ) ) + return new WP_Error( 'woocommerce_api_invalid_coupon_id', __( 'Invalid coupon ID', 'woocommerce' ), array( 'status' => 404 ) ); + + $coupon = new WC_Coupon( $code ); + + $coupon_post = get_post( $coupon->id ); + + $coupon_data = array( + 'id' => $coupon->id, + 'code' => $coupon->code, + 'type' => $coupon->type, + 'created_at' => $this->server->format_datetime( $coupon_post->post_date_gmt ), + 'updated_at' => $this->server->format_datetime( $coupon_post->post_modified_gmt ), + 'amount' => woocommerce_format_decimal( $coupon->amount ), + 'individual_use' => $coupon->individual_use, + 'product_ids' => $coupon->product_ids, + 'exclude_product_ids' => $coupon->exclude_product_ids, + 'usage_limit' => $coupon->usage_limit, + 'usage_limit_per_user' => $coupon->usage_limit_per_user, + 'limit_usage_to_x_items' => $coupon->limit_usage_to_x_items, + 'usage_count' => $coupon->usage_count, + 'expiry_date' => $this->server->format_datetime( $coupon->expiry_date ), + 'apply_before_tax' => $coupon->apply_before_tax(), + 'enable_free_shipping' => $coupon->enable_free_shipping(), + 'product_categories' => $coupon->product_categories, + 'exclude_product_categories' => $coupon->exclude_product_categories, + 'exclude_sale_items' => $coupon->exclude_sale_items(), + 'minimum_amount' => $coupon->minimum_amount, + 'customer_email' => $coupon->customer_email, + ); + + return apply_filters( 'woocommerce_api_coupon_response', $coupon_data, $coupon, $fields, $this->server ); + } + + /** + * Get the total number of coupons + * + * @since 2.1 + * @param array $filter + * @return array + */ + public function get_coupons_count( $filter = array() ) { + + $query = $this->query_coupons( $filter ); + + // TODO: permissions? + + return array( 'count' => $query->found_posts ); + } + + /** + * Get the coupon for the given code + * + * @since 2.1 + * @param string $code the coupon code + * @param string $fields fields to include in response + * @return int|WP_Error + */ + public function get_coupon_by_code( $code, $fields = null ) { + global $wpdb; + + $id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $wpdb->posts WHERE post_title = %s AND post_type = 'shop_coupon' AND post_status = 'publish'", $code ) ); + + if ( is_null( $id ) ) + return new WP_Error( 'woocommerce_api_invalid_coupon_code', __( 'Invalid coupon code', 'woocommerce' ), array( 'status' => 404 ) ); + + return $this->get_coupon( $id, $fields ); + } + + /** + * Create a coupon + * + * @since 2.1 + * @param array $data + * @return array + */ + public function create_coupon( $data ) { + + // TODO: permissions check + + // TODO: implement - what's the minimum set of data required? + + return array(); + } + + /** + * Edit a coupon + * + * @since 2.1 + * @param int $id the coupon ID + * @param array $data + * @return array + */ + public function edit_coupon( $id, $data ) { + + $id = $this->validate_request( $id, 'shop_coupon', 'edit' ); + + if ( is_wp_error( $id ) ) + return $id; + + // TODO: implement + + return $this->get_coupon( $id ); + } + + /** + * Delete a coupon + * + * @since 2.1 + * @param int $id the coupon ID + * @param bool $force true to permanently delete coupon, false to move to trash + * @return array + */ + public function delete_coupon( $id, $force = false ) { + + $id = $this->validate_request( $id, 'shop_coupon', 'delete' ); + + if ( is_wp_error( $id ) ) + return $id; + + return $this->delete( $id, 'shop_coupon', ( 'true' === $force ) ); + } + + /** + * Helper method to get coupon post objects + * + * @since 2.1 + * @param array $args request arguments for filtering query + * @return WP_Query + */ + private function query_coupons( $args ) { + + // set base query arguments + $query_args = array( + 'fields' => 'ids', + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + ); + + $query_args = $this->merge_query_args( $query_args, $args ); + + return new WP_Query( $query_args ); + } + +} diff --git a/includes/api/class-wc-api-customers.php b/includes/api/class-wc-api-customers.php new file mode 100644 index 00000000000..fdd6d64e6e0 --- /dev/null +++ b/includes/api/class-wc-api-customers.php @@ -0,0 +1,475 @@ + + * GET /customers//orders + * + * @since 2.1 + * @param array $routes + * @return array + */ + public function register_routes( $routes ) { + + # GET|POST /customers + $routes[ $this->base ] = array( + array( array( $this, 'get_customers' ), WC_API_SERVER::READABLE ), + array( array( $this, 'create_customer' ), WC_API_SERVER::CREATABLE | WC_API_SERVER::ACCEPT_DATA ), + ); + + # GET /customers/count + $routes[ $this->base . '/count'] = array( + array( array( $this, 'get_customers_count' ), WC_API_SERVER::READABLE ), + ); + + # GET|PUT|DELETE /customers/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'get_customer' ), WC_API_SERVER::READABLE ), + array( array( $this, 'edit_customer' ), WC_API_SERVER::EDITABLE | WC_API_SERVER::ACCEPT_DATA ), + array( array( $this, 'delete_customer' ), WC_API_SERVER::DELETABLE ), + ); + + # GET /customers//orders + $routes[ $this->base . '/(?P\d+)/orders' ] = array( + array( array( $this, 'get_customer_orders' ), WC_API_SERVER::READABLE ), + ); + + return $routes; + } + + /** + * Get all customers + * + * @since 2.1 + * @param array $fields + * @param array $filter + * @param int $page + * @return array + */ + public function get_customers( $fields = null, $filter = array(), $page = 1 ) { + + $filter['page'] = $page; + + $query = $this->query_customers( $filter ); + + $customers = array(); + + foreach( $query->get_results() as $user_id ) { + + if ( ! $this->is_readable( $user_id ) ) + continue; + + $customers[] = $this->get_customer( $user_id, $fields ); + } + + $this->server->add_pagination_headers( $query ); + + return array( 'customers' => $customers ); + } + + /** + * Get the customer for the given ID + * + * @since 2.1 + * @param int $id the customer ID + * @param string $fields + * @return array + */ + public function get_customer( $id, $fields = null ) { + global $wpdb; + + $id = $this->validate_request( $id, 'customer', 'read' ); + + if ( is_wp_error( $id ) ) + return $id; + + $customer = new WP_User( $id ); + + // get info about user's last order + $last_order = $wpdb->get_row( "SELECT id, post_date_gmt + FROM $wpdb->posts AS posts + LEFT JOIN {$wpdb->postmeta} AS meta on posts.ID = meta.post_id + WHERE meta.meta_key = '_customer_user' + AND meta.meta_value = {$customer->ID} + AND posts.post_type = 'shop_order' + AND posts.post_status = 'publish' + " ); + + $customer_data = array( + 'id' => $customer->ID, + 'created_at' => $this->server->format_datetime( $customer->user_registered ), + 'email' => $customer->user_email, + 'first_name' => $customer->first_name, + 'last_name' => $customer->last_name, + 'username' => $customer->user_login, + 'last_order_id' => is_object( $last_order ) ? $last_order->id : null, + 'last_order_date' => is_object( $last_order ) ? $this->server->format_datetime( $last_order->post_date_gmt ) : null, + 'orders_count' => (int) $customer->_order_count, + 'total_spent' => woocommerce_format_decimal( $customer->_money_spent ), + 'avatar_url' => $this->get_avatar_url( $customer->customer_email ), + 'billing_address' => array( + 'first_name' => $customer->billing_first_name, + 'last_name' => $customer->billing_last_name, + 'company' => $customer->billing_company, + 'address_1' => $customer->billing_address_1, + 'address_2' => $customer->billing_address_2, + 'city' => $customer->billing_city, + 'state' => $customer->billing_state, + 'postcode' => $customer->billing_postcode, + 'country' => $customer->billing_country, + 'email' => $customer->billing_email, + 'phone' => $customer->billing_phone, + ), + 'shipping_address' => array( + 'first_name' => $customer->shipping_first_name, + 'last_name' => $customer->shipping_last_name, + 'company' => $customer->shipping_company, + 'address_1' => $customer->shipping_address_1, + 'address_2' => $customer->shipping_address_2, + 'city' => $customer->shipping_city, + 'state' => $customer->shipping_state, + 'postcode' => $customer->shipping_postcode, + 'country' => $customer->shipping_country, + ), + ); + + return apply_filters( 'woocommerce_api_customer_response', $customer_data, $customer, $fields, $this->server ); + } + + /** + * Get the total number of customers + * + * @since 2.1 + * @param array $filter + * @return array + */ + public function get_customers_count( $filter = array() ) { + + $query = $this->query_customers( $filter ); + + if ( ! current_user_can( 'list_users' ) ) + return new WP_Error( 'woocommerce_api_user_cannot_read_customer', __( 'You do not have permission to read customers', 'woocommerce' ), array( 'status' => 401 ) ); + + return array( 'count' => count( $query->get_results() ) ); + } + + + /** + * Create a customer + * + * @since 2.1 + * @param array $data + * @return array + */ + public function create_customer( $data ) { + + if ( ! current_user_can( 'create_users' ) ) + return new WP_Error( 'woocommerce_api_user_cannot_create_customer', __( 'You do not have permission to create this customer', 'woocommerce' ), array( 'status' => 401 ) ); + + // TODO: implement - woocommerce_create_new_customer() + + return array(); + } + + /** + * Edit a customer + * + * @since 2.1 + * @param int $id the customer ID + * @param array $data + * @return array + */ + public function edit_customer( $id, $data ) { + + $id = $this->validate_request( $id, 'customer', 'edit' ); + + if ( ! is_wp_error( $id ) ) + return $id; + + // TODO: implement + + return $this->get_customer( $id ); + } + + /** + * Delete a customer + * + * @since 2.1 + * @param int $id the customer ID + * @return array + */ + public function delete_customer( $id ) { + + $id = $this->validate_request( $id, 'customer', 'delete' ); + + if ( ! is_wp_error( $id ) ) + return $id; + + return $this->delete( $id, 'customer' ); + } + + /** + * Get the orders for a customer + * + * @since 2.1 + * @param int $id the customer ID + * @param string $fields fields to include in response + * @return array + */ + public function get_customer_orders( $id, $fields = null ) { + global $wpdb; + + $id = $this->validate_request( $id, 'customer', 'read' ); + + if ( is_wp_error( $id ) ) + return $id; + + $order_ids = $wpdb->get_col( $wpdb->prepare( "SELECT id + FROM $wpdb->posts AS posts + LEFT JOIN {$wpdb->postmeta} AS meta on posts.ID = meta.post_id + WHERE meta.meta_key = '_customer_user' + AND meta.meta_value = '%s' + AND posts.post_type = 'shop_order' + AND posts.post_status = 'publish' + ", $id ) ); + + if ( empty( $order_ids ) ) + return array( 'orders' => array() ); + + $orders = array(); + + foreach ( $order_ids as $order_id ) { + $orders[] = WC()->api->WC_API_Orders->get_order( $order_id, $fields ); + } + + return array( 'orders' => apply_filters( 'woocommerce_api_customer_orders_response', $orders, $id, $fields, $order_ids, $this->server ) ); + } + + /** + * Helper method to get customer user objects + * + * Note that WP_User_Query does not have built-in pagination so limit & offset are used to provide limited + * pagination support + * + * @since 2.1 + * @param array $args request arguments for filtering query + * @return array + */ + private function query_customers( $args = array() ) { + + // default users per page + $users_per_page = get_option( 'posts_per_page' ); + + // set base query arguments + $query_args = array( + 'fields' => 'ID', + 'role' => 'customer', + 'orderby' => 'registered', + 'number' => $users_per_page, + ); + + // search + if ( ! empty( $args['q'] ) ) { + $query_args['search'] = $args['q']; + } + + // limit number of users returned + if ( ! empty( $args['limit'] ) ) { + + $query_args['number'] = absint( $args['limit'] ); + + $users_per_page = absint( $args['limit'] ); + } + + // page + $page = absint( $args['page'] ); + + // offset + if ( ! empty( $args['offset'] ) ) { + $query_args['offset'] = absint( $args['offset'] ); + } else { + $query_args['offset'] = $users_per_page * ( $page - 1 ); + } + + // created date + if ( ! empty( $args['created_at_min'] ) ) { + $this->created_at_min = $this->server->parse_datetime( $args['created_at_min'] ); + } + + if ( ! empty( $args['created_at_max'] ) ) { + $this->created_at_max = $this->server->parse_datetime( $args['created_at_max'] ); + } + + $query = new WP_User_Query( $query_args ); + + // helper members for pagination headers + $query->total_pages = ceil( $query->get_total() / $users_per_page ); + $query->page = $page; + + return $query; + } + + /** + * Add customer data to orders + * + * @since 2.1 + * @param $order_data + * @param $order + * @return array + */ + public function add_customer_data( $order_data, $order ) { + + if ( 0 == $order->customer_user ) { + + $order_data['customer'] = 'guest'; + + } else { + + $order_data['customer'] = $this->get_customer( $order->customer_user ); + } + + return $order_data; + } + + /** + * Modify the WP_User_Query to support filtering on the date the customer was created + * + * @since 2.1 + * @param WP_User_Query $query + */ + public function modify_user_query( $query ) { + + if ( $this->created_at_min ) + $query->query_where .= sprintf( " AND user_registered >= STR_TO_DATE( '%s', '%%Y-%%m-%%d %%h:%%i:%%s' )", esc_sql( $this->created_at_min ) ); + + if ( $this->created_at_max ) + $query->query_where .= sprintf( " AND user_registered <= STR_TO_DATE( '%s', '%%Y-%%m-%%d %%h:%%i:%%s' )", esc_sql( $this->created_at_max ) ); + } + + /** + * Wrapper for @see get_avatar() which doesn't simply return + * the URL so we need to pluck it from the HTML img tag + * + * @since 2.1 + * @param string $email the customer's email + * @return string the URL to the customer's avatar + */ + private function get_avatar_url( $email ) { + + $dom = new DOMDocument(); + + $dom->loadHTML( get_avatar( $email ) ); + + $url = $dom->getElementsByTagName( 'img' )->item( 0 )->getAttribute( 'src' ); + + return ( ! empty( $url ) ) ? $url : null; + } + + /** + * Validate the request by checking: + * + * 1) the ID is a valid integer + * 2) the ID returns a valid WP_User + * 3) the current user has the proper permissions + * + * @since 2.1 + * @see WC_API_Resource::validate_request() + * @param string|int $id the customer ID + * @param string $type the request type, unused because this method overrides the parent class + * @param string $context the context of the request, either `read`, `edit` or `delete` + * @return int|WP_Error valid user ID or WP_Error if any of the checks fails + */ + protected function validate_request( $id, $type, $context ) { + + $id = absint( $id ); + + // validate ID + if ( empty( $id ) ) + return new WP_Error( 'woocommerce_api_invalid_customer_id', __( 'Invalid customer ID', 'woocommerce' ), array( 'status' => 404 ) ); + + // non-existent IDs return a valid WP_User object with the user ID = 0 + $customer = new WP_User( $id ); + + if ( 0 === $customer->ID ) + return new WP_Error( 'woocommerce_api_invalid_customer', __( 'Invalid customer', 'woocommerce' ), array( 'status' => 404 ) ); + + // validate permissions + switch ( $context ) { + + case 'read': + if ( ! current_user_can( 'list_users' ) ) + return new WP_Error( 'woocommerce_api_user_cannot_read_customer', __( 'You do not have permission to read this customer', 'woocommerce' ), array( 'status' => 401 ) ); + break; + + case 'edit': + if ( ! current_user_can( 'edit_users' ) ) + return new WP_Error( 'woocommerce_api_user_cannot_edit_customer', __( 'You do not have permission to edit this customer', 'woocommerce' ), array( 'status' => 401 ) ); + break; + + case 'delete': + if ( ! current_user_can( 'delete_users' ) ) + return new WP_Error( 'woocommerce_api_user_cannot_delete_customer', __( 'You do not have permission to delete this customer', 'woocommerce' ), array( 'status' => 401 ) ); + break; + } + + return $id; + } + + /** + * Check if the current user can read users + * + * @since 2.1 + * @see WC_API_Resource::is_readable() + * @param int|WP_Post $post unused + * @return bool true if the current user can read users, false otherwise + */ + protected function is_readable( $post ) { + + return current_user_can( 'list_users' ); + } + +} diff --git a/includes/api/class-wc-api-json-handler.php b/includes/api/class-wc-api-json-handler.php new file mode 100644 index 00000000000..c08ebd4789a --- /dev/null +++ b/includes/api/class-wc-api-json-handler.php @@ -0,0 +1,73 @@ +api->server->send_status( 400 ); + + $data = array( array( 'code' => 'woocommerce_api_jsonp_disabled', 'message' => __( 'JSONP support is disabled on this site', 'woocommerce' ) ) ); + } + + // Check for invalid characters (only alphanumeric allowed) + if ( preg_match( '/\W/', $_GET['_jsonp'] ) ) { + + WC()->api->server->send_status( 400 ); + + $data = array( array( 'code' => 'woocommerce_api_json_callback_invalid', __( 'The JSONP callback function is invalid', 'woocommerce' ) ) ); + } + + return $_GET['_jsonp'] . '(' . json_encode( $data ) . ')'; + } + + return json_encode( $data ); + } + +} diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php new file mode 100644 index 00000000000..7650fd83be9 --- /dev/null +++ b/includes/api/class-wc-api-orders.php @@ -0,0 +1,374 @@ + + * GET /orders//notes + * + * @since 2.1 + * @param array $routes + * @return array + */ + public function register_routes( $routes ) { + + # GET /orders + $routes[ $this->base ] = array( + array( array( $this, 'get_orders' ), WC_API_Server::READABLE ), + ); + + # GET /orders/count + $routes[ $this->base . '/count'] = array( + array( array( $this, 'get_orders_count' ), WC_API_Server::READABLE ), + ); + + # GET|PUT|DELETE /orders/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'get_order' ), WC_API_Server::READABLE ), + array( array( $this, 'edit_order' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'delete_order' ), WC_API_Server::DELETABLE ), + ); + + # GET /orders//notes + $routes[ $this->base . '/(?P\d+)/notes' ] = array( + array( array( $this, 'get_order_notes' ), WC_API_Server::READABLE ), + ); + + return $routes; + } + + /** + * Get all orders + * + * @since 2.1 + * @param string $fields + * @param array $filter + * @param string $status + * @param int $page + * @return array + */ + public function get_orders( $fields = null, $filter = array(), $status = null, $page = 1 ) { + + if ( ! empty( $status ) ) + $filter['status'] = $status; + + $filter['page'] = $page; + + $query = $this->query_orders( $filter ); + + $orders = array(); + + foreach( $query->posts as $order_id ) { + + if ( ! $this->is_readable( $order_id ) ) + continue; + + $orders[] = $this->get_order( $order_id, $fields ); + } + + $this->server->add_pagination_headers( $query ); + + return array( 'orders' => $orders ); + } + + + /** + * Get the order for the given ID + * + * @since 2.1 + * @param int $id the order ID + * @param array $fields + * @return array + */ + public function get_order( $id, $fields = null ) { + + // ensure order ID is valid & user has permission to read + $id = $this->validate_request( $id, 'shop_order', 'read' ); + + if ( is_wp_error( $id ) ) + return $id; + + $order = new WC_Order( $id ); + + $order_post = get_post( $id ); + + $order_data = array( + 'id' => $order->id, + 'order_number' => $order->get_order_number(), + 'created_at' => $this->server->format_datetime( $order_post->post_date_gmt ), + 'updated_at' => $this->server->format_datetime( $order_post->post_modified_gmt ), + 'completed_at' => $this->server->format_datetime( $order->completed_date, true ), + 'status' => $order->status, + 'currency' => $order->order_currency, + 'total' => woocommerce_format_decimal( $order->get_total() ), + 'total_line_items_quantity' => $order->get_item_count(), + 'total_tax' => woocommerce_format_decimal( $order->get_total_tax() ), + 'total_shipping' => woocommerce_format_decimal( $order->get_total_shipping() ), + 'cart_tax' => woocommerce_format_decimal( $order->get_cart_tax() ), + 'shipping_tax' => woocommerce_format_decimal( $order->get_shipping_tax() ), + 'total_discount' => woocommerce_format_decimal( $order->get_total_discount() ), + 'cart_discount' => woocommerce_format_decimal( $order->get_cart_discount() ), + 'order_discount' => woocommerce_format_decimal( $order->get_order_discount() ), + 'shipping_methods' => $order->get_shipping_method(), + 'payment_details' => array( + 'method_id' => $order->payment_method, + 'method_title' => $order->payment_method_title, + 'paid' => isset( $order->paid_date ), + ), + 'billing_address' => array( + 'first_name' => $order->billing_first_name, + 'last_name' => $order->billing_last_name, + 'company' => $order->billing_company, + 'address_1' => $order->billing_address_1, + 'address_2' => $order->billing_address_2, + 'city' => $order->billing_city, + 'state' => $order->billing_state, + 'postcode' => $order->billing_postcode, + 'country' => $order->billing_country, + 'email' => $order->billing_email, + 'phone' => $order->billing_phone, + ), + 'shipping_address' => array( + 'first_name' => $order->shipping_first_name, + 'last_name' => $order->shipping_last_name, + 'company' => $order->shipping_company, + 'address_1' => $order->shipping_address_1, + 'address_2' => $order->shipping_address_2, + 'city' => $order->shipping_city, + 'state' => $order->shipping_state, + 'postcode' => $order->shipping_postcode, + 'country' => $order->shipping_country, + ), + 'note' => $order->customer_note, + 'customer_ip' => $order->customer_ip_address, + 'customer_user_agent' => $order->customer_user_agent, + 'customer_id' => $order->customer_user, + 'view_order_url' => $order->get_view_order_url(), + 'line_items' => array(), + 'shipping_lines' => array(), + 'tax_lines' => array(), + 'fee_lines' => array(), + 'coupon_lines' => array(), + ); + + // add line items + foreach( $order->get_items() as $item_id => $item ) { + + $product = $order->get_product_from_item( $item ); + + $order_data['line_items'][] = array( + 'id' => $item_id, + 'subtotal' => woocommerce_format_decimal( $order->get_line_subtotal( $item ) ), + 'total' => woocommerce_format_decimal( $order->get_line_total( $item ) ), + 'total_tax' => woocommerce_format_decimal( $order->get_line_tax( $item ) ), + 'quantity' => (int) $item['qty'], + 'tax_class' => ( ! empty( $item['tax_class'] ) ) ? $item['tax_class'] : null, + 'name' => $item['name'], + 'product_id' => ( isset( $product->variation_id ) ) ? $product->variation_id : $product->id, + 'sku' => $product->get_sku(), + ); + } + + // add shipping + foreach ( $order->get_shipping_methods() as $shipping_item_id => $shipping_item ) { + + $order_data['shipping_lines'][] = array( + 'id' => $shipping_item_id, + 'method_id' => $shipping_item['method_id'], + 'method_title' => $shipping_item['name'], + 'total' => woocommerce_format_decimal( $shipping_item['cost'] ), + ); + } + + // add taxes + foreach ( $order->get_tax_totals() as $tax_code => $tax ) { + + $order_data['tax_lines'][] = array( + 'code' => $tax_code, + 'title' => $tax->label, + 'total' => woocommerce_format_decimal( $tax->amount ), + 'compound' => (bool) $tax->is_compound, + ); + } + + // add fees + foreach ( $order->get_fees() as $fee_item_id => $fee_item ) { + + $order_data['fee_lines'] = array( + 'id' => $fee_item_id, + 'title' => $fee_item['name'], + 'tax_class' => ( ! empty( $fee_item['tax_class'] ) ) ? $fee_item['tax_class'] : null, + 'total' => woocommerce_format_decimal( $order->get_line_total( $fee_item ) ), + 'total_tax' => woocommerce_format_decimal( $order->get_line_tax( $fee_item ) ), + ); + } + + // add coupons + foreach ( $order->get_items( 'coupon' ) as $coupon_item_id => $coupon_item ) { + + $order_data['coupon_lines'] = array( + 'id' => $coupon_item_id, + 'code' => $coupon_item['name'], + 'amount' => woocommerce_format_decimal( $coupon_item['discount_amount'] ), + ); + } + + return apply_filters( 'woocommerce_api_order_response', $order_data, $order, $fields, $this->server ); + } + + /** + * Get the total number of orders + * + * @since 2.1 + * @param string $status + * @param array $filter + * @return array + */ + public function get_orders_count( $status = null, $filter = array() ) { + + if ( ! empty( $status ) ) + $filter['status'] = $status; + + $query = $this->query_orders( $filter ); + + // TODO: permissions? + + return array( 'count' => $query->found_posts ); + } + + + /** + * Edit an order + * + * @since 2.1 + * @param int $id the order ID + * @param array $data + * @return array + */ + public function edit_order( $id, $data ) { + + $id = $this->validate_request( $id, 'shop_order', 'write' ); + + if ( is_wp_error( $id ) ) + return $id; + + // TODO: implement, especially for status change + + return $this->get_order( $id ); + } + + /** + * Delete an order + * + * @since 2.1 + * @param int $id the order ID + * @param bool $force true to permanently delete order, false to move to trash + * @return array + */ + public function delete_order( $id, $force = false ) { + + $id = $this->validate_request( $id, 'shop_order', 'delete' ); + + return $this->delete( $id, 'order', ( 'true' === $force ) ); + } + + /** + * Get the admin order notes for an order + * + * @since 2.1 + * @param int $id the order ID + * @param string $fields fields to include in response + * @return array + */ + public function get_order_notes( $id, $fields = null ) { + + // ensure ID is valid order ID + $id = $this->validate_request( $id, 'order', 'read' ); + + if ( is_wp_error( $id ) ) + return $id; + + $args = array( + 'post_id' => $id, + 'approve' => 'approve', + 'type' => 'order_note' + ); + + remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments', 10, 1 ) ); + + $notes = get_comments( $args ); + + add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 ); + + $order_notes = array(); + + foreach ( $notes as $note ) { + + $order_notes[] = array( + 'id' => $note->comment_ID, + 'created_at' => $this->server->format_datetime( $note->comment_date_gmt ), + 'note' => $note->comment_content, + 'customer_note' => get_comment_meta( $note->comment_ID, 'is_customer_note', true ) ? true : false, + ); + } + + return array( 'order_notes' => apply_filters( 'woocommerce_api_order_notes_response', $order_notes, $id, $fields, $notes, $this->server ) ); + } + + /** + * Helper method to get order post objects + * + * @since 2.1 + * @param array $args request arguments for filtering query + * @return WP_Query + */ + private function query_orders( $args ) { + + // set base query arguments + $query_args = array( + 'fields' => 'ids', + 'post_type' => 'shop_order', + 'post_status' => 'publish', + ); + + // add status argument + if ( ! empty( $args['status'] ) ) { + + $statuses = explode( ',', $args['status'] ); + + $query_args['tax_query'] = array( + array( + 'taxonomy' => 'shop_order_status', + 'field' => 'slug', + 'terms' => $statuses, + ), + ); + + unset( $args['status'] ); + } + + $query_args = $this->merge_query_args( $query_args, $args ); + + return new WP_Query( $query_args ); + } + +} diff --git a/includes/api/class-wc-api-products.php b/includes/api/class-wc-api-products.php new file mode 100644 index 00000000000..aad0bd933fd --- /dev/null +++ b/includes/api/class-wc-api-products.php @@ -0,0 +1,541 @@ + + * GET /products//reviews + * + * @since 2.1 + * @param array $routes + * @return array + */ + public function register_routes( $routes ) { + + # GET /products + $routes[ $this->base ] = array( + array( array( $this, 'get_products' ), WC_API_Server::READABLE ), + ); + + # GET /products/count + $routes[ $this->base . '/count'] = array( + array( array( $this, 'get_products_count' ), WC_API_Server::READABLE ), + ); + + # GET|PUT|DELETE /products/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'get_product' ), WC_API_Server::READABLE ), + array( array( $this, 'edit_product' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'delete_product' ), WC_API_Server::DELETABLE ), + ); + + # GET /products//reviews + $routes[ $this->base . '/(?P\d+)/reviews' ] = array( + array( array( $this, 'get_product_reviews' ), WC_API_Server::READABLE ), + ); + + return $routes; + } + + /** + * Get all products + * + * @since 2.1 + * @param string $fields + * @param string $type + * @param array $filter + * @param int $page + * @return array + */ + public function get_products( $fields = null, $type = null, $filter = array(), $page = 1 ) { + + if ( ! empty( $type ) ) + $filter['type'] = $type; + + $filter['page'] = $page; + + $query = $this->query_products( $filter ); + + $products = array(); + + foreach( $query->posts as $product_id ) { + + if ( ! $this->is_readable( $product_id ) ) + continue; + + $products[] = $this->get_product( $product_id, $fields ); + } + + $this->server->add_pagination_headers( $query ); + + return array( 'products' => $products ); + } + + /** + * Get the product for the given ID + * + * @since 2.1 + * @param int $id the product ID + * @param string $fields + * @return array + */ + public function get_product( $id, $fields = null ) { + + $id = $this->validate_request( $id, 'product', 'read' ); + + if ( is_wp_error( $id ) ) + return $id; + + $product = get_product( $id ); + + // add data that applies to every product type + $product_data = $this->get_product_data( $product ); + + // add variations to variable products + if ( $product->is_type( 'variable' ) && $product->has_child() ) { + + $product_data['variations'] = $this->get_variation_data( $product ); + } + + // add the parent product data to an individual variation + if ( $product->is_type( 'variation' ) ) { + + $product_data['parent'] = $this->get_product_data( $product->parent ); + } + + return apply_filters( 'woocommerce_api_product_response', $product_data, $product, $fields, $this->server ); + } + + /** + * Get the total number of orders + * + * @since 2.1 + * @param string $type + * @param array $filter + * @return array + */ + public function get_products_count( $type = null, $filter = array() ) { + + if ( ! empty( $type ) ) + $filter['type'] = $type; + + // TODO: permissions? + + $query = $this->query_products( $filter ); + + return array( 'count' => $query->found_posts ); + } + + /** + * Edit a product + * + * @since 2.1 + * @param int $id the product ID + * @param array $data + * @return array + */ + public function edit_product( $id, $data ) { + + $id = $this->validate_request( $id, 'product', 'edit' ); + + if ( is_wp_error( $id ) ) + return $id; + + // TODO: implement + + return $this->get_product( $id ); + } + + /** + * Delete a product + * + * @since 2.1 + * @param int $id the product ID + * @param bool $force true to permanently delete order, false to move to trash + * @return array + */ + public function delete_product( $id, $force = false ) { + + $id = $this->validate_request( $id, 'product', 'delete' ); + + if ( is_wp_error( $id ) ) + return $id; + + return $this->delete( $id, 'product', ( 'true' === $force ) ); + } + + /** + * Get the reviews for a product + * + * @since 2.1 + * @param int $id the product ID to get reviews for + * @param string $fields fields to include in response + * @return array + */ + public function get_product_reviews( $id, $fields = null ) { + + $id = $this->validate_request( $id, 'product', 'read' ); + + if ( is_wp_error( $id ) ) + return $id; + + $args = array( + 'post_id' => $id, + 'approve' => 'approve', + ); + + $comments = get_comments( $args ); + + $reviews = array(); + + foreach ( $comments as $comment ) { + + $reviews[] = array( + 'id' => $comment->comment_ID, + 'created_at' => $this->server->format_datetime( $comment->comment_date_gmt ), + 'review' => $comment->comment_content, + 'rating' => get_comment_meta( $comment->comment_ID, 'rating', true ), + 'reviewer_name' => $comment->comment_author, + 'reviewer_email' => $comment->comment_author_email, + 'verified' => (bool) woocommerce_customer_bought_product( $comment->comment_author_email, $comment->user_id, $id ), + ); + } + + return array( 'product_reviews' => apply_filters( 'woocommerce_api_product_reviews_response', $reviews, $id, $fields, $comments, $this->server ) ); + } + + /** + * Helper method to get product post objects + * + * @since 2.1 + * @param array $args request arguments for filtering query + * @return WP_Query + */ + private function query_products( $args ) { + + // set base query arguments + $query_args = array( + 'fields' => 'ids', + 'post_type' => 'product', + 'post_status' => 'publish', + 'post_parent' => 0, + 'meta_query' => array(), + ); + + if ( ! empty( $args['type'] ) ) { + + $types = explode( ',', $args['type'] ); + + $query_args['tax_query'] = array( + array( + 'taxonomy' => 'product_type', + 'field' => 'slug', + 'terms' => $types, + ), + ); + + unset( $args['type'] ); + } + + $query_args = $this->merge_query_args( $query_args, $args ); + + return new WP_Query( $query_args ); + } + + /** + * Get standard product data that applies to every product type + * + * @since 2.1 + * @param WC_Product $product + * @return array + */ + private function get_product_data( $product ) { + + return array( + 'title' => $product->get_title(), + 'id' => (int) $product->is_type( 'variation' ) ? $product->get_variation_id() : $product->id, + 'created_at' => $this->server->format_datetime( $product->get_post_data()->post_date_gmt ), + 'updated_at' => $this->server->format_datetime( $product->get_post_data()->post_modified_gmt ), + 'type' => $product->product_type, + 'status' => $product->get_post_data()->post_status, + 'downloadable' => $product->is_downloadable(), + 'virtual' => $product->is_virtual(), + 'permalink' => $product->get_permalink(), + 'sku' => $product->get_sku(), + 'price' => woocommerce_format_decimal( $product->get_price() ), + 'regular_price' => woocommerce_format_decimal( $product->get_regular_price() ), + 'sale_price' => $product->get_sale_price() ? woocommerce_format_decimal( $product->get_sale_price() ) : null, + 'price_html' => $product->get_price_html(), + 'taxable' => $product->is_taxable(), + 'tax_status' => $product->get_tax_status(), + 'tax_class' => $product->get_tax_class(), + 'managing_stock' => $product->managing_stock(), + 'stock_quantity' => (int) $product->get_stock_quantity(), + 'in_stock' => $product->is_in_stock(), + 'backorders_allowed' => $product->backorders_allowed(), + 'backordered' => $product->is_on_backorder(), + 'sold_individually' => $product->is_sold_individually(), + 'purchaseable' => $product->is_purchasable(), + 'featured' => $product->is_featured(), + 'visible' => $product->is_visible(), + 'catalog_visibility' => $product->visibility, + 'on_sale' => $product->is_on_sale(), + 'weight' => $product->get_weight() ? woocommerce_format_decimal( $product->get_weight() ) : null, + 'dimensions' => array( + 'length' => $product->length, + 'width' => $product->width, + 'height' => $product->height, + 'unit' => get_option( 'woocommerce_dimension_unit' ), + ), + 'shipping_required' => $product->needs_shipping(), + 'shipping_taxable' => $product->is_shipping_taxable(), + 'shipping_class' => $product->get_shipping_class(), + 'shipping_class_id' => ( 0 !== $product->get_shipping_class_id() ) ? $product->get_shipping_class_id() : null, + 'description' => apply_filters( 'the_content', $product->get_post_data()->post_content ), + 'short_description' => apply_filters( 'woocommerce_short_description', $product->get_post_data()->post_excerpt ), + 'reviews_allowed' => ( 'open' === $product->get_post_data()->comment_status ), + 'average_rating' => woocommerce_format_decimal( $product->get_average_rating() ), + 'rating_count' => (int) $product->get_rating_count(), + 'related_ids' => array_map( 'absint', array_values( $product->get_related() ) ), + 'upsell_ids' => array_map( 'absint', $product->get_upsells() ), + 'cross_sell_ids' => array_map( 'absint', $product->get_cross_sells() ), + 'categories' => wp_get_post_terms( $product->id, 'product_cat', array( 'fields' => 'names' ) ), + 'tags' => wp_get_post_terms( $product->id, 'product_tag', array( 'fields' => 'names' ) ), + 'images' => $this->get_images( $product ), + 'attributes' => $this->get_attributes( $product ), + 'downloads' => $this->get_downloads( $product ), + 'download_limit' => (int) $product->download_limit, + 'download_expiry' => (int) $product->download_expiry, + 'download_type' => $product->download_type, + 'purchase_note' => apply_filters( 'the_content', $product->purchase_note ), + 'variations' => array(), + 'parent' => array(), + ); + } + + /** + * Get an individual variation's data + * + * @since 2.1 + * @param WC_Product $product + * @return array + */ + private function get_variation_data( $product ) { + + $variations = array(); + + foreach ( $product->get_children() as $child_id ) { + + $variation = $product->get_child( $child_id ); + + if ( ! $variation->exists() ) + continue; + + $variations[] = array( + 'id' => $variation->get_variation_id(), + 'created_at' => $this->server->format_datetime( $variation->get_post_data()->post_date_gmt ), + 'updated_at' => $this->server->format_datetime( $variation->get_post_data()->post_modified_gmt ), + 'downloadable' => $variation->is_downloadable(), + 'virtual' => $variation->is_virtual(), + 'permalink' => $variation->get_permalink(), + 'sku' => $variation->get_sku(), + 'price' => woocommerce_format_decimal( $variation->get_price() ), + 'regular_price' => woocommerce_format_decimal( $variation->get_regular_price() ), + 'sale_price' => $variation->get_sale_price() ? woocommerce_format_decimal( $variation->get_sale_price() ) : null, + 'taxable' => $variation->is_taxable(), + 'tax_status' => $variation->get_tax_status(), + 'tax_class' => $variation->get_tax_class(), + 'stock_quantity' => (int) $variation->get_stock_quantity(), + 'in_stock' => $variation->is_in_stock(), + 'backordered' => $variation->is_on_backorder(), + 'purchaseable' => $variation->is_purchasable(), + 'visible' => $variation->variation_is_visible(), + 'on_sale' => $variation->is_on_sale(), + 'weight' => $variation->get_weight() ? woocommerce_format_decimal( $variation->get_weight() ) : null, + 'dimensions' => array( + 'length' => $variation->length, + 'width' => $variation->width, + 'height' => $variation->height, + 'unit' => get_option( 'woocommerce_dimension_unit' ), + ), + 'shipping_class' => $variation->get_shipping_class(), + 'shipping_class_id' => ( 0 !== $variation->get_shipping_class_id() ) ? $variation->get_shipping_class_id() : null, + 'image' => $this->get_images( $variation ), + 'attributes' => $this->get_attributes( $variation ), + 'downloads' => $this->get_downloads( $variation ), + 'download_limit' => (int) $product->download_limit, + 'download_expiry' => (int) $product->download_expiry, + ); + } + + return $variations; + } + + /** + * Get the images for a product or product variation + * + * @since 2.1 + * @param WC_Product|WC_Product_Variation $product + * @return array + */ + private function get_images( $product ) { + + $images = $attachment_ids = array(); + + if ( $product->is_type( 'variation' ) ) { + + if ( has_post_thumbnail( $product->get_variation_id() ) ) { + + // add variation image if set + $attachment_ids[] = get_post_thumbnail_id( $product->get_variation_id() ); + + } elseif ( has_post_thumbnail( $product->id ) ) { + + // otherwise use the parent product featured image if set + $attachment_ids[] = get_post_thumbnail_id( $product->id ); + } + + } else { + + // add featured image + if ( has_post_thumbnail( $product->id ) ) { + $attachment_ids[] = get_post_thumbnail_id( $product->id ); + } + + // add gallery images + $attachment_ids = array_merge( $attachment_ids, $product->get_gallery_attachment_ids() ); + } + + // build image data + foreach ( $attachment_ids as $position => $attachment_id ) { + + $attachment_post = get_post( $attachment_id ); + + if ( is_null( $attachment_post ) ) + continue; + + $attachment = wp_get_attachment_image_src( $attachment_id, 'full' ); + + if ( ! is_array( $attachment ) ) + continue; + + $images[] = array( + 'id' => (int) $attachment_id, + 'created_at' => $this->server->format_datetime( $attachment_post->post_date_gmt ), + 'updated_at' => $this->server->format_datetime( $attachment_post->post_modified_gmt ), + 'src' => current( $attachment ), + 'title' => get_the_title( $attachment_id ), + 'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), + 'position' => $position, + ); + } + + // set a placeholder image if the product has no images set + if ( empty( $images ) ) { + + $images[] = array( + 'id' => 0, + 'created_at' => $this->server->format_datetime( time() ), // default to now + 'updated_at' => $this->server->format_datetime( time() ), + 'src' => woocommerce_placeholder_img_src(), + 'title' => __( 'Placeholder', 'woocommerce' ), + 'alt' => __( 'Placeholder', 'woocommerce' ), + 'position' => 0, + ); + } + + return $images; + } + + /** + * Get the attributes for a product or product variation + * + * @since 2.1 + * @param WC_Product|WC_Product_Variation $product + * @return array + */ + private function get_attributes( $product ) { + + $attributes = array(); + + if ( $product->is_type( 'variation' ) ) { + + // variation attributes + foreach ( $product->get_variation_attributes() as $attribute_name => $attribute ) { + + // taxonomy-based attributes are prefixed with `pa_`, otherwise simply `attribute_` + $attributes[] = array( + 'name' => ucwords( str_replace( 'attribute_', '', str_replace( 'pa_', '', $attribute_name ) ) ), + 'option' => $attribute, + ); + } + + } else { + + foreach ( $product->get_attributes() as $attribute ) { + + // taxonomy-based attributes are comma-separated, others are pipe (|) separated + if ( $attribute['is_taxonomy'] ) + $options = explode( ',', $product->get_attribute( $attribute['name'] ) ); + else + $options = explode( '|', $product->get_attribute( $attribute['name'] ) ); + + $attributes[] = array( + 'name' => ucwords( str_replace( 'pa_', '', $attribute['name'] ) ), + 'position' => $attribute['position'], + 'visible' => (bool) $attribute['is_visible'], + 'variation' => (bool) $attribute['is_variation'], + 'options' => array_map( 'trim', $options ), + ); + } + } + + return $attributes; + } + + /** + * Get the downloads for a product or product variation + * + * @since 2.1 + * @param WC_Product|WC_Product_Variation $product + * @return array + */ + private function get_downloads( $product ) { + + $downloads = array(); + + if ( $product->is_downloadable() ) { + + foreach ( $product->get_files() as $file_id => $file ) { + + $downloads[] = array( + 'id' => $file_id, // do not cast as int as this is a hash + 'name' => $file['name'], + 'file' => $file['file'], + ); + } + } + + return $downloads; + } + +} diff --git a/includes/api/class-wc-api-reports.php b/includes/api/class-wc-api-reports.php new file mode 100644 index 00000000000..a1939a9b1bb --- /dev/null +++ b/includes/api/class-wc-api-reports.php @@ -0,0 +1,385 @@ +base ] = array( + array( array( $this, 'get_reports' ), WC_API_Server::READABLE ), + ); + + # GET /reports/sales + $routes[ $this->base . '/sales'] = array( + array( array( $this, 'get_sales_report' ), WC_API_Server::READABLE ), + ); + + return $routes; + } + + /** + * Get a simple listing of available reports + * + * @since 2.1 + * @return array + */ + public function get_reports() { + + return array( 'reports' => array( 'sales' ) ); + } + + /** + * Get the sales report + * + * @since 2.1 + * @param string $fields fields to include in response + * @param array $filter date filtering + * @return array + */ + public function get_sales_report( $fields = null, $filter = array() ) { + + // check user permissions + $check = $this->validate_request(); + + if ( is_wp_error( $check ) ) + return $check; + + // set date filtering + $this->setup_report( $filter ); + + // total sales, taxes, shipping, and order count + $totals = $this->report->get_order_report_data( array( + 'data' => array( + '_order_total' => array( + 'type' => 'meta', + 'function' => 'SUM', + 'name' => 'sales' + ), + '_order_tax' => array( + 'type' => 'meta', + 'function' => 'SUM', + 'name' => 'tax' + ), + '_order_shipping_tax' => array( + 'type' => 'meta', + 'function' => 'SUM', + 'name' => 'shipping_tax' + ), + '_order_shipping' => array( + 'type' => 'meta', + 'function' => 'SUM', + 'name' => 'shipping' + ), + 'ID' => array( + 'type' => 'post_data', + 'function' => 'COUNT', + 'name' => 'order_count' + ) + ), + 'filter_range' => true, + ) ); + + // total items ordered + $total_items = absint( $this->report->get_order_report_data( array( + 'data' => array( + '_qty' => array( + 'type' => 'order_item_meta', + 'order_item_type' => 'line_item', + 'function' => 'SUM', + 'name' => 'order_item_qty' + ) + ), + 'query_type' => 'get_var', + 'filter_range' => true, + ) ) ); + + // total discount used + $total_discount = $this->report->get_order_report_data( array( + 'data' => array( + 'discount_amount' => array( + 'type' => 'order_item_meta', + 'order_item_type' => 'coupon', + 'function' => 'SUM', + 'name' => 'discount_amount' + ) + ), + 'where' => array( + array( + 'key' => 'order_item_type', + 'value' => 'coupon', + 'operator' => '=' + ) + ), + 'query_type' => 'get_var', + 'filter_range' => true, + ) ); + + // get order totals grouped by period + $orders = $this->report->get_order_report_data( array( + 'data' => array( + '_order_total' => array( + 'type' => 'meta', + 'function' => 'SUM', + 'name' => 'total_sales' + ), + '_order_shipping' => array( + 'type' => 'meta', + 'function' => 'SUM', + 'name' => 'total_shipping' + ), + '_order_tax' => array( + 'type' => 'meta', + 'function' => 'SUM', + 'name' => 'total_tax' + ), + '_order_shipping_tax' => array( + 'type' => 'meta', + 'function' => 'SUM', + 'name' => 'total_shipping_tax' + ), + 'ID' => array( + 'type' => 'post_data', + 'function' => 'COUNT', + 'name' => 'total_orders', + 'distinct' => true, + ), + 'post_date' => array( + 'type' => 'post_data', + 'function' => '', + 'name' => 'post_date' + ), + ), + 'group_by' => $this->report->group_by_query, + 'order_by' => 'post_date ASC', + 'query_type' => 'get_results', + 'filter_range' => true, + ) ); + + // get order item totals grouped by period + $order_items = $this->report->get_order_report_data( array( + 'data' => array( + '_qty' => array( + 'type' => 'order_item_meta', + 'order_item_type' => 'line_item', + 'function' => 'SUM', + 'name' => 'order_item_count' + ), + 'post_date' => array( + 'type' => 'post_data', + 'function' => '', + 'name' => 'post_date' + ), + ), + 'where' => array( + array( + 'key' => 'order_item_type', + 'value' => 'line_item', + 'operator' => '=' + ) + ), + 'group_by' => $this->report->group_by_query, + 'order_by' => 'post_date ASC', + 'query_type' => 'get_results', + 'filter_range' => true, + ) ); + + // get discount totals grouped by period + $discounts = $this->report->get_order_report_data( array( + 'data' => array( + 'discount_amount' => array( + 'type' => 'order_item_meta', + 'order_item_type' => 'coupon', + 'function' => 'SUM', + 'name' => 'discount_amount' + ), + 'post_date' => array( + 'type' => 'post_data', + 'function' => '', + 'name' => 'post_date' + ), + ), + 'where' => array( + array( + 'key' => 'order_item_type', + 'value' => 'coupon', + 'operator' => '=' + ) + ), + 'group_by' => $this->report->group_by_query . ', order_item_name', + 'order_by' => 'post_date ASC', + 'query_type' => 'get_results', + 'filter_range' => true, + ) ); + + $period_totals = array(); + + // setup period totals by ensuring each period in the interval has data + for ( $i = 0; $i <= $this->report->chart_interval; $i ++ ) { + + switch ( $this->report->chart_groupby ) { + case 'day' : + $time = date( 'Y-m-d', strtotime( "+{$i} DAY", $this->report->start_date ) ); + break; + case 'month' : + $time = date( 'Y-m', strtotime( "+{$i} MONTH", $this->report->start_date ) ); + break; + } + + $period_totals[ $time ] = array( + 'sales' => woocommerce_format_decimal( 0.00 ), + 'orders' => 0, + 'items' => 0, + 'tax' => woocommerce_format_decimal( 0.00 ), + 'shipping' => woocommerce_format_decimal( 0.00 ), + 'discount' => woocommerce_format_decimal( 0.00 ), + ); + } + + // add total sales, total order count, total tax and total shipping for each period + foreach ( $orders as $order ) { + + $time = ( 'day' === $this->report->chart_groupby ) ? date( 'Y-m-d', strtotime( $order->post_date ) ) : date( 'Y-m', strtotime( $order->post_date ) ); + + if ( ! isset( $period_totals[ $time ] ) ) + continue; + + $period_totals[ $time ]['sales'] = woocommerce_format_decimal( $order->total_sales ); + $period_totals[ $time ]['orders'] = (int) $order->total_orders; + $period_totals[ $time ]['tax'] = woocommerce_format_decimal( $order->total_tax + $order->total_shipping_tax ); + $period_totals[ $time ]['shipping'] = woocommerce_format_decimal( $order->total_shipping ); + } + + // add total order items for each period + foreach ( $order_items as $order_item ) { + + $time = ( 'day' === $this->report->chart_groupby ) ? date( 'Y-m-d', strtotime( $order_item->post_date ) ) : date( 'Y-m', strtotime( $order_item->post_date ) ); + + if ( ! isset( $period_totals[ $time ] ) ) + continue; + + $period_totals[ $time ]['items'] = (int) $order_item->order_item_count; + } + + // add total discount for each period + foreach ( $discounts as $discount ) { + + $time = ( 'day' === $this->report->chart_groupby ) ? date( 'Y-m-d', strtotime( $discount->post_date ) ) : date( 'Y-m', strtotime( $discount->post_date ) ); + + if ( ! isset( $period_totals[ $time ] ) ) + continue; + + $period_totals[ $time ]['discount'] = woocommerce_format_decimal( $discount->discount_amount ); + } + + $sales_data = array( + 'sales' => woocommerce_format_decimal( $totals->sales ), + 'average' => woocommerce_format_decimal( $totals->sales / ( $this->report->chart_interval + 1 ) ), + 'orders' => (int) $totals->order_count, + 'items' => $total_items, + 'tax' => woocommerce_format_decimal( $totals->tax + $totals->shipping_tax ), + 'shipping' => woocommerce_format_decimal( $totals->shipping ), + 'discount' => is_null( $total_discount ) ? woocommerce_format_decimal( 0.00 ) : woocommerce_format_decimal( $total_discount ), + 'totals_grouped_by' => $this->report->chart_groupby, + 'totals' => $period_totals, + ); + + return array( 'sales' => apply_filters( 'woocommerce_api_report_response', $sales_data, 'sales', $fields, $this->report, $this->server ) ); + } + + /** + * Setup the report object and parse any date filtering + * + * @since 2.1 + * @param array $filter date filtering + */ + private function setup_report( $filter ) { + + include_once( WC()->plugin_path() . '/includes/admin/reports/class-wc-admin-report.php' ); + + $this->report = new WC_Admin_Report(); + + if ( empty( $filter['period'] ) ) { + + // custom date range + $filter['period'] = 'custom'; + + if ( ! empty( $filter['date_min'] ) || ! empty( $filter['date_max'] ) ) { + + // overwrite _GET to make use of WC_Admin_Report::calculate_current_range() for custom date ranges + $_GET['start_date'] = $this->server->parse_datetime( $filter['date_min'] ); + $_GET['end_date'] = isset( $filter['date_max'] ) ? $this->server->parse_datetime( $filter['date_max'] ) : null; + + } else { + + // default custom range to today + $_GET['start_date'] = $_GET['end_date'] = date( 'Y-m-d', current_time( 'timestamp' ) ); + } + + } else { + + // ensure period is valid + if ( ! in_array( $filter['period'], array( 'week', 'month', 'last_month', 'year' ) ) ) { + $filter['period'] = 'week'; + } + + // TODO: change WC_Admin_Report class to use "week" instead, as it's more consistent with other periods + // allow "week" for period instead of "7day" + if ( 'week' === $filter['period'] ) { + $filter['period'] = '7day'; + } + } + + $this->report->calculate_current_range( $filter['period'] ); + } + + /** + * Verify that the current user has permission to view reports + * + * @since 2.1 + * @see WC_API_Resource::validate_request() + * @param null $id unused + * @param null $type unused + * @param null $context unused + * @return bool true if the request is valid and should be processed, false otherwise + */ + protected function validate_request( $id = null, $type = null, $context = null ) { + + if ( ! current_user_can( 'view_woocommerce_reports' ) ) { + + return new WP_Error( 'woocommerce_api_user_cannot_read_report', __( 'You do not have permission to read this report', 'woocommerce' ), array( 'status' => 401 ) ); + + } else { + + return true; + } + } +} diff --git a/includes/api/class-wc-api-resource.php b/includes/api/class-wc-api-resource.php new file mode 100644 index 00000000000..770b62faded --- /dev/null +++ b/includes/api/class-wc-api-resource.php @@ -0,0 +1,399 @@ +server = $server; + + // automatically register routes for sub-classes + add_filter( 'woocommerce_api_endpoints', array( $this, 'register_routes' ) ); + + // remove fields from responses when requests specify certain fields + // note these are hooked at a later priority so data added via filters (e.g. customer data to the order response) + // still has the fields filtered properly + foreach ( array( 'order', 'coupon', 'customer', 'product', 'report' ) as $resource ) { + + add_filter( "woocommerce_api_{$resource}_response", array( $this, 'maybe_add_meta' ), 15, 2 ); + add_filter( "woocommerce_api_{$resource}_response", array( $this, 'filter_response_fields' ), 20, 3 ); + } + } + + /** + * Validate the request by checking: + * + * 1) the ID is a valid integer + * 2) the ID returns a valid post object and matches the provided post type + * 3) the current user has the proper permissions to read/edit/delete the post + * + * @since 2.1 + * @param string|int $id the post ID + * @param string $type the post type, either `shop_order`, `shop_coupon`, or `product` + * @param string $context the context of the request, either `read`, `edit` or `delete` + * @return int|WP_Error valid post ID or WP_Error if any of the checks fails + */ + protected function validate_request( $id, $type, $context ) { + + if ( 'shop_order' === $type || 'shop_coupon' === $type ) + $resource_name = str_replace( 'shop_', '', $type ); + else + $resource_name = $type; + + $id = absint( $id ); + + // validate ID + if ( empty( $id ) ) + return new WP_Error( "woocommerce_api_invalid_{$resource_name}_id", sprintf( __( 'Invalid %s ID', 'woocommerce' ), $type ), array( 'status' => 404 ) ); + + // only custom post types have per-post type/permission checks + if ( 'customer' !== $type ) { + + $post = get_post( $id, ARRAY_A ); + + // for checking permissions, product variations are the same as the product post type + $post_type = ( 'product_variation' === $post['post_type'] ) ? 'product' : $post['post_type']; + + // validate post type + if ( $type !== $post_type ) + return new WP_Error( "woocommerce_api_invalid_{$resource_name}", sprintf( __( 'Invalid %s', 'woocommerce' ), $resource_name ), array( 'status' => 404 ) ); + + // validate permissions + switch ( $context ) { + + case 'read': + if ( ! $this->is_readable( $post ) ) + return new WP_Error( "woocommerce_api_user_cannot_read_{$resource_name}", sprintf( __( 'You do not have permission to read this %s', 'woocommerce' ), $resource_name ), array( 'status' => 401 ) ); + break; + + case 'edit': + if ( ! $this->is_editable( $post ) ) + return new WP_Error( "woocommerce_api_user_cannot_edit_{$resource_name}", sprintf( __( 'You do not have permission to edit this %s', 'woocommerce' ), $resource_name ), array( 'status' => 401 ) ); + break; + + case 'delete': + if ( ! $this->is_deletable( $post ) ) + return new WP_Error( "woocommerce_api_user_cannot_delete_{$resource_name}", sprintf( __( 'You do not have permission to delete this %s', 'woocommerce' ), $resource_name ), array( 'status' => 401 ) ); + break; + } + } + + return $id; + } + + /** + * Add common request arguments to argument list before WP_Query is run + * + * @since 2.1 + * @param array $base_args required arguments for the query (e.g. `post_type`, etc) + * @param array $request_args arguments provided in the request + * @return array + */ + protected function merge_query_args( $base_args, $request_args ) { + + $args = array(); + + // date + if ( ! empty( $request_args['created_at_min'] ) || ! empty( $request_args['created_at_max'] ) || ! empty( $request_args['updated_at_min'] ) || ! empty( $request_args['updated_at_max'] ) ) { + + $args['date_query'] = array(); + + // resources created after specified date + if ( ! empty( $request_args['created_at_min'] ) ) + $args['date_query'][] = array( 'column' => 'post_date_gmt', 'after' => $this->server->parse_datetime( $request_args['created_at_min'] ), 'inclusive' => true ); + + // resources created before specified date + if ( ! empty( $request_args['created_at_max'] ) ) + $args['date_query'][] = array( 'column' => 'post_date_gmt', 'before' => $this->server->parse_datetime( $request_args['created_at_max'] ), 'inclusive' => true ); + + // resources updated after specified date + if ( ! empty( $request_args['updated_at_min'] ) ) + $args['date_query'][] = array( 'column' => 'post_modified_gmt', 'after' => $this->server->parse_datetime( $request_args['updated_at_min'] ), 'inclusive' => true ); + + // resources updated before specified date + if ( ! empty( $request_args['updated_at_max'] ) ) + $args['date_query'][] = array( 'column' => 'post_modified_gmt', 'before' => $this->server->parse_datetime( $request_args['updated_at_max'] ), 'inclusive' => true ); + } + + // search + if ( ! empty( $request_args['q'] ) ) + $args['s'] = $request_args['q']; + + // resources per response + if ( ! empty( $request_args['limit'] ) ) + $args['posts_per_page'] = $request_args['limit']; + + // resource offset + if ( ! empty( $request_args['offset'] ) ) + $args['offset'] = $request_args['offset']; + + // resource page + $args['paged'] = absint( $request_args['page'] ); + + return array_merge( $base_args, $args ); + } + + /** + * Add meta to resources when requested by the client. Meta is added as a top-level + * `_meta` attribute (e.g. `order_meta`) as a list of key/value pairs + * + * @since 2.1 + * @param array $data the resource data + * @param object $resource the resource object (e.g WC_Order) + * @return mixed + */ + public function maybe_add_meta( $data, $resource ) { + + if ( isset( $this->server->params['GET']['filter']['meta'] ) && 'true' === $this->server->params['GET']['filter']['meta'] && is_object( $resource ) ) { + + // don't attempt to add meta more than once + if ( preg_grep( '/[a-z]+_meta/', array_keys( $data ) ) ) + return $data; + + // define the top-level property name for the meta + switch ( get_class( $resource ) ) { + + case 'WC_Order': + $meta_name = 'order_meta'; + break; + + case 'WC_Coupon': + $meta_name = 'coupon_meta'; + break; + + case 'WP_User': + $meta_name = 'customer_meta'; + break; + + default: + $meta_name = 'product_meta'; + break; + } + + if ( is_a( $resource, 'WP_User' ) ) { + + // customer meta + $meta = (array) get_user_meta( $resource->ID ); + + } elseif ( is_a( $resource, 'WC_Product_Variation' ) ) { + + // product variation meta + $meta = (array) get_post_meta( $resource->get_variation_id() ); + + } else { + + // coupon/order/product meta + $meta = (array) get_post_meta( $resource->id ); + } + + foreach( $meta as $meta_key => $meta_value ) { + + // don't add hidden meta by default + if ( ! is_protected_meta( $meta_key ) ) { + $data[ $meta_name ][ $meta_key ] = maybe_unserialize( $meta_value[0] ); + } + } + + } + + return $data; + } + + /** + * Restrict the fields included in the response if the request specified certain only certain fields should be returned + * + * @since 2.1 + * @param array $data the response data + * @param object $resource the object that provided the response data, e.g. WC_Coupon or WC_Order + * @param array|string the requested list of fields to include in the response + * @return array response data + */ + public function filter_response_fields( $data, $resource, $fields ) { + + if ( empty( $fields ) ) + return $data; + + $fields = explode( ',', $fields ); + $sub_fields = array(); + + // get sub fields + foreach ( $fields as $field ) { + + if ( false !== strpos( $field, '.' ) ) { + + list( $name, $value ) = explode( '.', $field ); + + $sub_fields[ $name ] = $value; + } + } + + // iterate through top-level fields + foreach ( $data as $data_field => $data_value ) { + + // if a field has sub-fields and the top-level field has sub-fields to filter + if ( is_array( $data_value ) && in_array( $data_field, array_keys( $sub_fields ) ) ) { + + // iterate through each sub-field + foreach ( $data_value as $sub_field => $sub_field_value ) { + + // remove non-matching sub-fields + if ( ! in_array( $sub_field, $sub_fields ) ) { + unset( $data[ $data_field ][ $sub_field ] ); + } + } + + } else { + + // remove non-matching top-level fields + if ( ! in_array( $data_field, $fields ) ) { + unset( $data[ $data_field ] ); + } + } + } + + return $data; + } + + /** + * Delete a given resource + * + * @since 2.1 + * @param int $id the resource ID + * @param string $type the resource post type, or `customer` + * @param bool $force true to permanently delete resource, false to move to trash (not supported for `customer`) + * @return array|WP_Error + */ + protected function delete( $id, $type, $force = false ) { + + if ( 'shop_order' === $type || 'shop_coupon' === $type ) + $resource_name = str_replace( 'shop_', '', $type ); + else + $resource_name = $type; + + if ( 'customer' === $type ) { + + $result = wp_delete_user( $id ); + + if ( $result ) + return array( 'message' => __( 'Permanently deleted customer', 'woocommerce' ) ); + else + return new WP_Error( 'woocommerce_api_cannot_delete_customer', __( 'The customer cannot be deleted', 'woocommerce' ), array( 'status' => 500 ) ); + + } else { + + // delete order/coupon/product + + $result = ( $force ) ? wp_delete_post( $id, true ) : wp_trash_post( $id ); + + if ( ! $result ) + return new WP_Error( "woocommerce_api_cannot_delete_{$resource_name}", sprintf( __( 'This %s cannot be deleted', 'woocommerce' ), $resource_name ), array( 'status' => 500 ) ); + + if ( $force ) { + return array( 'message' => sprintf( __( 'Permanently deleted %s', 'woocommerce' ), $resource_name ) ); + + } else { + + $this->server->send_status( '202' ); + + return array( 'message' => sprintf( __( 'Deleted %s', 'woocommerce' ), $resource_name ) ); + } + } + } + + + /** + * Checks if the given post is readable by the current user + * + * @since 2.1 + * @see WC_API_Resource::check_permission() + * @param WP_Post|int $post + * @return bool + */ + protected function is_readable( $post ) { + + return $this->check_permission( $post, 'read' ); + } + + /** + * Checks if the given post is editable by the current user + * + * @since 2.1 + * @see WC_API_Resource::check_permission() + * @param WP_Post|int $post + * @return bool + */ + protected function is_editable( $post ) { + + return $this->check_permission( $post, 'edit' ); + + } + + /** + * Checks if the given post is deletable by the current user + * + * @since 2.1 + * @see WC_API_Resource::check_permission() + * @param WP_Post|int $post + * @return bool + */ + protected function is_deletable( $post ) { + + return $this->check_permission( $post, 'delete' ); + } + + /** + * Checks the permissions for the current user given a post and context + * + * @since 2.1 + * @param WP_Post|int $post + * @param string $context the type of permission to check, either `read`, `write`, or `delete` + * @return bool true if the current user has the permissions to perform the context on the post + */ + private function check_permission( $post, $context ) { + + if ( ! is_a( $post, 'WP_Post' ) ) + $post = get_post( $post, ARRAY_A ); + + if ( is_null( $post ) ) + return false; + + $post_type = get_post_type_object( $post['post_type'] ); + + if ( 'read' === $context ) + return current_user_can( $post_type->cap->read_post, $post['ID'] ); + + elseif ( 'edit' === $context ) + return current_user_can( $post_type->cap->edit_post, $post['ID'] ); + + elseif ( 'delete' === $context ) + return current_user_can( $post_type->cap->delete_post, $post['ID'] ); + + else + return false; + } + +} diff --git a/includes/api/class-wc-api-server.php b/includes/api/class-wc-api-server.php new file mode 100644 index 00000000000..c65ee4bb801 --- /dev/null +++ b/includes/api/class-wc-api-server.php @@ -0,0 +1,740 @@ + self::METHOD_GET, + 'GET' => self::METHOD_GET, + 'POST' => self::METHOD_POST, + 'PUT' => self::METHOD_PUT, + 'PATCH' => self::METHOD_PATCH, + 'DELETE' => self::METHOD_DELETE, + ); + + /** + * Requested path (relative to the API root, wp-json.php) + * + * @var string + */ + public $path = ''; + + /** + * Requested method (GET/HEAD/POST/PUT/PATCH/DELETE) + * + * @var string + */ + public $method = 'HEAD'; + + /** + * Request parameters + * + * This acts as an abstraction of the superglobals + * (GET => $_GET, POST => $_POST) + * + * @var array + */ + public $params = array( 'GET' => array(), 'POST' => array() ); + + /** + * Request headers + * + * @var array + */ + public $headers = array(); + + /** + * Request files (matches $_FILES) + * + * @var array + */ + public $files = array(); + + /** + * Request/Response handler, either JSON by default + * or XML if requested by client + * + * @var WC_API_Handler + */ + public $handler; + + + /** + * Setup class and set request/response handler + * + * @since 2.1 + * @param $path + * @return WC_API_Server + */ + public function __construct( $path ) { + + if ( empty( $path ) ) { + if ( isset( $_SERVER['PATH_INFO'] ) ) + $path = $_SERVER['PATH_INFO']; + else + $path = '/'; + } + + $this->path = $path; + $this->method = $_SERVER['REQUEST_METHOD']; + $this->params['GET'] = $_GET; + $this->params['POST'] = $_POST; + $this->headers = $this->get_headers( $_SERVER ); + $this->files = $_FILES; + + // Compatibility for clients that can't use PUT/PATCH/DELETE + if ( isset( $_GET['_method'] ) ) { + $this->method = strtoupper( $_GET['_method'] ); + } + + // determine type of request/response and load handler, JSON by default + if ( $this->is_json_request() ) + $handler_class = 'WC_API_JSON_Handler'; + + elseif ( $this->is_xml_request() ) + $handler_class = 'WC_API_XML_Handler'; + + else + $handler_class = apply_filters( 'woocommerce_api_default_response_handler', 'WC_API_JSON_Handler', $this->path, $this ); + + $this->handler = new $handler_class(); + } + + /** + * Check authentication for the request + * + * @since 2.1 + * @return WP_User|WP_Error WP_User object indicates successful login, WP_Error indicates unsuccessful login + */ + public function check_authentication() { + + // allow plugins to remove default authentication or add their own authentication + $user = apply_filters( 'woocommerce_api_check_authentication', null, $this ); + + // API requests run under the context of the authenticated user + if ( is_a( $user, 'WP_User' ) ) + wp_set_current_user( $user->ID ); + + // WP_Errors are handled in serve_request() + elseif ( ! is_wp_error( $user ) ) + $user = new WP_Error( 'woocommerce_api_authentication_error', __( 'Invalid authentication method', 'woocommerce' ), array( 'code' => '500' ) ); + + return $user; + } + + /** + * Convert an error to an array + * + * This iterates over all error codes and messages to change it into a flat + * array. This enables simpler client behaviour, as it is represented as a + * list in JSON rather than an object/map + * + * @since 2.1 + * @param WP_Error $error + * @return array List of associative arrays with code and message keys + */ + protected function error_to_array( $error ) { + $errors = array(); + foreach ( (array) $error->errors as $code => $messages ) { + foreach ( (array) $messages as $message ) { + $errors[] = array( 'code' => $code, 'message' => $message ); + } + } + return $errors; + } + + /** + * Handle serving an API request + * + * Matches the current server URI to a route and runs the first matching + * callback then outputs a JSON representation of the returned value. + * + * @since 2.1 + * @uses WC_API_Server::dispatch() + */ + public function serve_request() { + + do_action( 'woocommerce_api_server_before_serve', $this ); + + $this->header( 'Content-Type', $this->handler->get_content_type(), true ); + + // the API is enabled by default + if ( ! apply_filters( 'woocommerce_api_enabled', true, $this ) || ( 'no' === get_option( 'woocommerce_api_enabled' ) ) ) { + + $this->send_status( 404 ); + + echo $this->handler->generate_response( array( array( 'code' => 'woocommerce_api_disabled', 'message' => 'The WooCommerce API is disabled on this site' ) ) ); + + return; + } + + $result = $this->check_authentication(); + + // if authorization check was successful, dispatch the request + if ( ! is_wp_error( $result ) ) { + $result = $this->dispatch(); + } + + // handle any dispatch errors + if ( is_wp_error( $result ) ) { + $data = $result->get_error_data(); + if ( is_array( $data ) && isset( $data['status'] ) ) { + $this->send_status( $data['status'] ); + } + + $result = $this->error_to_array( $result ); + } + + // This is a filter rather than an action, since this is designed to be + // re-entrant if needed + $served = apply_filters( 'woocommerce_api_serve_request', false, $result, $this ); + + if ( ! $served ) { + + if ( 'HEAD' === $this->method ) + return; + + echo $this->handler->generate_response( $result ); + } + } + + /** + * Retrieve the route map + * + * The route map is an associative array with path regexes as the keys. The + * value is an indexed array with the callback function/method as the first + * item, and a bitmask of HTTP methods as the second item (see the class + * constants). + * + * Each route can be mapped to more than one callback by using an array of + * the indexed arrays. This allows mapping e.g. GET requests to one callback + * and POST requests to another. + * + * Note that the path regexes (array keys) must have @ escaped, as this is + * used as the delimiter with preg_match() + * + * @since 2.1 + * @return array `'/path/regex' => array( $callback, $bitmask )` or `'/path/regex' => array( array( $callback, $bitmask ), ...)` + */ + public function get_routes() { + + // index added by default + $endpoints = array( + + '/' => array( array( $this, 'get_index' ), self::READABLE ), + ); + + $endpoints = apply_filters( 'woocommerce_api_endpoints', $endpoints ); + + // Normalise the endpoints + foreach ( $endpoints as $route => &$handlers ) { + if ( count( $handlers ) <= 2 && isset( $handlers[1] ) && ! is_array( $handlers[1] ) ) { + $handlers = array( $handlers ); + } + } + + return $endpoints; + } + + /** + * Match the request to a callback and call it + * + * @since 2.1 + * @return mixed The value returned by the callback, or a WP_Error instance + */ + public function dispatch() { + + switch ( $this->method ) { + + case 'HEAD': + case 'GET': + $method = self::METHOD_GET; + break; + + case 'POST': + $method = self::METHOD_POST; + break; + + case 'PUT': + $method = self::METHOD_PUT; + break; + + case 'PATCH': + $method = self::METHOD_PATCH; + break; + + case 'DELETE': + $method = self::METHOD_DELETE; + break; + + default: + return new WP_Error( 'woocommerce_api_unsupported_method', __( 'Unsupported request method' ), array( 'status' => 400 ) ); + } + + foreach ( $this->get_routes() as $route => $handlers ) { + foreach ( $handlers as $handler ) { + $callback = $handler[0]; + $supported = isset( $handler[1] ) ? $handler[1] : self::METHOD_GET; + + if ( !( $supported & $method ) ) + continue; + + $match = preg_match( '@^' . $route . '$@i', urldecode( $this->path ), $args ); + + if ( !$match ) + continue; + + if ( ! is_callable( $callback ) ) + return new WP_Error( 'woocommerce_api_invalid_handler', __( 'The handler for the route is invalid' ), array( 'status' => 500 ) ); + + $args = array_merge( $args, $this->params['GET'] ); + if ( $method & self::METHOD_POST ) { + $args = array_merge( $args, $this->params['POST'] ); + } + if ( $supported & self::ACCEPT_DATA ) { + $data = $this->handler->parse_body( $this->get_raw_data() ); + $args = array_merge( $args, array( 'data' => $data ) ); + } + elseif ( $supported & self::ACCEPT_RAW_DATA ) { + $data = $this->get_raw_data(); + $args = array_merge( $args, array( 'data' => $data ) ); + } + + $args['_method'] = $method; + $args['_route'] = $route; + $args['_path'] = $this->path; + $args['_headers'] = $this->headers; + $args['_files'] = $this->files; + + $args = apply_filters( 'woocommerce_api_dispatch_args', $args, $callback ); + + // Allow plugins to halt the request via this filter + if ( is_wp_error( $args ) ) { + return $args; + } + + $params = $this->sort_callback_params( $callback, $args ); + if ( is_wp_error( $params ) ) + return $params; + + return call_user_func_array( $callback, $params ); + } + } + + return new WP_Error( 'woocommerce_api_no_route', __( 'No route was found matching the URL and request method' ), array( 'status' => 404 ) ); + } + + /** + * Sort parameters by order specified in method declaration + * + * Takes a callback and a list of available params, then filters and sorts + * by the parameters the method actually needs, using the Reflection API + * + * @since 2.1 + * @param array $callback the endpoint callback + * @param array $provided the provided request parameters + * @return array + */ + protected function sort_callback_params( $callback, $provided ) { + if ( is_array( $callback ) ) + $ref_func = new ReflectionMethod( $callback[0], $callback[1] ); + else + $ref_func = new ReflectionFunction( $callback ); + + $wanted = $ref_func->getParameters(); + $ordered_parameters = array(); + + foreach ( $wanted as $param ) { + if ( isset( $provided[ $param->getName() ] ) ) { + // We have this parameters in the list to choose from + + $ordered_parameters[] = is_array( $provided[ $param->getName() ] ) ? array_map( 'urldecode', $provided[ $param->getName() ] ) : urldecode( $provided[ $param->getName() ] ); + } + elseif ( $param->isDefaultValueAvailable() ) { + // We don't have this parameter, but it's optional + $ordered_parameters[] = $param->getDefaultValue(); + } + else { + // We don't have this parameter and it wasn't optional, abort! + return new WP_Error( 'woocommerce_api_missing_callback_param', sprintf( __( 'Missing parameter %s' ), $param->getName() ), array( 'status' => 400 ) ); + } + } + return $ordered_parameters; + } + + /** + * Get the site index. + * + * This endpoint describes the capabilities of the site. + * + * @since 2.1 + * @return array Index entity + */ + public function get_index() { + + // General site data + $available = array( + 'name' => get_option( 'blogname' ), + 'description' => get_option( 'blogdescription' ), + 'URL' => get_option( 'siteurl' ), + 'routes' => array(), + 'meta' => array( + 'timezone' => $this->get_timezone(), + 'currency' => get_woocommerce_currency(), + 'money_format' => get_woocommerce_currency_symbol(), + 'tax_included' => ( 'yes' === get_option( 'woocommerce_prices_include_tax' ) ), + 'weight_unit' => get_option( 'woocommerce_weight_unit' ), + 'dimension_unit' => get_option( 'woocommerce_dimension_unit' ), + 'ssl_enabled' => ( 'yes' === get_option( 'woocommerce_force_ssl_checkout' ) ), + 'links' => array( + 'help' => 'http://docs.woothemes.com/document/woocommerce-rest-api/', + 'profile' => 'https://raw.github.com/rmccue/WP-API/master/docs/schema.json', // TODO: update this + ), + ), + ); + + // Find the available routes + foreach ( $this->get_routes() as $route => $callbacks ) { + $data = array(); + + $route = preg_replace( '#\(\?P(<\w+?>).*?\)#', '$1', $route ); + $methods = array(); + foreach ( self::$method_map as $name => $bitmask ) { + foreach ( $callbacks as $callback ) { + // Skip to the next route if any callback is hidden + if ( $callback[1] & self::HIDDEN_ENDPOINT ) + continue 3; + + if ( $callback[1] & $bitmask ) + $data['supports'][] = $name; + + if ( $callback[1] & self::ACCEPT_DATA ) + $data['accepts_data'] = true; + + // For non-variable routes, generate links + if ( strpos( $route, '<' ) === false ) { + $data['meta'] = array( + 'self' => get_woocommerce_api_url( $route ), + ); + } + } + } + $available['routes'][$route] = apply_filters( 'woocommerce_api_endpoints_description', $data ); + } + return apply_filters( 'woocommerce_api_index', $available ); + } + + /** + * Send a HTTP status code + * + * @since 2.1 + * @param int $code HTTP status + */ + public function send_status( $code ) { + status_header( $code ); + } + + /** + * Send a HTTP header + * + * @since 2.1 + * @param string $key Header key + * @param string $value Header value + * @param boolean $replace Should we replace the existing header? + */ + public function header( $key, $value, $replace = true ) { + header( sprintf( '%s: %s', $key, $value ), $replace ); + } + + /** + * Send a Link header + * + * @internal The $rel parameter is first, as this looks nicer when sending multiple + * + * @link http://tools.ietf.org/html/rfc5988 + * @link http://www.iana.org/assignments/link-relations/link-relations.xml + * + * @since 2.1 + * @param string $rel Link relation. Either a registered type, or an absolute URL + * @param string $link Target IRI for the link + * @param array $other Other parameters to send, as an assocative array + */ + public function link_header( $rel, $link, $other = array() ) { + $header = 'Link: <' . $link . '>; rel="' . $rel . '"'; + foreach ( $other as $key => $value ) { + if ( 'title' == $key ) + $value = '"' . $value . '"'; + $header .= '; ' . $key . '=' . $value; + } + $this->header( 'Link', $header, false ); + } + + /** + * Send pagination headers for resources + * + * @since 2.1 + * @param WP_Query|WP_User_Query $query + */ + public function add_pagination_headers( $query ) { + + // WP_User_Query + if ( is_a( $query, 'WP_User_Query' ) ) { + + $page = $query->page; + $single = count( $query->get_results() ) > 1; + $total = $query->get_total(); + $total_pages = $query->total_pages; + + // WP_Query + } else { + + $page = $query->get( 'paged' ); + $single = $query->is_single(); + $total = $query->found_posts * $query->max_num_pages; + $total_pages = $query->max_num_pages; + } + + if ( ! $page ) + $page = 1; + + $next_page = absint( $page ) + 1; + + if ( ! $single ) { + + // first/prev + if ( $page > 1 ) { + $this->link_header( 'first', $this->get_paginated_url( 1 ) ); + $this->link_header( 'prev', $this->get_paginated_url( $page -1 ) ); + } + + // next + if ( $next_page <= $total_pages ) { + $this->link_header( 'next', $this->get_paginated_url( $next_page ) ); + } + + // last + if ( $page != $total_pages ) + $this->link_header( 'last', $this->get_paginated_url( $total_pages ) ); + } + + $this->header( 'X-WC-Total', $total ); + $this->header( 'X-WC-TotalPages', $total_pages ); + + do_action( 'woocommerce_api_pagination_headers', $this, $query ); + } + + /** + * Returns the request URL with the page query parmeter set to the specified page + * + * @since 2.1 + * @param int $page + * @return string + */ + private function get_paginated_url( $page ) { + + // remove existing page query param + $request = remove_query_arg( 'page' ); + + // add provided page query param + $request = urldecode( add_query_arg( 'page', $page, $request ) ); + + // return full URL + return get_woocommerce_api_url( str_replace( '/wc-api/v1/', '', $request ) ); + } + + /** + * Retrieve the raw request entity (body) + * + * @since 2.1 + * @return string + */ + public function get_raw_data() { + global $HTTP_RAW_POST_DATA; + + // A bug in PHP < 5.2.2 makes $HTTP_RAW_POST_DATA not set by default, + // but we can do it ourself. + if ( !isset( $HTTP_RAW_POST_DATA ) ) { + $HTTP_RAW_POST_DATA = file_get_contents( 'php://input' ); + } + + return $HTTP_RAW_POST_DATA; + } + + /** + * Parse an RFC3339 datetime into a MySQl datetime + * + * Invalid dates default to unix epoch + * + * @since 2.1 + * @param string $datetime RFC3339 datetime + * @return string MySQl datetime (YYYY-MM-DD HH:MM:SS) + */ + public function parse_datetime( $datetime ) { + + // Strip millisecond precision (a full stop followed by one or more digits) + if ( strpos( $datetime, '.' ) !== false ) { + $datetime = preg_replace( '/\.\d+/', '', $datetime ); + } + + // default timezone to UTC + $datetime = preg_replace( '/[+-]\d+:+\d+$/', '+00:00', $datetime ); + + try { + + $datetime = new DateTime( $datetime, new DateTimeZone( 'UTC' ) ); + + } catch ( Exception $e ) { + + $datetime = new DateTime( '@0' ); + + } + + return $datetime->format( 'Y-m-d H:i:s' ); + } + + /** + * Format a unix timestamp or MySQL datetime into an RFC3339 datetime + * + * @since 2.1 + * @param int|string $timestamp unix timestamp or MySQL datetime + * @param bool $convert_to_utc + * @return string RFC3339 datetime + */ + public function format_datetime( $timestamp, $convert_to_utc = false ) { + + if ( $convert_to_utc ) { + $timezone = new DateTimeZone( woocommerce_timezone_string() ); + } else { + $timezone = new DateTimeZone( 'UTC' ); + } + + try { + + if ( is_numeric( $timestamp ) ) { + $date = new DateTime( "@{$timestamp}" ); + } else { + $date = new DateTime( $timestamp, $timezone ); + } + + // convert to UTC by adjusting the time based on the offset of the site's timezone + if ( $convert_to_utc ) { + $date->modify( -1 * $date->getOffset() . ' seconds' ); + } + + } catch ( Exception $e ) { + + $date = new DateTime( '@0' ); + } + + return $date->format( 'Y-m-d\TH:i:s\Z' ); + } + + /** + * Extract headers from a PHP-style $_SERVER array + * + * @since 2.1 + * @param array $server Associative array similar to $_SERVER + * @return array Headers extracted from the input + */ + public function get_headers($server) { + $headers = array(); + // CONTENT_* headers are not prefixed with HTTP_ + $additional = array('CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true); + + foreach ($server as $key => $value) { + if ( strpos( $key, 'HTTP_' ) === 0) { + $headers[ substr( $key, 5 ) ] = $value; + } + elseif ( isset( $additional[ $key ] ) ) { + $headers[ $key ] = $value; + } + } + + return $headers; + } + + /** + * Check if the current request accepts a JSON response by checking the endpoint suffix (.json) or + * the HTTP ACCEPT header + * + * @since 2.1 + * @return bool + */ + private function is_json_request() { + + // check path + if ( false !== stripos( $this->path, '.json' ) ) + return true; + + // check ACCEPT header, only 'application/json' is acceptable, see RFC 4627 + if ( isset( $this->headers['ACCEPT'] ) && 'application/json' == $this->headers['ACCEPT'] ) + return true; + + return false; + } + + /** + * Check if the current request accepts an XML response by checking the endpoint suffix (.xml) or + * the HTTP ACCEPT header + * + * @since 2.1 + * @return bool + */ + private function is_xml_request() { + + // check path + if ( false !== stripos( $this->path, '.xml' ) ) + return true; + + // check headers, 'application/xml' or 'text/xml' are acceptable, see RFC 2376 + if ( isset( $this->headers['ACCEPT'] ) && ( 'application/xml' == $this->headers['ACCEPT'] || 'text/xml' == $this->headers['ACCEPT'] ) ) + return true; + + return false; + } +} diff --git a/includes/api/class-wc-api-xml-handler.php b/includes/api/class-wc-api-xml-handler.php new file mode 100644 index 00000000000..6847924065d --- /dev/null +++ b/includes/api/class-wc-api-xml-handler.php @@ -0,0 +1,52 @@ +query_vars['wc-api'] = $_GET['wc-api']; + if ( ! empty( $_GET['wc-api-route'] ) ) + $wp->query_vars['wc-api-route'] = $_GET['wc-api-route']; + + // REST API request + if ( ! empty( $wp->query_vars['wc-api-route'] ) ) { + + define( 'WC_API_REQUEST', true ); + + // load required files + $this->includes(); + + $this->server = new WC_API_Server( $wp->query_vars['wc-api-route'] ); + + // load API resource classes + $this->register_resources( $this->server ); + + // Fire off the request + $this->server->serve_request(); + + exit; + } + + // legacy API requests if ( ! empty( $wp->query_vars['wc-api'] ) ) { + // Buffer, we won't want any output here ob_start(); @@ -76,4 +129,56 @@ class WC_API { die('1'); } } -} \ No newline at end of file + + + /** + * Include required files for REST API request + * + * @since 2.1 + */ + private function includes() { + + // TODO: are all these required? + include_once( ABSPATH . WPINC . '/class-IXR.php' ); + include_once( ABSPATH . WPINC . '/class-wp-xmlrpc-server.php' ); + + include_once( 'api/class-wc-api-server.php' ); + include_once( 'api/interface-wc-api-handler.php' ); + include_once( 'api/class-wc-api-json-handler.php' ); + include_once( 'api/class-wc-api-xml-handler.php' ); + + include_once( 'api/class-wc-api-authentication.php' ); + $this->authentication = new WC_API_Authentication(); + + include_once( 'api/class-wc-api-resource.php' ); + include_once( 'api/class-wc-api-orders.php' ); + include_once( 'api/class-wc-api-products.php' ); + include_once( 'api/class-wc-api-coupons.php' ); + include_once( 'api/class-wc-api-customers.php' ); + include_once( 'api/class-wc-api-reports.php' ); + + // TODO: some action to allow actors to load additional resource types or handlers + } + + /** + * Register API resources available + * + * @since 2.1 + * @param object $server the REST server + */ + public function register_resources( $server ) { + + $api_classes = apply_filters( 'woocommerce_api_classes', array( + 'WC_API_Customers', + 'WC_API_Orders', + 'WC_API_Products', + 'WC_API_Coupons', + 'WC_API_Reports', + ) ); + + foreach ( $api_classes as $api_class ) { + $this->$api_class = new $api_class( $server ); + } + } + +} diff --git a/includes/class-wc-comments.php b/includes/class-wc-comments.php index 11ef46685c7..cf06282588c 100644 --- a/includes/class-wc-comments.php +++ b/includes/class-wc-comments.php @@ -28,7 +28,7 @@ class WC_Comments { add_action( 'edit_comment', array( $this, 'clear_transients' ) ); // Secure order notes - add_filter( 'comments_clauses', array( $this, 'exclude_order_comments' ), 10, 1); + add_filter( 'comments_clauses', array( __CLASS__, 'exclude_order_comments' ), 10, 1 ); add_action( 'comment_feed_join', array( $this, 'exclude_order_comments_from_feed_join' ) ); add_action( 'comment_feed_where', array( $this, 'exclude_order_comments_from_feed_where' ) ); } @@ -40,12 +40,12 @@ class WC_Comments { * and are not filtered, however, the code current_user_can( 'read_post', $comment->comment_post_ID ) should keep them safe since only admin and * shop managers can view orders anyway. * - * The frontend view order pages get around this filter by using remove_filter('comments_clauses', array( 'WC_Comments' ,'exclude_order_comments') ); + * The frontend view order pages get around this filter by using remove_filter('comments_clauses', array( 'WC_Comments' ,'exclude_order_comments'), 10, 1 ); * * @param array $clauses * @return array */ - public function exclude_order_comments( $clauses ) { + public static function exclude_order_comments( $clauses ) { global $wpdb, $typenow, $pagenow; if ( is_admin() && $typenow == 'shop_order' && current_user_can( 'manage_woocommerce' ) ) @@ -149,4 +149,4 @@ class WC_Comments { } } -new WC_Comments(); \ No newline at end of file +new WC_Comments(); diff --git a/includes/class-wc-shortcodes.php b/includes/class-wc-shortcodes.php index ea7c5424cac..e99ce45cc92 100644 --- a/includes/class-wc-shortcodes.php +++ b/includes/class-wc-shortcodes.php @@ -962,4 +962,4 @@ class WC_Shortcodes { return ob_get_clean(); } -} \ No newline at end of file +} diff --git a/includes/wc-core-functions.php b/includes/wc-core-functions.php index 2ec0aa2ef07..1c897c22880 100644 --- a/includes/wc-core-functions.php +++ b/includes/wc-core-functions.php @@ -325,7 +325,7 @@ function wc_print_js() { /** * Set a cookie - wrapper for setcookie using WP constants - * + * * @param string $name Name of the cookie being set * @param string $value Value of the cookie * @param integer $expire Expiry of the cookie @@ -336,4 +336,21 @@ function wc_setcookie( $name, $value, $expire = 0 ) { } elseif ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { trigger_error( "Cookie cannot be set - headers already sent", E_USER_NOTICE ); } -} \ No newline at end of file +} + +/** + * Get the URL to the WooCommerce REST API + * + * @since 2.1 + * @param string $path an endpoint to include in the URL + * @return string the URL + */ +function get_woocommerce_api_url( $path ) { + + $url = get_home_url( null, 'wc-api/v' . WC_API::VERSION . '/', ( 'yes' === get_option( 'woocommerce_force_ssl_checkout' ) ) ? 'https' : 'http' ); + + if ( ! empty( $path ) && is_string( $path ) ) + $url .= ltrim( $path, '/' ); + + return $url; +} diff --git a/includes/wc-formatting-functions.php b/includes/wc-formatting-functions.php index f35e6c0638b..b9aef48dcee 100644 --- a/includes/wc-formatting-functions.php +++ b/includes/wc-formatting-functions.php @@ -380,6 +380,51 @@ function woocommerce_time_format() { return apply_filters( 'woocommerce_time_format', get_option( 'time_format' ) ); } +/** + * WooCommerce Timezone - helper to retrieve the timezone string for a site until + * a WP core method exists (see http://core.trac.wordpress.org/ticket/24730) + * + * Adapted from http://www.php.net/manual/en/function.timezone-name-from-abbr.php#89155 + * + * @since 2.1 + * @access public + * @return string a valid PHP timezone string for the site + */ +function woocommerce_timezone_string() { + + // if site timezone string exists, return it + if ( $timezone = get_option( 'timezone_string' ) ) + return $timezone; + + // get UTC offset, if it isn't set then return UTC + if ( 0 === ( $utc_offset = get_option( 'gmt_offset', 0 ) ) ) + return 'UTC'; + + // adjust UTC offset from hours to seconds + $utc_offset *= 3600; + + // attempt to guess the timezone string from the UTC offset + $timezone = timezone_name_from_abbr( '', $utc_offset ); + + // last try, guess timezone string manually + if ( false === $timezone ) { + + $is_dst = date( 'I' ); + + foreach ( timezone_abbreviations_list() as $abbr ) { + foreach ( $abbr as $city ) { + + if ( $city['dst'] == $is_dst && $city['offset'] == $utc_offset ) { + return $city['timezone_id']; + } + } + } + } + + // fallback to UTC + return 'UTC'; +} + if ( ! function_exists( 'woocommerce_rgb_from_hex' ) ) { /** @@ -534,4 +579,4 @@ function wc_format_postcode( $postcode, $country ) { function wc_format_phone_number( $tel ) { $tel = str_replace( '.', '-', $tel ); return $tel; -} \ No newline at end of file +}