From f6ea020e8d71eb88a79ad8a4a058ed8f06bcc6d0 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sat, 2 Nov 2013 01:16:45 -0400 Subject: [PATCH 01/43] Add WP JSON API Library --- .../libraries/wp-api/class-wp-json-posts.php | 1060 +++++++++++++++++ .../wp-api/class-wp-json-responsehandler.php | 45 + .../libraries/wp-api/class-wp-json-server.php | 684 +++++++++++ 3 files changed, 1789 insertions(+) create mode 100755 includes/libraries/wp-api/class-wp-json-posts.php create mode 100755 includes/libraries/wp-api/class-wp-json-responsehandler.php create mode 100644 includes/libraries/wp-api/class-wp-json-server.php diff --git a/includes/libraries/wp-api/class-wp-json-posts.php b/includes/libraries/wp-api/class-wp-json-posts.php new file mode 100755 index 00000000000..dd5c5a0245a --- /dev/null +++ b/includes/libraries/wp-api/class-wp-json-posts.php @@ -0,0 +1,1060 @@ +server = $server; + } + + /** + * Register the post-related routes + * + * @param array $routes Existing routes + * @return array Modified routes + */ + public function registerRoutes( $routes ) { + $post_routes = array( + // Post endpoints + '/posts' => array( + array( array( $this, 'getPosts' ), WP_JSON_Server::READABLE ), + array( array( $this, 'newPost' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + ), + + '/posts/(?P\d+)' => array( + array( array( $this, 'getPost' ), WP_JSON_Server::READABLE ), + array( array( $this, 'editPost' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'deletePost' ), WP_JSON_Server::DELETABLE ), + ), + '/posts/(?P\d+)/revisions' => array( '__return_null', WP_JSON_Server::READABLE ), + + // Comments + '/posts/(?P\d+)/comments' => array( + array( array( $this, 'getComments' ), WP_JSON_Server::READABLE ), + array( '__return_null', WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + ), + '/posts/(?P\d+)/comments/(?P\d+)' => array( + array( array( $this, 'getComment' ), WP_JSON_Server::READABLE ), + array( '__return_null', WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( '__return_null', WP_JSON_Server::DELETABLE ), + ), + + // Meta-post endpoints + '/posts/types' => array( array( $this, 'getPostTypes' ), WP_JSON_Server::READABLE ), + '/posts/types/(?P\w+)' => array( array( $this, 'getPostType' ), WP_JSON_Server::READABLE ), + '/posts/statuses' => array( array( $this, 'getPostStatuses' ), WP_JSON_Server::READABLE ), + ); + return array_merge( $routes, $post_routes ); + } + + /** + * Retrieve posts. + * + * @since 3.4.0 + * + * The optional $filter parameter modifies the query used to retrieve posts. + * Accepted keys are 'post_type', 'post_status', 'number', 'offset', + * 'orderby', and 'order'. + * + * The optional $fields parameter specifies what fields will be included + * in the response array. + * + * @uses wp_get_recent_posts() + * @see WP_JSON_Posts::getPost() for more on $fields + * @see get_posts() for more on $filter values + * + * @param array $filter optional + * @param array $fields optional + * @return array contains a collection of Post entities. + */ + public function getPosts( $filter = array(), $context = 'view', $type = 'post', $page = 1 ) { + $query = array(); + + $post_type = get_post_type_object( $type ); + if ( ! ( (bool) $post_type ) ) + return new WP_Error( 'json_invalid_post_type', __( 'The post type specified is not valid' ), array( 'status' => 403 ) ); + + $query['post_type'] = $post_type->name; + + global $wp; + // Allow the same as normal WP + $valid_vars = apply_filters('query_vars', $wp->public_query_vars); + + // If the user has the correct permissions, also allow use of internal + // query parameters, which are only undesirable on the frontend + // + // To disable anyway, use `add_filter('json_private_query_vars', '__return_empty_array');` + + if ( current_user_can( $post_type->cap->edit_posts ) ) { + $private = apply_filters('json_private_query_vars', $wp->private_query_vars); + $valid_vars = array_merge($valid_vars, $private); + } + + // Define our own in addition to WP's normal vars + $json_valid = array('posts_per_page'); + $valid_vars = array_merge($valid_vars, $json_valid); + + // Filter and flip for querying + $valid_vars = apply_filters('json_query_vars', $valid_vars); + $valid_vars = array_flip($valid_vars); + + // Exclude the post_type query var to avoid dodging the permission + // check above + unset($valid_vars['post_type']); + + foreach ($valid_vars as $var => $index) { + if ( isset( $filter[ $var ] ) ) { + $query[ $var ] = apply_filters( 'json_query_var-' . $var, $filter[ $var ] ); + } + } + + // Special parameter handling + $query['paged'] = absint( $page ); + + $post_query = new WP_Query(); + $posts_list = $post_query->query( $query ); + $this->server->query_navigation_headers( $post_query ); + + if ( ! $posts_list ) + return array(); + + // holds all the posts data + $struct = array(); + + $this->server->header( 'Last-Modified', mysql2date( 'D, d M Y H:i:s', get_lastpostmodified( 'GMT' ), 0 ).' GMT' ); + + foreach ( $posts_list as $post ) { + $post = get_object_vars( $post ); + + // Do we have permission to read this post? + if ( ! $this->checkReadPermission( $post ) ) + continue; + + $this->server->link_header( 'item', json_url( '/posts/' . $post['ID'] ), array( 'title' => $post['post_title'] ) ); + $struct[] = $this->prepare_post( $post, $context ); + } + + return $struct; + } + + /** + * Check if we can read a post + * + * Correctly handles posts with the inherit status. + * @param array $post Post data + * @return boolean Can we read it? + */ + protected function checkReadPermission( $post ) { + // Can we read the post? + $post_type = get_post_type_object( $post['post_type'] ); + if ( 'publish' === $post['post_status'] || current_user_can( $post_type->cap->read_post, $post['ID'] ) ) { + return true; + } + + // Can we read the parent if we're inheriting? + if ( 'inherit' === $post['post_status'] && $post['post_parent'] > 0 ) { + $parent = get_post( $post['post_parent'], ARRAY_A ); + + if ( $this->checkReadPermission( $parent ) ) { + return true; + } + } + + // If we don't have a parent, but the status is set to inherit, assume + // it's published (as per get_post_status()) + if ( 'inherit' === $post['post_status'] ) { + return true; + } + + return false; + } + + /** + * Create a new post for any registered post type. + * + * @since 3.4.0 + * @internal 'data' is used here rather than 'content', as get_default_post_to_edit uses $_REQUEST['content'] + * + * @param array $content Content data. Can contain: + * - post_type (default: 'post') + * - post_status (default: 'draft') + * - post_title + * - post_author + * - post_excerpt + * - post_content + * - post_date_gmt | post_date + * - post_format + * - post_password + * - comment_status - can be 'open' | 'closed' + * - ping_status - can be 'open' | 'closed' + * - sticky + * - post_thumbnail - ID of a media item to use as the post thumbnail/featured image + * - custom_fields - array, with each element containing 'key' and 'value' + * - terms - array, with taxonomy names as keys and arrays of term IDs as values + * - terms_names - array, with taxonomy names as keys and arrays of term names as values + * - enclosure + * - any other fields supported by wp_insert_post() + * @return array Post data (see {@see WP_JSON_Posts::getPost}) + */ + function newPost( $data ) { + unset( $data['ID'] ); + + $result = $this->insert_post( $data ); + if ( is_string( $result ) || is_int( $result ) ) { + $this->server->send_status( 201 ); + $this->server->header( 'Location', json_url( '/posts/' . $result ) ); + + return $this->getPost( $result ); + } + elseif ( $result instanceof IXR_Error ) { + return new WP_Error( 'json_insert_error', $result->message, array( 'status' => $result->code ) ); + } + else { + return new WP_Error( 'json_insert_error', __( 'An unknown error occurred while creating the post' ), array( 'status' => 500 ) ); + } + } + + /** + * Retrieve a post. + * + * @uses get_post() + * @param int $id Post ID + * @param array $fields Post fields to return (optional) + * @return array Post entity + */ + public function getPost( $id, $context = 'view' ) { + $id = (int) $id; + + if ( empty( $id ) ) + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + + $post = get_post( $id, ARRAY_A ); + + if ( empty( $post['ID'] ) ) + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + + $post_type = get_post_type_object( $post['post_type'] ); + if ( ! $this->checkReadPermission( $post ) ) + return new WP_Error( 'json_user_cannot_read', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) ); + + // Link headers (see RFC 5988) + + $this->server->header( 'Last-Modified', mysql2date( 'D, d M Y H:i:s', $post['post_modified_gmt'] ) . 'GMT' ); + + $post = $this->prepare_post( $post, $context ); + if ( is_wp_error( $post ) ) + return $post; + + foreach ( $post['meta']['links'] as $rel => $url ) { + $this->server->link_header( $rel, $url ); + } + $this->server->link_header( 'alternate', get_permalink( $id ), array( 'type' => 'text/html' ) ); + + return $post; + } + + /** + * Edit a post for any registered post type. + * + * The $data parameter only needs to contain fields that should be changed. + * All other fields will retain their existing values. + * + * @since 3.4.0 + * @internal 'data' is used here rather than 'content', as get_default_post_to_edit uses $_REQUEST['content'] + * + * @param int $id Post ID to edit + * @param array $data Data construct, see {@see WP_JSON_Posts::newPost} + * @param array $_headers Header data + * @return true on success + */ + function editPost( $id, $data, $_headers = array() ) { + $id = (int) $id; + + if ( empty( $id ) ) + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + + $post = get_post( $id, ARRAY_A ); + + if ( empty( $post['ID'] ) ) + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + + if ( isset( $_headers['IF_UNMODIFIED_SINCE'] ) ) { + // As mandated by RFC2616, we have to check all of RFC1123, RFC1036 + // and C's asctime() format (and ignore invalid headers) + $formats = array( DateTime::RFC1123, DateTime::RFC1036, 'D M j H:i:s Y' ); + foreach ( $formats as $format ) { + $check = DateTime::createFromFormat( $format, $_headers['IF_UNMODIFIED_SINCE'] ); + + if ( $check !== false ) + break; + } + + // If the post has been modified since the date provided, return an error. + if ( $check && mysql2date( 'U', $post['post_modified_gmt'] ) > $check->format('U') ) { + return new WP_Error( 'json_old_revision', __( 'There is a revision of this post that is more recent.' ), array( 'status' => 412 ) ); + } + } + + $data['ID'] = $id; + + $retval = $this->insert_post( $data ); + if ( is_wp_error( $retval ) ) { + return $retval; + } + + return $this->getPost( $id ); + } + + /** + * Delete a post for any registered post type + * + * @uses wp_delete_post() + * @param int $id + * @return true on success + */ + public function deletePost( $id, $force = false ) { + $id = (int) $id; + + if ( empty( $id ) ) + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + + $post = get_post( $id, ARRAY_A ); + + if ( empty( $post['ID'] ) ) + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + + $post_type = get_post_type_object( $post['post_type'] ); + if ( ! current_user_can( $post_type->cap->delete_post, $id ) ) + return new WP_Error( 'json_user_cannot_delete_post', __( 'Sorry, you are not allowed to delete this post.' ), array( 'status' => 401 ) ); + + $result = wp_delete_post( $id, $force ); + + if ( ! $result ) + return new WP_Error( 'json_cannot_delete', __( 'The post cannot be deleted.' ), array( 'status' => 500 ) ); + + if ( $force ) { + return array( 'message' => __( 'Permanently deleted post' ) ); + } + else { + // TODO: return a HTTP 202 here instead + return array( 'message' => __( 'Deleted post' ) ); + } + } + + /** + * Retrieve comments + * + * @param int $id Post ID to retrieve comments for + * @return array List of Comment entities + */ + public function getComments( $id ) { + //$args = array('status' => $status, 'post_id' => $id, 'offset' => $offset, 'number' => $number )l + $comments = get_comments( array('post_id' => $id) ); + + $struct = array(); + foreach ( $comments as $comment ) { + $struct[] = $this->prepare_comment( $comment, array( 'comment', 'meta' ), 'collection' ); + } + return $struct; + } + + /** + * Retrieve a single comment + * + * @param int $comment Comment ID + * @return array Comment entity + */ + public function getComment( $comment ) { + $comment = get_comment( $comment ); + $data = $this->prepare_comment( $comment ); + return $data; + } + + /** + * Get all public post types + * + * @uses self::getPostType() + * @return array List of post type data + */ + public function getPostTypes() { + $data = get_post_types( array(), 'objects' ); + + $types = array(); + foreach ($data as $name => $type) { + $type = $this->getPostType( $type, true ); + if ( is_wp_error( $type ) ) + continue; + + $types[ $name ] = $type; + } + + return $types; + } + + /** + * Get a post type + * + * @param string|object $type Type name, or type object (internal use) + * @param boolean $_in_collection Is this in a collection? (internal use) + * @return array Post type data + */ + public function getPostType( $type, $_in_collection = false ) { + if ( ! is_object( $type ) ) + $type = get_post_type_object($type); + + if ( $type->public === false ) + return new WP_Error( 'json_cannot_read_type', __( 'Cannot view post type' ), array( 'status' => 403 ) ); + + $data = array( + 'name' => $type->label, + 'slug' => $type->name, + 'description' => $type->description, + 'labels' => $type->labels, + 'queryable' => $type->publicly_queryable, + 'searchable' => ! $type->exclude_from_search, + 'hierarchical' => $type->hierarchical, + 'meta' => array( + 'links' => array() + ), + ); + + if ( $_in_collection ) + $data['meta']['links']['self'] = json_url( '/posts/types/' . $type->name ); + else + $data['meta']['links']['collection'] = json_url( '/posts/types' ); + + if ( $type->publicly_queryable ) { + if ($type->name === 'post') + $data['meta']['links']['archives'] = json_url( '/posts' ); + else + $data['meta']['links']['archives'] = json_url( add_query_arg( 'type', $type->name, '/posts' ) ); + } + + return apply_filters( 'json_post_type_data', $data, $type ); + } + + /** + * Get the registered post statuses + * + * @return array List of post status data + */ + public function getPostStatuses() { + $statuses = get_post_stati(array(), 'objects'); + + $data = array(); + foreach ($statuses as $status) { + if ( $status->internal === true || ! $status->show_in_admin_status_list ) + continue; + + $data[ $status->name ] = array( + 'name' => $status->label, + 'slug' => $status->name, + 'public' => $status->public, + 'protected' => $status->protected, + 'private' => $status->private, + 'queryable' => $status->publicly_queryable, + 'show_in_list' => $status->show_in_admin_all_list, + 'meta' => array( + 'links' => array() + ), + ); + if ( $status->publicly_queryable ) { + if ($status->name === 'publish') + $data[ $status->name ]['meta']['links']['archives'] = json_url( '/posts' ); + else + $data[ $status->name ]['meta']['links']['archives'] = json_url( add_query_arg( 'status', $status->name, '/posts' ) ); + } + } + + return apply_filters( 'json_post_statuses', $data, $statuses ); + } + + /** + * Prepares post data for return in an XML-RPC object. + * + * @access protected + * + * @param array $post The unprepared post data + * @param array $fields The subset of post type fields to return + * @return array The prepared post data + */ + protected function prepare_post( $post, $context = 'view' ) { + // holds the data for this post. built up based on $fields + $_post = array( + 'ID' => (int) $post['ID'], + ); + + $post_type = get_post_type_object( $post['post_type'] ); + if ( ! $this->checkReadPermission( $post ) ) + return new WP_Error( 'json_user_cannot_read', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) ); + + // prepare common post fields + $post_fields = array( + 'title' => get_the_title( $post['ID'] ), // $post['post_title'], + 'status' => $post['post_status'], + 'type' => $post['post_type'], + 'author' => (int) $post['post_author'], + 'content' => apply_filters( 'the_content', $post['post_content'] ), + 'parent' => (int) $post['post_parent'], + #'post_mime_type' => $post['post_mime_type'], + 'link' => get_permalink( $post['ID'] ), + ); + $post_fields_extended = array( + 'slug' => $post['post_name'], + 'guid' => apply_filters( 'get_the_guid', $post['guid'] ), + 'excerpt' => $this->prepare_excerpt( $post['post_excerpt'] ), + 'menu_order' => (int) $post['menu_order'], + 'comment_status' => $post['comment_status'], + 'ping_status' => $post['ping_status'], + 'sticky' => ( $post['post_type'] === 'post' && is_sticky( $post['ID'] ) ), + ); + $post_fields_raw = array( + 'title_raw' => $post['post_title'], + 'content_raw' => $post['post_content'], + 'guid_raw' => $post['guid'], + ); + + // Dates + $timezone = $this->server->get_timezone(); + + $date = DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_date'], $timezone ); + $post_fields['date'] = $date->format( 'c' ); + $post_fields_extended['date_tz'] = $date->format( 'e' ); + $post_fields_extended['date_gmt'] = date( 'c', strtotime( $post['post_date_gmt'] ) ); + + $modified = DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_modified'], $timezone ); + $post_fields['modified'] = $modified->format( 'c' ); + $post_fields_extended['modified_tz'] = $modified->format( 'e' ); + $post_fields_extended['modified_gmt'] = date( 'c', strtotime( $post['post_modified_gmt'] ) ); + + // Authorized fields + // TODO: Send `Vary: Authorization` to clarify that the data can be + // changed by the user's auth status + if ( current_user_can( $post_type->cap->edit_post, $post['ID'] ) ) { + $post_fields_extended['password'] = $post['post_password']; + } + + // Consider future posts as published + if ( $post_fields['status'] === 'future' ) + $post_fields['status'] = 'publish'; + + // Fill in blank post format + $post_fields['format'] = get_post_format( $post['ID'] ); + if ( empty( $post_fields['format'] ) ) + $post_fields['format'] = 'standard'; + + $post_fields['author'] = $this->prepare_author( $post['post_author'] ); + + if ( 'view' === $context && 0 !== $post['post_parent'] ) { + // Avoid nesting too deeply + // This gives post + post-extended + meta for the main post, + // post + meta for the parent and just meta for the grandparent + $parent = get_post( $post['post_parent'], ARRAY_A ); + $post_fields['parent'] = $this->prepare_post( $parent, 'parent' ); + } + + // Merge requested $post_fields fields into $_post + $_post = array_merge( $_post, $post_fields ); + + // Include extended fields. We might come back to this. + $_post = array_merge( $_post, $post_fields_extended ); + + if ( 'edit' === $context && current_user_can( $post_type->cap->edit_post, $post['ID'] ) ) + $_post = array_merge( $_post, $post_fields_raw ); + elseif ( 'edit' === $context ) + return new WP_Error( 'json_cannot_edit', __( 'Sorry, you cannot edit this post' ), array( 'status' => 403 ) ); + + // Post meta + $_post['post_meta'] = $this->prepare_meta( $post['ID'] ); + + // Entity meta + $_post['meta'] = array( + 'links' => array( + 'self' => json_url( '/posts/' . $post['ID'] ), + 'author' => json_url( '/users/' . $post['post_author'] ), + 'collection' => json_url( '/posts' ), + 'replies' => json_url( '/posts/' . $post['ID'] . '/comments' ), + 'version-history' => json_url( '/posts/' . $post['ID'] . '/revisions' ), + ), + ); + + if ( ! empty( $post['post_parent'] ) ) + $_post['meta']['links']['up'] = json_url( '/posts/' . (int) $post['post_parent'] ); + + return apply_filters( 'json_prepare_post', $_post, $post, $context ); + } + + /** + * Retrieve the post excerpt. + * + * @return string + */ + protected function prepare_excerpt( $excerpt ) { + if ( post_password_required() ) { + return __( 'There is no excerpt because this is a protected post.' ); + } + + return apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $excerpt ) ); + } + + /** + * Retrieve custom fields for post. + * + * @since 2.5.0 + * + * @param int $post_id Post ID. + * @return array Custom fields, if exist. + */ + protected function prepare_meta( $post_id ) { + $post_id = (int) $post_id; + + $custom_fields = array(); + + foreach ( (array) has_meta( $post_id ) as $meta ) { + // Don't expose protected fields. + if ( is_protected_meta( $meta['meta_key'] ) ) + continue; + + $custom_fields[] = array( + 'id' => $meta['meta_id'], + 'key' => $meta['meta_key'], + 'value' => $meta['meta_value'], + ); + } + + return apply_filters( 'json_prepare_meta', $custom_fields ); + } + + protected function prepare_author( $author ) { + $user = get_user_by( 'id', $author ); + + if (!$author) + return null; + + $author = array( + 'ID' => $user->ID, + 'name' => $user->display_name, + 'slug' => $user->user_nicename, + 'URL' => $user->user_url, + 'avatar' => $this->server->get_avatar( $user->user_email ), + 'meta' => array( + 'links' => array( + 'self' => json_url( '/users/' . $user->ID ), + 'archives' => json_url( '/users/' . $user->ID . '/posts' ), + ), + ), + ); + + if ( current_user_can( 'edit_user', $user->ID ) ) { + $author['first_name'] = $user->first_name; + $author['last_name'] = $user->last_name; + } + return $author; + } + + /** + * Helper method for wp_newPost and wp_editPost, containing shared logic. + * + * @since 3.4.0 + * @uses wp_insert_post() + * + * @param WP_User $user The post author if post_author isn't set in $content_struct. + * @param array $content_struct Post data to insert. + */ + protected function insert_post( $data ) { + $post = array(); + $update = ! empty( $data['ID'] ); + + if ( $update ) { + $current_post = get_post( absint( $data['ID'] ) ); + if ( ! $current_post ) + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 400 ) ); + $post['ID'] = absint( $data['ID'] ); + } + else { + // Defaults + $post['post_author'] = 0; + $post['post_password'] = ''; + $post['post_excerpt'] = ''; + $post['post_content'] = ''; + $post['post_title'] = ''; + } + + // Post type + if ( ! empty( $data['type'] ) ) { + // Changing post type + $post_type = get_post_type_object( $data['type'] ); + if ( ! $post_type ) + return new WP_Error( 'json_invalid_post_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + + $post['post_type'] = $data['type']; + } + elseif ( $update ) { + // Updating post, use existing post type + $current_post = get_post( $data['ID'] ); + if ( ! $current_post ) + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 400 ) ); + + $post_type = get_post_type_object( $current_post->post_type ); + } + else { + // Creating new post, use default type + $post['post_type'] = apply_filters( 'json_insert_default_post_type', 'post' ); + $post_type = get_post_type_object( $post['post_type'] ); + if ( ! $post_type ) + return new WP_Error( 'json_invalid_post_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } + + // Permissions check + if ( $update ) { + if ( ! current_user_can( $post_type->cap->edit_post, $data['ID'] ) ) + return new WP_Error( 'json_cannot_edit', __( 'Sorry, you are not allowed to edit this post.' ), array( 'status' => 401 ) ); + if ( $post_type->name != get_post_type( $data['ID'] ) ) + return new WP_Error( 'json_cannot_change_post_type', __( 'The post type may not be changed.' ), array( 'status' => 400 ) ); + } else { + if ( ! current_user_can( $post_type->cap->create_posts ) || ! current_user_can( $post_type->cap->edit_posts ) ) + return new WP_Error( 'json_cannot_create', __( 'Sorry, you are not allowed to post on this site.' ), array( 'status' => 400 ) ); + } + + // Post status + if ( ! empty( $data['status'] ) ) { + $post['post_status'] = $data['status']; + switch ( $post['post_status'] ) { + case 'draft': + case 'pending': + break; + case 'private': + if ( ! current_user_can( $post_type->cap->publish_posts ) ) + return new WP_Error( 'json_cannot_create_private', __( 'Sorry, you are not allowed to create private posts in this post type' ), array( 'status' => 403 ) ); + break; + case 'publish': + case 'future': + if ( ! current_user_can( $post_type->cap->publish_posts ) ) + return new WP_Error( 'json_cannot_publish', __( 'Sorry, you are not allowed to publish posts in this post type' ), array( 'status' => 403 ) ); + break; + default: + if ( ! get_post_status_object( $post['post_status'] ) ) + $post['post_status'] = 'draft'; + break; + } + } + + // Post title + if ( ! empty( $data['title'] ) ) { + $post['post_title'] = $data['title']; + } + + // Post date + if ( ! empty( $data['date'] ) ) { + list( $post['post_date'], $post['post_date_gmt'] ) = $this->server->get_date_with_gmt( $data['date'] ); + } + elseif ( ! empty( $data['date_gmt'] ) ) { + list( $post['post_date'], $post['post_date_gmt'] ) = $this->server->get_date_with_gmt( $data['date_gmt'], true ); + } + + // Post modified + if ( ! empty( $data['modified'] ) ) { + list( $post['post_modified'], $post['post_modified_gmt'] ) = $this->server->get_date_with_gmt( $data['modified'] ); + } + elseif ( ! empty( $data['modified_gmt'] ) ) { + list( $post['post_modified'], $post['post_modified_gmt'] ) = $this->server->get_date_with_gmt( $data['modified_gmt'], true ); + } + + // Post slug + if ( ! empty( $data['name'] ) ) { + $post['post_name'] = $data['name']; + } + + // Author + if ( ! empty( $data['author'] ) ) { + // Allow passing an author object + if ( is_object( $data['author'] ) ) { + if ( empty( $data['author']->ID ) ) { + return new WP_Error( 'json_invalid_author', __( 'Invalid author object.' ), array( 'status' => 400 ) ); + } + $data['author'] = absint( $data['author']->ID ); + } + else { + $data['author'] = absint( $data['author'] ); + } + + // Only check edit others' posts if we are another user + if ( $data['author'] !== get_current_user_id() ) { + if ( ! current_user_can( $post_type->cap->edit_others_posts ) ) + return new WP_Error( 'json_cannot_edit_others', __( 'You are not allowed to edit posts as this user.' ), array( 'status' => 401 ) ); + + $author = get_userdata( $post['post_author'] ); + + if ( ! $author ) + return new WP_Error( 'json_invalid_author', __( 'Invalid author ID.' ), array( 'status' => 400 ) ); + } + } + + // Post password + if ( ! empty( $data['password'] ) ) { + $post['post_password'] = $data['password']; + if ( ! current_user_can( $post_type->cap->publish_posts ) ) + return new WP_Error( 'json_cannot_create_passworded', __( 'Sorry, you are not allowed to create password protected posts in this post type' ), array( 'status' => 401 ) ); + } + + // Content and excerpt + if ( ! empty( $data['content_raw'] ) ) { + $post['post_content'] = $data['content_raw']; + } + if ( ! empty( $data['excerpt_raw'] ) ) { + $post['post_excerpt'] = $data['excerpt_raw']; + } + + // Parent + if ( ! empty( $data['parent'] ) ) { + $parent = get_post( $data['parent'] ); + $post['post_parent'] = $data['post_parent']; + } + + // Menu order + if ( ! empty( $data['menu_order'] ) ) { + $post['menu_order'] = $data['menu_order']; + } + + // Comment status + if ( ! empty( $data['comment_status'] ) ) { + $post['comment_status'] = $data['comment_status']; + } + + // Ping status + if ( ! empty( $data['ping_status'] ) ) { + $post['ping_status'] = $data['ping_status']; + } + + // Post format + if ( ! empty( $data['post_format'] ) ) { + $formats = get_post_format_slugs(); + if ( ! in_array( $data['post_format'], $formats ) ) { + return new WP_Error( 'json_invalid_post_format', __( 'Invalid post format.' ), array( 'status' => 400 ) ); + } + $post['post_format'] = $data['post_format']; + } + + // Pre-insert hook + $can_insert = apply_filters( 'json_pre_insert_post', true, $post, $data, $update ); + if ( is_wp_error( $can_insert ) ) { + return $can_insert; + } + + // Post meta + // TODO: implement this + $post_ID = $update ? wp_update_post( $post, true ) : wp_insert_post( $post, true ); + + if ( is_wp_error( $post_ID ) ) { + return $post_ID; + } + + // Sticky + if ( isset( $post['sticky'] ) ) { + if ( $post['sticky'] ) + stick_post( $data['ID'] ); + else + unstick_post( $data['ID'] ); + } + + do_action( 'json_insert_post', $post, $data, $update ); + + return $post_ID; + } + + /** + * Parse an RFC3339 timestamp into a DateTime + * + * @param string $date RFC3339 timestamp + * @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ? + * @return DateTime + */ + protected function parse_date( $date, $force_utc = false ) { + // Default timezone to the server's current one + $timezone = self::get_timezone(); + if ( $force_utc ) { + $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); + $timezone = new DateTimeZone( 'UTC' ); + } + + // Strip millisecond precision (a full stop followed by one or more digits) + if ( strpos( $date, '.' ) !== false ) { + $date = preg_replace( '/\.\d+/', '', $date ); + } + $datetime = DateTime::createFromFormat( DateTime::RFC3339, $date ); + + return $datetime; + } + + /** + * Get a local date with its GMT equivalent, in MySQL datetime format + * + * @param string $date RFC3339 timestamp + * @param boolean $force_utc Should we force UTC timestamp? + * @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s) + */ + protected function get_date_with_gmt( $date, $force_utc = false ) { + $datetime = $this->server->parse_date( $date, $force_utc ); + + $datetime->setTimezone( self::get_timezone() ); + $local = $datetime->format( 'Y-m-d H:i:s' ); + + $datetime->setTimezone( new DateTimeZone( 'UTC' ) ); + $utc = $datetime->format('Y-m-d H:i:s'); + + return array( $local, $utc ); + } + + /** + * Retrieve the avatar for a user who provided a user ID or email address. + * + * {@see get_avatar()} doesn't return just the URL, so we have to + * reimplement this here. + * + * @todo Rework how we do this. Copying it is a hack. + * + * @since 2.5 + * @param string $email Email address + * @return string tag for the user's avatar + */ + protected function get_avatar( $email ) { + if ( ! get_option( 'show_avatars' ) ) + return false; + + $email_hash = md5( strtolower( trim( $email ) ) ); + + if ( is_ssl() ) { + $host = 'https://secure.gravatar.com'; + } else { + if ( !empty($email) ) + $host = sprintf( 'http://%d.gravatar.com', ( hexdec( $email_hash[0] ) % 2 ) ); + else + $host = 'http://0.gravatar.com'; + } + + $avatar = "$host/avatar/$email_hash&d=404"; + + $rating = get_option( 'avatar_rating' ); + if ( !empty( $rating ) ) + $avatar .= "&r={$rating}"; + + return apply_filters( 'get_avatar', $avatar, $email, '96', '404', '' ); + } + + /** + * Prepares comment data for returning as a JSON response. + * + * @param stdClass $comment Comment object + * @param array $requested_fields Fields to retrieve from the comment + * @param string $context Where is the comment being loaded? + * @return array Comment data for JSON serialization + */ + protected function prepare_comment( $comment, $requested_fields = array( 'comment', 'meta' ), $context = 'single' ) { + $fields = array( + 'ID' => (int) $comment->comment_ID, + 'post' => (int) $comment->comment_post_ID, + ); + + $post = (array) get_post( $fields['post'] ); + + // Content + $fields['content'] = apply_filters( 'comment_text', $comment->comment_content, $comment ); + // $fields['content_raw'] = $comment->comment_content; + + // Status + switch ( $comment->comment_approved ) { + case 'hold': + case '0': + $fields['status'] = 'hold'; + break; + + case 'approve': + case '1': + $fields['status'] = 'approved'; + break; + + case 'spam': + case 'trash': + default: + $fields['status'] = $comment->comment_approved; + } + + // Type + $fields['type'] = apply_filters( 'get_comment_type', $comment->comment_type ); + if ( empty( $fields['type'] ) ) { + $fields['type'] = 'comment'; + } + + // Post + if ( 'single' === $context ) { + $parent = get_post( $post['post_parent'], ARRAY_A ); + $fields['parent'] = $this->prepare_post( $parent, 'single-parent' ); + } + + // Parent + if ( ( 'single' === $context || 'single-parent' === $context ) && (int) $comment->comment_parent ) { + $parent_fields = array( 'meta' ); + if ( $context === 'single' ) + $parent_fields[] = 'comment'; + $parent = get_comment( $post['post_parent'] ); + $fields['parent'] = $this->prepare_comment( $parent, $parent_fields, 'single-parent' ); + } + + // Parent + $fields['parent'] = (int) $comment->comment_parent; + + // Author + if ( (int) $comment->user_id !== 0 ) { + $fields['author'] = $this->prepare_author( (int) $comment->user_id ); + } + else { + $fields['author'] = array( + 'ID' => 0, + 'name' => $comment->comment_author, + 'URL' => $comment->comment_author_url, + 'avatar' => $this->server->get_avatar( $comment->comment_author_email ), + ); + } + + // Date + $timezone = $this->server->get_timezone(); + + $date = DateTime::createFromFormat( 'Y-m-d H:i:s', $comment->comment_date, $timezone ); + $fields['date'] = $date->format( 'c' ); + $fields['date_tz'] = $date->format( 'e' ); + $fields['date_gmt'] = date( 'c', strtotime( $comment->comment_date_gmt ) ); + + // Meta + $meta = array( + 'links' => array( + 'up' => json_url( sprintf( '/posts/%d', (int) $comment->comment_post_ID ) ) + ), + ); + if ( 0 !== (int) $comment->comment_parent ) { + $meta['links']['in-reply-to'] = json_url( sprintf( '/posts/%d/comments/%d', (int) $comment->comment_post_ID, (int) $comment->comment_parent ) ); + } + if ( 'single' !== $context ) { + $meta['links']['self'] = json_url( sprintf( '/posts/%d/comments/%d', (int) $comment->comment_post_ID, (int) $comment->comment_ID ) ); + } + + // Remove unneeded fields + $data = array(); + if ( in_array( 'comment', $requested_fields ) ) + $data = array_merge( $data, $fields ); + + if ( in_array( 'meta', $requested_fields ) ) + $data['meta'] = $meta; + + return $data; + } +} \ No newline at end of file diff --git a/includes/libraries/wp-api/class-wp-json-responsehandler.php b/includes/libraries/wp-api/class-wp-json-responsehandler.php new file mode 100755 index 00000000000..a3fe4b345ba --- /dev/null +++ b/includes/libraries/wp-api/class-wp-json-responsehandler.php @@ -0,0 +1,45 @@ + 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(); + + /** + * Check the authentication headers if supplied + * + * @return WP_Error|WP_User|null WP_User object indicates successful login, WP_Error indicates unsuccessful login and null indicates no authentication provided + */ + public function check_authentication() { + $user = apply_filters( 'json_check_authentication', null ); + if ( is_a( $user, 'WP_User' ) ) + return $user; + + if ( !isset( $_SERVER['PHP_AUTH_USER'] ) ) + return; + + $username = $_SERVER['PHP_AUTH_USER']; + $password = $_SERVER['PHP_AUTH_PW']; + + $user = wp_authenticate( $username, $password ); + + if ( is_wp_error( $user ) ) + return $user; + + wp_set_current_user( $user->ID ); + 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 + * + * @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; + } + + /** + * Get an appropriate error representation in JSON + * + * Note: This should only be used in {@see WP_JSON_Server::serve_request()}, + * as it cannot handle WP_Error internally. All callbacks and other internal + * methods should instead return a WP_Error with the data set to an array + * that includes a 'status' key, with the value being the HTTP status to + * send. + * + * @param string $code WP_Error-style code + * @param string $message Human-readable message + * @param int $status HTTP status code to send + * @return string JSON representation of the error + */ + protected function json_error( $code, $message, $status = null ) { + if ( $status ) + $this->send_status( $status ); + + $error = compact( 'code', 'message' ); + return json_encode( array( $error ) ); + } + + /** + * 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. + * + * @uses WP_JSON_Server::dispatch() + */ + public function serve_request( $path = null ) { + $this->header( 'Content-Type', 'application/json; charset=' . get_option( 'blog_charset' ), true ); + + // Proper filter for turning off the JSON API. It is on by default. + $enabled = apply_filters( 'json_enabled', true ); + $jsonp_enabled = apply_filters( 'json_jsonp_enabled', true ); + + if ( ! $enabled ) { + echo $this->json_error( 'json_disabled', 'The JSON API is disabled on this site.', 404 ); + return false; + } + if ( isset($_GET['_jsonp']) ) { + if ( ! $jsonp_enabled ) { + echo $this->json_error( 'json_callback_disabled', 'JSONP support is disabled on this site.', 400 ); + return false; + } + + // Check for invalid characters (only alphanumeric allowed) + if ( preg_match( '/\W/', $_GET['_jsonp'] ) ) { + echo $this->json_error( 'json_callback_invalid', 'The JSONP callback function is invalid.', 400 ); + return false; + } + } + + 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'] ); + } + + $result = $this->check_authentication(); + + if ( ! is_wp_error( $result ) ) { + $result = $this->dispatch(); + } + + 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( 'json_serve_request', false, $result, $path, $this->method ); + + if ( ! $served ) { + if ( 'HEAD' === $this->method ) + return; + + if ( isset($_GET['_jsonp']) ) + echo $_GET['_jsonp'] . '(' . json_encode( $result ) . ')'; + else + echo json_encode( $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() + * + * @return array `'/path/regex' => array( $callback, $bitmask )` or `'/path/regex' => array( array( $callback, $bitmask ), ...)` + */ + public function getRoutes() { + $endpoints = array( + // Meta endpoints + '/' => array( array( $this, 'getIndex' ), self::READABLE ), + + // Users + '/users' => array( + array( '__return_null', self::READABLE ), + array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ), + ), + // /users/me is an alias, and simply redirects to /users/ + '/users/me' => array( '__return_null', self::ALLMETHODS ), + '/users/(?P\d+)' => array( + array( '__return_null', self::READABLE ), + array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ), + ), + ); + + $endpoints = apply_filters( 'json_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 + * + * @param string $path Requested route + * @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( 'json_unsupported_method', __( 'Unsupported request method' ), array( 'status' => 400 ) ); + } + foreach ( $this->getRoutes() 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', $this->path, $args ); + + if ( !$match ) + continue; + + if ( ! is_callable( $callback ) ) + return new WP_Error( 'json_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_JSON ) { + $data = json_decode( $this->get_raw_data(), true ); + $args = array_merge( $args, array( 'data' => $data ) ); + } + elseif ( $supported & self::ACCEPT_RAW ) { + $data = $this->get_raw_data(); + } + + $args['_method'] = $method; + $args['_route'] = $route; + $args['_path'] = $this->path; + $args['_headers'] = $this->headers; + $args['_files'] = $this->files; + + $args = apply_filters( 'json_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( 'json_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 + * + * @param callback $callback + * @param array $params + * @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[] = $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( 'json_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. + * + * @todo Should we generate text documentation too based on PHPDoc? + * + * @return array Index entity + */ + public function getIndex() { + // General site data + $available = array( + 'name' => get_option( 'blogname' ), + 'description' => get_option( 'blogdescription' ), + 'URL' => get_option( 'siteurl' ), + 'routes' => array(), + 'meta' => array( + 'links' => array( + 'help' => 'https://github.com/rmccue/WP-API', + 'profile' => 'https://raw.github.com/rmccue/WP-API/master/docs/schema.json', + ), + ), + ); + + // Find the available routes + foreach ( $this->getRoutes() 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_JSON ) + $data['accepts_json'] = true; + + // For non-variable routes, generate links + if ( strpos( $route, '<' ) === false ) { + $data['meta'] = array( + 'self' => json_url( $route ), + ); + } + } + } + $available['routes'][$route] = apply_filters( 'json_endpoints_description', $data ); + } + return apply_filters( 'json_index', $available ); + } + + /** + * Send a HTTP status code + * + * @param int $code HTTP status + */ + public function send_status( $code ) { + status_header( $code ); + } + + /** + * Send a HTTP header + * + * @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 + * + * @todo Make this safe for <>"';, + * @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 + * + * @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 navigation-related headers for post collections + * + * @param WP_Query $query + */ + public function query_navigation_headers( $query ) { + $max_page = $query->max_num_pages; + $paged = $query->get('paged'); + + if ( !$paged ) + $paged = 1; + + $nextpage = intval($paged) + 1; + + if ( ! $query->is_single() ) { + if ( $paged > 1 ) { + $request = remove_query_arg( 'page' ); + $request = add_query_arg( 'page', $paged - 1, $request ); + $this->link_header( 'prev', $request ); + } + + if ( $nextpage <= $max_page ) { + $request = remove_query_arg( 'page' ); + $request = add_query_arg( 'page', $nextpage, $request ); + $this->link_header( 'next', $request ); + } + } + + $this->header( 'X-WP-Total', $query->found_posts ); + $this->header( 'X-WP-TotalPages', $max_page ); + + do_action('json_query_navigation_headers', $this, $query); + } + + + /** + * Retrieve the raw request entity (body) + * + * @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 timestamp into a DateTime + * + * @param string $date RFC3339 timestamp + * @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ? + * @return DateTime + */ + public function parse_date( $date, $force_utc = false ) { + // Default timezone to the server's current one + $timezone = self::get_timezone(); + if ( $force_utc ) { + $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); + $timezone = new DateTimeZone( 'UTC' ); + } + + // Strip millisecond precision (a full stop followed by one or more digits) + if ( strpos( $date, '.' ) !== false ) { + $date = preg_replace( '/\.\d+/', '', $date ); + } + $datetime = DateTime::createFromFormat( DateTime::RFC3339, $date ); + + return $datetime; + } + + /** + * Get a local date with its GMT equivalent, in MySQL datetime format + * + * @param string $date RFC3339 timestamp + * @param boolean $force_utc Should we force UTC timestamp? + * @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s) + */ + public function get_date_with_gmt( $date, $force_utc = false ) { + $datetime = $this->parse_date( $date, $force_utc ); + + $datetime->setTimezone( self::get_timezone() ); + $local = $datetime->format( 'Y-m-d H:i:s' ); + + $datetime->setTimezone( new DateTimeZone( 'UTC' ) ); + $utc = $datetime->format('Y-m-d H:i:s'); + + return array( $local, $utc ); + } + + /** + * Retrieve the avatar for a user who provided a user ID or email address. + * + * {@see get_avatar()} doesn't return just the URL, so we have to + * reimplement this here. + * + * @todo Rework how we do this. Copying it is a hack. + * + * @since 2.5 + * @param string $email Email address + * @return string tag for the user's avatar + */ + public function get_avatar( $email ) { + if ( ! get_option( 'show_avatars' ) ) + return false; + + $email_hash = md5( strtolower( trim( $email ) ) ); + + if ( is_ssl() ) { + $host = 'https://secure.gravatar.com'; + } else { + if ( !empty($email) ) + $host = sprintf( 'http://%d.gravatar.com', ( hexdec( $email_hash[0] ) % 2 ) ); + else + $host = 'http://0.gravatar.com'; + } + + $avatar = "$host/avatar/$email_hash&d=404"; + + $rating = get_option( 'avatar_rating' ); + if ( !empty( $rating ) ) + $avatar .= "&r={$rating}"; + + return apply_filters( 'get_avatar', $avatar, $email, '96', '404', '' ); + } + + /** + * Get the timezone object for the site + * + * @return DateTimeZone + */ + public function get_timezone() { + static $zone = null; + if ($zone !== null) + return $zone; + + $tzstring = get_option( 'timezone_string' ); + if ( ! $tzstring ) { + // Create a UTC+- zone if no timezone string exists + $current_offset = get_option( 'gmt_offset' ); + if ( 0 == $current_offset ) + $tzstring = 'UTC'; + elseif ($current_offset < 0) + $tzstring = 'Etc/GMT' . $current_offset; + else + $tzstring = 'Etc/GMT+' . $current_offset; + } + $zone = new DateTimeZone( $tzstring ); + return $zone; + } + + /** + * Extract headers from a PHP-style $_SERVER array + * + * @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; + } +} From a0ddef247dedfb35d3f294d67331368c7134141f Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sat, 2 Nov 2013 18:12:50 -0400 Subject: [PATCH 02/43] Fix strict standards warning --- includes/class-wc-shortcodes.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/class-wc-shortcodes.php b/includes/class-wc-shortcodes.php index b224b2f2990..a168dba2bf9 100644 --- a/includes/class-wc-shortcodes.php +++ b/includes/class-wc-shortcodes.php @@ -13,7 +13,7 @@ class WC_Shortcodes { /** * Init shortcodes */ - public function init() { + public static function init() { // Define shortcodes $shortcodes = array( 'product' => __CLASS__ . '::product', @@ -956,4 +956,4 @@ class WC_Shortcodes { return ob_get_clean(); } -} \ No newline at end of file +} From 65f48e84566e411fb902030336892d1347e14595 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 3 Nov 2013 18:53:35 -0500 Subject: [PATCH 03/43] Modify WP_JSON_Server to allow authentication --- includes/libraries/wp-api/class-wp-json-server.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/libraries/wp-api/class-wp-json-server.php b/includes/libraries/wp-api/class-wp-json-server.php index 5a8774d83fe..f0726a21d2b 100644 --- a/includes/libraries/wp-api/class-wp-json-server.php +++ b/includes/libraries/wp-api/class-wp-json-server.php @@ -97,8 +97,10 @@ class WP_JSON_Server implements WP_JSON_ResponseHandler { */ public function check_authentication() { $user = apply_filters( 'json_check_authentication', null ); - if ( is_a( $user, 'WP_User' ) ) + if ( ! is_null( $user ) && ( is_a( $user, 'WP_User' ) || is_wp_error( $user ) ) ) { + wp_set_current_user( $user->ID ); return $user; + } if ( !isset( $_SERVER['PHP_AUTH_USER'] ) ) return; From a4a8b5e5c063657f1f628d77ae4bb957ca980979 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 3 Nov 2013 18:54:56 -0500 Subject: [PATCH 04/43] Add required global functions for WP_JSON_Server class --- includes/wc-core-functions.php | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/includes/wc-core-functions.php b/includes/wc-core-functions.php index d415acd5179..e19190112d0 100644 --- a/includes/wc-core-functions.php +++ b/includes/wc-core-functions.php @@ -323,7 +323,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 @@ -334,4 +334,34 @@ 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 +} + +// TODO: not sure if these should be moved to wp-json-server class and made WC-specific or not +/** + * Get URL to a JSON endpoint on a site + * + * @todo Check if this is even necessary + * @param int $blog_id Blog ID + * @param string $path JSON route + * @param string $scheme Sanitization scheme (usually 'json') + * @return string Full URL to the endpoint + */ +function get_json_url( $blog_id = null, $path = '', $scheme = 'json' ) { + $url = get_home_url( $blog_id, 'wc-api/v1/', $scheme ); + + if ( !empty( $path ) && is_string( $path ) && strpos( $path, '..' ) === false ) + $url .= '/' . ltrim( $path, '/' ); + + return apply_filters( 'json_url', $url, $path, $blog_id ); +} + +/** + * Get URL to a JSON endpoint + * + * @param string $path JSON route + * @param string $scheme Sanitization scheme (usually 'json') + * @return string Full URL to the endpoint + */ +function json_url( $path = '', $scheme = 'json' ) { + return get_json_url( null, $path, $scheme ); +} From 4daaf22304629883804ba540276c8af0c20323d5 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 3 Nov 2013 18:55:34 -0500 Subject: [PATCH 05/43] Add REST API settings stub --- includes/admin/class-wc-admin-settings.php | 3 +- .../settings/class-wc-settings-rest-api.php | 116 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 includes/admin/settings/class-wc-settings-rest-api.php diff --git a/includes/admin/class-wc-admin-settings.php b/includes/admin/class-wc-admin-settings.php index 94b8a7f7703..b91007ecbb1 100644 --- a/includes/admin/class-wc-admin-settings.php +++ b/includes/admin/class-wc-admin-settings.php @@ -35,6 +35,7 @@ class WC_Admin_Settings { $settings[] = include( 'settings/class-wc-settings-shipping.php' ); $settings[] = include( 'settings/class-wc-settings-tax.php' ); $settings[] = include( 'settings/class-wc-settings-emails.php' ); + $settings[] = include( 'settings/class-wc-settings-rest-api.php' ); $settings[] = include( 'settings/class-wc-settings-integrations.php' ); $settings = apply_filters( 'woocommerce_get_settings_pages', $settings ); @@ -770,4 +771,4 @@ class WC_Admin_Settings { } } -endif; \ No newline at end of file +endif; diff --git a/includes/admin/settings/class-wc-settings-rest-api.php b/includes/admin/settings/class-wc-settings-rest-api.php new file mode 100644 index 00000000000..17c9a353440 --- /dev/null +++ b/includes/admin/settings/class-wc-settings-rest-api.php @@ -0,0 +1,116 @@ +id = 'api'; + $this->label = __( 'API', 'woocommerce' ); + + add_filter( 'woocommerce_settings_tabs_array', array( $this, 'add_settings_page' ), 20 ); + add_action( 'woocommerce_settings_' . $this->id, array( $this, 'output' ) ); + add_action( 'woocommerce_settings_save_' . $this->id, array( $this, 'save' ) ); + add_action( 'woocommerce_sections_' . $this->id, array( $this, 'output_sections' ) ); + } + + /** + * Get sections + * + * @return array + */ + public function get_sections() { + $sections = array( + '' => __( 'API Options', 'woocommerce' ), + 'log' => __( 'Log', 'woocommerce' ) + ); + + return $sections; + } + + /** + * Output the settings + */ + public function output() { + global $current_section; + + $settings = $this->get_settings( $current_section ); + + WC_Admin_Settings::output_fields( $settings ); + } + + /** + * Save settings + */ + public function save() { + global $current_section; + + $settings = $this->get_settings( $current_section ); + WC_Admin_Settings::save_fields( $settings ); + } + + /** + * Get settings array + * + * @param string $current_section + * @return array + */ + public function get_settings( $current_section = '' ) { + + if ( $current_section == 'log' ) { + + // TODO: implement log display + + } else { + + return apply_filters( 'woocommerce_api_settings', array( + + array( 'title' => __( 'General Options', 'woocommerce' ), 'type' => 'title', 'desc' => '', 'id' => 'general_options' ), + + array( + 'title' => __( 'Enable API', 'woocommerce' ), + 'id' => 'woocommerce_api_enabled', + 'type' => 'checkbox', + 'default' => 'yes', + ), + + array( + 'title' => __( 'Allow read-only public access to Products endpoint', 'woocommerce' ), + 'desc' => __( 'This enables read-only public access to the products endpoint', 'woocommerce' ), + 'id' => 'woocommerce_api_public_products_endpoint', + 'type' => 'checkbox', + 'default' => 'no', + ), + + array( 'type' => 'sectionend', 'id' => 'general_options' ), + + array( 'title' => __( 'API Keys', 'woocommerce' ), 'type' => 'title', 'id' => 'api_key_options' ), + + // TODO: implement key management here + + array( 'type' => 'sectionend', 'id' => 'digital_download_options' ), + + )); + } + } +} + +endif; + +return new WC_Settings_REST_API(); From 4cc1847a66d38957ff1617a4f5a490ec716a2044 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 3 Nov 2013 19:26:29 -0500 Subject: [PATCH 06/43] Remove unused WP_JSON_Posts class --- .../libraries/wp-api/class-wp-json-posts.php | 1060 ----------------- 1 file changed, 1060 deletions(-) delete mode 100755 includes/libraries/wp-api/class-wp-json-posts.php diff --git a/includes/libraries/wp-api/class-wp-json-posts.php b/includes/libraries/wp-api/class-wp-json-posts.php deleted file mode 100755 index dd5c5a0245a..00000000000 --- a/includes/libraries/wp-api/class-wp-json-posts.php +++ /dev/null @@ -1,1060 +0,0 @@ -server = $server; - } - - /** - * Register the post-related routes - * - * @param array $routes Existing routes - * @return array Modified routes - */ - public function registerRoutes( $routes ) { - $post_routes = array( - // Post endpoints - '/posts' => array( - array( array( $this, 'getPosts' ), WP_JSON_Server::READABLE ), - array( array( $this, 'newPost' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), - ), - - '/posts/(?P\d+)' => array( - array( array( $this, 'getPost' ), WP_JSON_Server::READABLE ), - array( array( $this, 'editPost' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( array( $this, 'deletePost' ), WP_JSON_Server::DELETABLE ), - ), - '/posts/(?P\d+)/revisions' => array( '__return_null', WP_JSON_Server::READABLE ), - - // Comments - '/posts/(?P\d+)/comments' => array( - array( array( $this, 'getComments' ), WP_JSON_Server::READABLE ), - array( '__return_null', WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), - ), - '/posts/(?P\d+)/comments/(?P\d+)' => array( - array( array( $this, 'getComment' ), WP_JSON_Server::READABLE ), - array( '__return_null', WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( '__return_null', WP_JSON_Server::DELETABLE ), - ), - - // Meta-post endpoints - '/posts/types' => array( array( $this, 'getPostTypes' ), WP_JSON_Server::READABLE ), - '/posts/types/(?P\w+)' => array( array( $this, 'getPostType' ), WP_JSON_Server::READABLE ), - '/posts/statuses' => array( array( $this, 'getPostStatuses' ), WP_JSON_Server::READABLE ), - ); - return array_merge( $routes, $post_routes ); - } - - /** - * Retrieve posts. - * - * @since 3.4.0 - * - * The optional $filter parameter modifies the query used to retrieve posts. - * Accepted keys are 'post_type', 'post_status', 'number', 'offset', - * 'orderby', and 'order'. - * - * The optional $fields parameter specifies what fields will be included - * in the response array. - * - * @uses wp_get_recent_posts() - * @see WP_JSON_Posts::getPost() for more on $fields - * @see get_posts() for more on $filter values - * - * @param array $filter optional - * @param array $fields optional - * @return array contains a collection of Post entities. - */ - public function getPosts( $filter = array(), $context = 'view', $type = 'post', $page = 1 ) { - $query = array(); - - $post_type = get_post_type_object( $type ); - if ( ! ( (bool) $post_type ) ) - return new WP_Error( 'json_invalid_post_type', __( 'The post type specified is not valid' ), array( 'status' => 403 ) ); - - $query['post_type'] = $post_type->name; - - global $wp; - // Allow the same as normal WP - $valid_vars = apply_filters('query_vars', $wp->public_query_vars); - - // If the user has the correct permissions, also allow use of internal - // query parameters, which are only undesirable on the frontend - // - // To disable anyway, use `add_filter('json_private_query_vars', '__return_empty_array');` - - if ( current_user_can( $post_type->cap->edit_posts ) ) { - $private = apply_filters('json_private_query_vars', $wp->private_query_vars); - $valid_vars = array_merge($valid_vars, $private); - } - - // Define our own in addition to WP's normal vars - $json_valid = array('posts_per_page'); - $valid_vars = array_merge($valid_vars, $json_valid); - - // Filter and flip for querying - $valid_vars = apply_filters('json_query_vars', $valid_vars); - $valid_vars = array_flip($valid_vars); - - // Exclude the post_type query var to avoid dodging the permission - // check above - unset($valid_vars['post_type']); - - foreach ($valid_vars as $var => $index) { - if ( isset( $filter[ $var ] ) ) { - $query[ $var ] = apply_filters( 'json_query_var-' . $var, $filter[ $var ] ); - } - } - - // Special parameter handling - $query['paged'] = absint( $page ); - - $post_query = new WP_Query(); - $posts_list = $post_query->query( $query ); - $this->server->query_navigation_headers( $post_query ); - - if ( ! $posts_list ) - return array(); - - // holds all the posts data - $struct = array(); - - $this->server->header( 'Last-Modified', mysql2date( 'D, d M Y H:i:s', get_lastpostmodified( 'GMT' ), 0 ).' GMT' ); - - foreach ( $posts_list as $post ) { - $post = get_object_vars( $post ); - - // Do we have permission to read this post? - if ( ! $this->checkReadPermission( $post ) ) - continue; - - $this->server->link_header( 'item', json_url( '/posts/' . $post['ID'] ), array( 'title' => $post['post_title'] ) ); - $struct[] = $this->prepare_post( $post, $context ); - } - - return $struct; - } - - /** - * Check if we can read a post - * - * Correctly handles posts with the inherit status. - * @param array $post Post data - * @return boolean Can we read it? - */ - protected function checkReadPermission( $post ) { - // Can we read the post? - $post_type = get_post_type_object( $post['post_type'] ); - if ( 'publish' === $post['post_status'] || current_user_can( $post_type->cap->read_post, $post['ID'] ) ) { - return true; - } - - // Can we read the parent if we're inheriting? - if ( 'inherit' === $post['post_status'] && $post['post_parent'] > 0 ) { - $parent = get_post( $post['post_parent'], ARRAY_A ); - - if ( $this->checkReadPermission( $parent ) ) { - return true; - } - } - - // If we don't have a parent, but the status is set to inherit, assume - // it's published (as per get_post_status()) - if ( 'inherit' === $post['post_status'] ) { - return true; - } - - return false; - } - - /** - * Create a new post for any registered post type. - * - * @since 3.4.0 - * @internal 'data' is used here rather than 'content', as get_default_post_to_edit uses $_REQUEST['content'] - * - * @param array $content Content data. Can contain: - * - post_type (default: 'post') - * - post_status (default: 'draft') - * - post_title - * - post_author - * - post_excerpt - * - post_content - * - post_date_gmt | post_date - * - post_format - * - post_password - * - comment_status - can be 'open' | 'closed' - * - ping_status - can be 'open' | 'closed' - * - sticky - * - post_thumbnail - ID of a media item to use as the post thumbnail/featured image - * - custom_fields - array, with each element containing 'key' and 'value' - * - terms - array, with taxonomy names as keys and arrays of term IDs as values - * - terms_names - array, with taxonomy names as keys and arrays of term names as values - * - enclosure - * - any other fields supported by wp_insert_post() - * @return array Post data (see {@see WP_JSON_Posts::getPost}) - */ - function newPost( $data ) { - unset( $data['ID'] ); - - $result = $this->insert_post( $data ); - if ( is_string( $result ) || is_int( $result ) ) { - $this->server->send_status( 201 ); - $this->server->header( 'Location', json_url( '/posts/' . $result ) ); - - return $this->getPost( $result ); - } - elseif ( $result instanceof IXR_Error ) { - return new WP_Error( 'json_insert_error', $result->message, array( 'status' => $result->code ) ); - } - else { - return new WP_Error( 'json_insert_error', __( 'An unknown error occurred while creating the post' ), array( 'status' => 500 ) ); - } - } - - /** - * Retrieve a post. - * - * @uses get_post() - * @param int $id Post ID - * @param array $fields Post fields to return (optional) - * @return array Post entity - */ - public function getPost( $id, $context = 'view' ) { - $id = (int) $id; - - if ( empty( $id ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - - $post = get_post( $id, ARRAY_A ); - - if ( empty( $post['ID'] ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - - $post_type = get_post_type_object( $post['post_type'] ); - if ( ! $this->checkReadPermission( $post ) ) - return new WP_Error( 'json_user_cannot_read', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) ); - - // Link headers (see RFC 5988) - - $this->server->header( 'Last-Modified', mysql2date( 'D, d M Y H:i:s', $post['post_modified_gmt'] ) . 'GMT' ); - - $post = $this->prepare_post( $post, $context ); - if ( is_wp_error( $post ) ) - return $post; - - foreach ( $post['meta']['links'] as $rel => $url ) { - $this->server->link_header( $rel, $url ); - } - $this->server->link_header( 'alternate', get_permalink( $id ), array( 'type' => 'text/html' ) ); - - return $post; - } - - /** - * Edit a post for any registered post type. - * - * The $data parameter only needs to contain fields that should be changed. - * All other fields will retain their existing values. - * - * @since 3.4.0 - * @internal 'data' is used here rather than 'content', as get_default_post_to_edit uses $_REQUEST['content'] - * - * @param int $id Post ID to edit - * @param array $data Data construct, see {@see WP_JSON_Posts::newPost} - * @param array $_headers Header data - * @return true on success - */ - function editPost( $id, $data, $_headers = array() ) { - $id = (int) $id; - - if ( empty( $id ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - - $post = get_post( $id, ARRAY_A ); - - if ( empty( $post['ID'] ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - - if ( isset( $_headers['IF_UNMODIFIED_SINCE'] ) ) { - // As mandated by RFC2616, we have to check all of RFC1123, RFC1036 - // and C's asctime() format (and ignore invalid headers) - $formats = array( DateTime::RFC1123, DateTime::RFC1036, 'D M j H:i:s Y' ); - foreach ( $formats as $format ) { - $check = DateTime::createFromFormat( $format, $_headers['IF_UNMODIFIED_SINCE'] ); - - if ( $check !== false ) - break; - } - - // If the post has been modified since the date provided, return an error. - if ( $check && mysql2date( 'U', $post['post_modified_gmt'] ) > $check->format('U') ) { - return new WP_Error( 'json_old_revision', __( 'There is a revision of this post that is more recent.' ), array( 'status' => 412 ) ); - } - } - - $data['ID'] = $id; - - $retval = $this->insert_post( $data ); - if ( is_wp_error( $retval ) ) { - return $retval; - } - - return $this->getPost( $id ); - } - - /** - * Delete a post for any registered post type - * - * @uses wp_delete_post() - * @param int $id - * @return true on success - */ - public function deletePost( $id, $force = false ) { - $id = (int) $id; - - if ( empty( $id ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - - $post = get_post( $id, ARRAY_A ); - - if ( empty( $post['ID'] ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - - $post_type = get_post_type_object( $post['post_type'] ); - if ( ! current_user_can( $post_type->cap->delete_post, $id ) ) - return new WP_Error( 'json_user_cannot_delete_post', __( 'Sorry, you are not allowed to delete this post.' ), array( 'status' => 401 ) ); - - $result = wp_delete_post( $id, $force ); - - if ( ! $result ) - return new WP_Error( 'json_cannot_delete', __( 'The post cannot be deleted.' ), array( 'status' => 500 ) ); - - if ( $force ) { - return array( 'message' => __( 'Permanently deleted post' ) ); - } - else { - // TODO: return a HTTP 202 here instead - return array( 'message' => __( 'Deleted post' ) ); - } - } - - /** - * Retrieve comments - * - * @param int $id Post ID to retrieve comments for - * @return array List of Comment entities - */ - public function getComments( $id ) { - //$args = array('status' => $status, 'post_id' => $id, 'offset' => $offset, 'number' => $number )l - $comments = get_comments( array('post_id' => $id) ); - - $struct = array(); - foreach ( $comments as $comment ) { - $struct[] = $this->prepare_comment( $comment, array( 'comment', 'meta' ), 'collection' ); - } - return $struct; - } - - /** - * Retrieve a single comment - * - * @param int $comment Comment ID - * @return array Comment entity - */ - public function getComment( $comment ) { - $comment = get_comment( $comment ); - $data = $this->prepare_comment( $comment ); - return $data; - } - - /** - * Get all public post types - * - * @uses self::getPostType() - * @return array List of post type data - */ - public function getPostTypes() { - $data = get_post_types( array(), 'objects' ); - - $types = array(); - foreach ($data as $name => $type) { - $type = $this->getPostType( $type, true ); - if ( is_wp_error( $type ) ) - continue; - - $types[ $name ] = $type; - } - - return $types; - } - - /** - * Get a post type - * - * @param string|object $type Type name, or type object (internal use) - * @param boolean $_in_collection Is this in a collection? (internal use) - * @return array Post type data - */ - public function getPostType( $type, $_in_collection = false ) { - if ( ! is_object( $type ) ) - $type = get_post_type_object($type); - - if ( $type->public === false ) - return new WP_Error( 'json_cannot_read_type', __( 'Cannot view post type' ), array( 'status' => 403 ) ); - - $data = array( - 'name' => $type->label, - 'slug' => $type->name, - 'description' => $type->description, - 'labels' => $type->labels, - 'queryable' => $type->publicly_queryable, - 'searchable' => ! $type->exclude_from_search, - 'hierarchical' => $type->hierarchical, - 'meta' => array( - 'links' => array() - ), - ); - - if ( $_in_collection ) - $data['meta']['links']['self'] = json_url( '/posts/types/' . $type->name ); - else - $data['meta']['links']['collection'] = json_url( '/posts/types' ); - - if ( $type->publicly_queryable ) { - if ($type->name === 'post') - $data['meta']['links']['archives'] = json_url( '/posts' ); - else - $data['meta']['links']['archives'] = json_url( add_query_arg( 'type', $type->name, '/posts' ) ); - } - - return apply_filters( 'json_post_type_data', $data, $type ); - } - - /** - * Get the registered post statuses - * - * @return array List of post status data - */ - public function getPostStatuses() { - $statuses = get_post_stati(array(), 'objects'); - - $data = array(); - foreach ($statuses as $status) { - if ( $status->internal === true || ! $status->show_in_admin_status_list ) - continue; - - $data[ $status->name ] = array( - 'name' => $status->label, - 'slug' => $status->name, - 'public' => $status->public, - 'protected' => $status->protected, - 'private' => $status->private, - 'queryable' => $status->publicly_queryable, - 'show_in_list' => $status->show_in_admin_all_list, - 'meta' => array( - 'links' => array() - ), - ); - if ( $status->publicly_queryable ) { - if ($status->name === 'publish') - $data[ $status->name ]['meta']['links']['archives'] = json_url( '/posts' ); - else - $data[ $status->name ]['meta']['links']['archives'] = json_url( add_query_arg( 'status', $status->name, '/posts' ) ); - } - } - - return apply_filters( 'json_post_statuses', $data, $statuses ); - } - - /** - * Prepares post data for return in an XML-RPC object. - * - * @access protected - * - * @param array $post The unprepared post data - * @param array $fields The subset of post type fields to return - * @return array The prepared post data - */ - protected function prepare_post( $post, $context = 'view' ) { - // holds the data for this post. built up based on $fields - $_post = array( - 'ID' => (int) $post['ID'], - ); - - $post_type = get_post_type_object( $post['post_type'] ); - if ( ! $this->checkReadPermission( $post ) ) - return new WP_Error( 'json_user_cannot_read', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) ); - - // prepare common post fields - $post_fields = array( - 'title' => get_the_title( $post['ID'] ), // $post['post_title'], - 'status' => $post['post_status'], - 'type' => $post['post_type'], - 'author' => (int) $post['post_author'], - 'content' => apply_filters( 'the_content', $post['post_content'] ), - 'parent' => (int) $post['post_parent'], - #'post_mime_type' => $post['post_mime_type'], - 'link' => get_permalink( $post['ID'] ), - ); - $post_fields_extended = array( - 'slug' => $post['post_name'], - 'guid' => apply_filters( 'get_the_guid', $post['guid'] ), - 'excerpt' => $this->prepare_excerpt( $post['post_excerpt'] ), - 'menu_order' => (int) $post['menu_order'], - 'comment_status' => $post['comment_status'], - 'ping_status' => $post['ping_status'], - 'sticky' => ( $post['post_type'] === 'post' && is_sticky( $post['ID'] ) ), - ); - $post_fields_raw = array( - 'title_raw' => $post['post_title'], - 'content_raw' => $post['post_content'], - 'guid_raw' => $post['guid'], - ); - - // Dates - $timezone = $this->server->get_timezone(); - - $date = DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_date'], $timezone ); - $post_fields['date'] = $date->format( 'c' ); - $post_fields_extended['date_tz'] = $date->format( 'e' ); - $post_fields_extended['date_gmt'] = date( 'c', strtotime( $post['post_date_gmt'] ) ); - - $modified = DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_modified'], $timezone ); - $post_fields['modified'] = $modified->format( 'c' ); - $post_fields_extended['modified_tz'] = $modified->format( 'e' ); - $post_fields_extended['modified_gmt'] = date( 'c', strtotime( $post['post_modified_gmt'] ) ); - - // Authorized fields - // TODO: Send `Vary: Authorization` to clarify that the data can be - // changed by the user's auth status - if ( current_user_can( $post_type->cap->edit_post, $post['ID'] ) ) { - $post_fields_extended['password'] = $post['post_password']; - } - - // Consider future posts as published - if ( $post_fields['status'] === 'future' ) - $post_fields['status'] = 'publish'; - - // Fill in blank post format - $post_fields['format'] = get_post_format( $post['ID'] ); - if ( empty( $post_fields['format'] ) ) - $post_fields['format'] = 'standard'; - - $post_fields['author'] = $this->prepare_author( $post['post_author'] ); - - if ( 'view' === $context && 0 !== $post['post_parent'] ) { - // Avoid nesting too deeply - // This gives post + post-extended + meta for the main post, - // post + meta for the parent and just meta for the grandparent - $parent = get_post( $post['post_parent'], ARRAY_A ); - $post_fields['parent'] = $this->prepare_post( $parent, 'parent' ); - } - - // Merge requested $post_fields fields into $_post - $_post = array_merge( $_post, $post_fields ); - - // Include extended fields. We might come back to this. - $_post = array_merge( $_post, $post_fields_extended ); - - if ( 'edit' === $context && current_user_can( $post_type->cap->edit_post, $post['ID'] ) ) - $_post = array_merge( $_post, $post_fields_raw ); - elseif ( 'edit' === $context ) - return new WP_Error( 'json_cannot_edit', __( 'Sorry, you cannot edit this post' ), array( 'status' => 403 ) ); - - // Post meta - $_post['post_meta'] = $this->prepare_meta( $post['ID'] ); - - // Entity meta - $_post['meta'] = array( - 'links' => array( - 'self' => json_url( '/posts/' . $post['ID'] ), - 'author' => json_url( '/users/' . $post['post_author'] ), - 'collection' => json_url( '/posts' ), - 'replies' => json_url( '/posts/' . $post['ID'] . '/comments' ), - 'version-history' => json_url( '/posts/' . $post['ID'] . '/revisions' ), - ), - ); - - if ( ! empty( $post['post_parent'] ) ) - $_post['meta']['links']['up'] = json_url( '/posts/' . (int) $post['post_parent'] ); - - return apply_filters( 'json_prepare_post', $_post, $post, $context ); - } - - /** - * Retrieve the post excerpt. - * - * @return string - */ - protected function prepare_excerpt( $excerpt ) { - if ( post_password_required() ) { - return __( 'There is no excerpt because this is a protected post.' ); - } - - return apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $excerpt ) ); - } - - /** - * Retrieve custom fields for post. - * - * @since 2.5.0 - * - * @param int $post_id Post ID. - * @return array Custom fields, if exist. - */ - protected function prepare_meta( $post_id ) { - $post_id = (int) $post_id; - - $custom_fields = array(); - - foreach ( (array) has_meta( $post_id ) as $meta ) { - // Don't expose protected fields. - if ( is_protected_meta( $meta['meta_key'] ) ) - continue; - - $custom_fields[] = array( - 'id' => $meta['meta_id'], - 'key' => $meta['meta_key'], - 'value' => $meta['meta_value'], - ); - } - - return apply_filters( 'json_prepare_meta', $custom_fields ); - } - - protected function prepare_author( $author ) { - $user = get_user_by( 'id', $author ); - - if (!$author) - return null; - - $author = array( - 'ID' => $user->ID, - 'name' => $user->display_name, - 'slug' => $user->user_nicename, - 'URL' => $user->user_url, - 'avatar' => $this->server->get_avatar( $user->user_email ), - 'meta' => array( - 'links' => array( - 'self' => json_url( '/users/' . $user->ID ), - 'archives' => json_url( '/users/' . $user->ID . '/posts' ), - ), - ), - ); - - if ( current_user_can( 'edit_user', $user->ID ) ) { - $author['first_name'] = $user->first_name; - $author['last_name'] = $user->last_name; - } - return $author; - } - - /** - * Helper method for wp_newPost and wp_editPost, containing shared logic. - * - * @since 3.4.0 - * @uses wp_insert_post() - * - * @param WP_User $user The post author if post_author isn't set in $content_struct. - * @param array $content_struct Post data to insert. - */ - protected function insert_post( $data ) { - $post = array(); - $update = ! empty( $data['ID'] ); - - if ( $update ) { - $current_post = get_post( absint( $data['ID'] ) ); - if ( ! $current_post ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 400 ) ); - $post['ID'] = absint( $data['ID'] ); - } - else { - // Defaults - $post['post_author'] = 0; - $post['post_password'] = ''; - $post['post_excerpt'] = ''; - $post['post_content'] = ''; - $post['post_title'] = ''; - } - - // Post type - if ( ! empty( $data['type'] ) ) { - // Changing post type - $post_type = get_post_type_object( $data['type'] ); - if ( ! $post_type ) - return new WP_Error( 'json_invalid_post_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); - - $post['post_type'] = $data['type']; - } - elseif ( $update ) { - // Updating post, use existing post type - $current_post = get_post( $data['ID'] ); - if ( ! $current_post ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 400 ) ); - - $post_type = get_post_type_object( $current_post->post_type ); - } - else { - // Creating new post, use default type - $post['post_type'] = apply_filters( 'json_insert_default_post_type', 'post' ); - $post_type = get_post_type_object( $post['post_type'] ); - if ( ! $post_type ) - return new WP_Error( 'json_invalid_post_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); - } - - // Permissions check - if ( $update ) { - if ( ! current_user_can( $post_type->cap->edit_post, $data['ID'] ) ) - return new WP_Error( 'json_cannot_edit', __( 'Sorry, you are not allowed to edit this post.' ), array( 'status' => 401 ) ); - if ( $post_type->name != get_post_type( $data['ID'] ) ) - return new WP_Error( 'json_cannot_change_post_type', __( 'The post type may not be changed.' ), array( 'status' => 400 ) ); - } else { - if ( ! current_user_can( $post_type->cap->create_posts ) || ! current_user_can( $post_type->cap->edit_posts ) ) - return new WP_Error( 'json_cannot_create', __( 'Sorry, you are not allowed to post on this site.' ), array( 'status' => 400 ) ); - } - - // Post status - if ( ! empty( $data['status'] ) ) { - $post['post_status'] = $data['status']; - switch ( $post['post_status'] ) { - case 'draft': - case 'pending': - break; - case 'private': - if ( ! current_user_can( $post_type->cap->publish_posts ) ) - return new WP_Error( 'json_cannot_create_private', __( 'Sorry, you are not allowed to create private posts in this post type' ), array( 'status' => 403 ) ); - break; - case 'publish': - case 'future': - if ( ! current_user_can( $post_type->cap->publish_posts ) ) - return new WP_Error( 'json_cannot_publish', __( 'Sorry, you are not allowed to publish posts in this post type' ), array( 'status' => 403 ) ); - break; - default: - if ( ! get_post_status_object( $post['post_status'] ) ) - $post['post_status'] = 'draft'; - break; - } - } - - // Post title - if ( ! empty( $data['title'] ) ) { - $post['post_title'] = $data['title']; - } - - // Post date - if ( ! empty( $data['date'] ) ) { - list( $post['post_date'], $post['post_date_gmt'] ) = $this->server->get_date_with_gmt( $data['date'] ); - } - elseif ( ! empty( $data['date_gmt'] ) ) { - list( $post['post_date'], $post['post_date_gmt'] ) = $this->server->get_date_with_gmt( $data['date_gmt'], true ); - } - - // Post modified - if ( ! empty( $data['modified'] ) ) { - list( $post['post_modified'], $post['post_modified_gmt'] ) = $this->server->get_date_with_gmt( $data['modified'] ); - } - elseif ( ! empty( $data['modified_gmt'] ) ) { - list( $post['post_modified'], $post['post_modified_gmt'] ) = $this->server->get_date_with_gmt( $data['modified_gmt'], true ); - } - - // Post slug - if ( ! empty( $data['name'] ) ) { - $post['post_name'] = $data['name']; - } - - // Author - if ( ! empty( $data['author'] ) ) { - // Allow passing an author object - if ( is_object( $data['author'] ) ) { - if ( empty( $data['author']->ID ) ) { - return new WP_Error( 'json_invalid_author', __( 'Invalid author object.' ), array( 'status' => 400 ) ); - } - $data['author'] = absint( $data['author']->ID ); - } - else { - $data['author'] = absint( $data['author'] ); - } - - // Only check edit others' posts if we are another user - if ( $data['author'] !== get_current_user_id() ) { - if ( ! current_user_can( $post_type->cap->edit_others_posts ) ) - return new WP_Error( 'json_cannot_edit_others', __( 'You are not allowed to edit posts as this user.' ), array( 'status' => 401 ) ); - - $author = get_userdata( $post['post_author'] ); - - if ( ! $author ) - return new WP_Error( 'json_invalid_author', __( 'Invalid author ID.' ), array( 'status' => 400 ) ); - } - } - - // Post password - if ( ! empty( $data['password'] ) ) { - $post['post_password'] = $data['password']; - if ( ! current_user_can( $post_type->cap->publish_posts ) ) - return new WP_Error( 'json_cannot_create_passworded', __( 'Sorry, you are not allowed to create password protected posts in this post type' ), array( 'status' => 401 ) ); - } - - // Content and excerpt - if ( ! empty( $data['content_raw'] ) ) { - $post['post_content'] = $data['content_raw']; - } - if ( ! empty( $data['excerpt_raw'] ) ) { - $post['post_excerpt'] = $data['excerpt_raw']; - } - - // Parent - if ( ! empty( $data['parent'] ) ) { - $parent = get_post( $data['parent'] ); - $post['post_parent'] = $data['post_parent']; - } - - // Menu order - if ( ! empty( $data['menu_order'] ) ) { - $post['menu_order'] = $data['menu_order']; - } - - // Comment status - if ( ! empty( $data['comment_status'] ) ) { - $post['comment_status'] = $data['comment_status']; - } - - // Ping status - if ( ! empty( $data['ping_status'] ) ) { - $post['ping_status'] = $data['ping_status']; - } - - // Post format - if ( ! empty( $data['post_format'] ) ) { - $formats = get_post_format_slugs(); - if ( ! in_array( $data['post_format'], $formats ) ) { - return new WP_Error( 'json_invalid_post_format', __( 'Invalid post format.' ), array( 'status' => 400 ) ); - } - $post['post_format'] = $data['post_format']; - } - - // Pre-insert hook - $can_insert = apply_filters( 'json_pre_insert_post', true, $post, $data, $update ); - if ( is_wp_error( $can_insert ) ) { - return $can_insert; - } - - // Post meta - // TODO: implement this - $post_ID = $update ? wp_update_post( $post, true ) : wp_insert_post( $post, true ); - - if ( is_wp_error( $post_ID ) ) { - return $post_ID; - } - - // Sticky - if ( isset( $post['sticky'] ) ) { - if ( $post['sticky'] ) - stick_post( $data['ID'] ); - else - unstick_post( $data['ID'] ); - } - - do_action( 'json_insert_post', $post, $data, $update ); - - return $post_ID; - } - - /** - * Parse an RFC3339 timestamp into a DateTime - * - * @param string $date RFC3339 timestamp - * @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ? - * @return DateTime - */ - protected function parse_date( $date, $force_utc = false ) { - // Default timezone to the server's current one - $timezone = self::get_timezone(); - if ( $force_utc ) { - $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); - $timezone = new DateTimeZone( 'UTC' ); - } - - // Strip millisecond precision (a full stop followed by one or more digits) - if ( strpos( $date, '.' ) !== false ) { - $date = preg_replace( '/\.\d+/', '', $date ); - } - $datetime = DateTime::createFromFormat( DateTime::RFC3339, $date ); - - return $datetime; - } - - /** - * Get a local date with its GMT equivalent, in MySQL datetime format - * - * @param string $date RFC3339 timestamp - * @param boolean $force_utc Should we force UTC timestamp? - * @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s) - */ - protected function get_date_with_gmt( $date, $force_utc = false ) { - $datetime = $this->server->parse_date( $date, $force_utc ); - - $datetime->setTimezone( self::get_timezone() ); - $local = $datetime->format( 'Y-m-d H:i:s' ); - - $datetime->setTimezone( new DateTimeZone( 'UTC' ) ); - $utc = $datetime->format('Y-m-d H:i:s'); - - return array( $local, $utc ); - } - - /** - * Retrieve the avatar for a user who provided a user ID or email address. - * - * {@see get_avatar()} doesn't return just the URL, so we have to - * reimplement this here. - * - * @todo Rework how we do this. Copying it is a hack. - * - * @since 2.5 - * @param string $email Email address - * @return string tag for the user's avatar - */ - protected function get_avatar( $email ) { - if ( ! get_option( 'show_avatars' ) ) - return false; - - $email_hash = md5( strtolower( trim( $email ) ) ); - - if ( is_ssl() ) { - $host = 'https://secure.gravatar.com'; - } else { - if ( !empty($email) ) - $host = sprintf( 'http://%d.gravatar.com', ( hexdec( $email_hash[0] ) % 2 ) ); - else - $host = 'http://0.gravatar.com'; - } - - $avatar = "$host/avatar/$email_hash&d=404"; - - $rating = get_option( 'avatar_rating' ); - if ( !empty( $rating ) ) - $avatar .= "&r={$rating}"; - - return apply_filters( 'get_avatar', $avatar, $email, '96', '404', '' ); - } - - /** - * Prepares comment data for returning as a JSON response. - * - * @param stdClass $comment Comment object - * @param array $requested_fields Fields to retrieve from the comment - * @param string $context Where is the comment being loaded? - * @return array Comment data for JSON serialization - */ - protected function prepare_comment( $comment, $requested_fields = array( 'comment', 'meta' ), $context = 'single' ) { - $fields = array( - 'ID' => (int) $comment->comment_ID, - 'post' => (int) $comment->comment_post_ID, - ); - - $post = (array) get_post( $fields['post'] ); - - // Content - $fields['content'] = apply_filters( 'comment_text', $comment->comment_content, $comment ); - // $fields['content_raw'] = $comment->comment_content; - - // Status - switch ( $comment->comment_approved ) { - case 'hold': - case '0': - $fields['status'] = 'hold'; - break; - - case 'approve': - case '1': - $fields['status'] = 'approved'; - break; - - case 'spam': - case 'trash': - default: - $fields['status'] = $comment->comment_approved; - } - - // Type - $fields['type'] = apply_filters( 'get_comment_type', $comment->comment_type ); - if ( empty( $fields['type'] ) ) { - $fields['type'] = 'comment'; - } - - // Post - if ( 'single' === $context ) { - $parent = get_post( $post['post_parent'], ARRAY_A ); - $fields['parent'] = $this->prepare_post( $parent, 'single-parent' ); - } - - // Parent - if ( ( 'single' === $context || 'single-parent' === $context ) && (int) $comment->comment_parent ) { - $parent_fields = array( 'meta' ); - if ( $context === 'single' ) - $parent_fields[] = 'comment'; - $parent = get_comment( $post['post_parent'] ); - $fields['parent'] = $this->prepare_comment( $parent, $parent_fields, 'single-parent' ); - } - - // Parent - $fields['parent'] = (int) $comment->comment_parent; - - // Author - if ( (int) $comment->user_id !== 0 ) { - $fields['author'] = $this->prepare_author( (int) $comment->user_id ); - } - else { - $fields['author'] = array( - 'ID' => 0, - 'name' => $comment->comment_author, - 'URL' => $comment->comment_author_url, - 'avatar' => $this->server->get_avatar( $comment->comment_author_email ), - ); - } - - // Date - $timezone = $this->server->get_timezone(); - - $date = DateTime::createFromFormat( 'Y-m-d H:i:s', $comment->comment_date, $timezone ); - $fields['date'] = $date->format( 'c' ); - $fields['date_tz'] = $date->format( 'e' ); - $fields['date_gmt'] = date( 'c', strtotime( $comment->comment_date_gmt ) ); - - // Meta - $meta = array( - 'links' => array( - 'up' => json_url( sprintf( '/posts/%d', (int) $comment->comment_post_ID ) ) - ), - ); - if ( 0 !== (int) $comment->comment_parent ) { - $meta['links']['in-reply-to'] = json_url( sprintf( '/posts/%d/comments/%d', (int) $comment->comment_post_ID, (int) $comment->comment_parent ) ); - } - if ( 'single' !== $context ) { - $meta['links']['self'] = json_url( sprintf( '/posts/%d/comments/%d', (int) $comment->comment_post_ID, (int) $comment->comment_ID ) ); - } - - // Remove unneeded fields - $data = array(); - if ( in_array( 'comment', $requested_fields ) ) - $data = array_merge( $data, $fields ); - - if ( in_array( 'meta', $requested_fields ) ) - $data['meta'] = $meta; - - return $data; - } -} \ No newline at end of file From 1d113b3e397dd6c28136ba6e2eecca136c1492d8 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 3 Nov 2013 19:29:45 -0500 Subject: [PATCH 07/43] Add REST API endpoints/methods to WC API class --- includes/class-wc-api.php | 147 ++++++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 13 deletions(-) diff --git a/includes/class-wc-api.php b/includes/class-wc-api.php index ab69ece5a40..d1038b67886 100644 --- a/includes/class-wc-api.php +++ b/includes/class-wc-api.php @@ -2,36 +2,51 @@ /** * WooCommerce API * - * This API class handles the WC-API endpoint requests. + * Handles WC-API endpoint requests * - * @class WC_API - * @version 2.0.0 - * @package WooCommerce/Classes - * @category Class - * @author WooThemes + * @author WooThemes + * @category API + * @package WooCommerce/API + * @since 2.0 */ + +if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly + class WC_API { /** - * __construct function. + * Setup class * * @access public - * @return void + * @since 2.0 + * @return WC_API */ public function __construct() { + + // add query vars add_filter( 'query_vars', array( $this, 'add_query_vars'), 0 ); + + // register API endpoints add_action( 'init', array( $this, 'add_endpoint'), 0 ); - add_action( 'parse_request', array( $this, 'api_requests'), 0 ); + + // handle REST/legacy API request + add_action( 'parse_request', array( $this, 'handle_api_requests'), 0 ); + + // TODO: should this be done via this filter or in wp-json-server class? + add_filter( 'json_endpoints', array( $this, 'remove_users_endpoint' ), 0 ); } /** * add_query_vars function. * * @access public - * @return void + * @since 2.0 + * @param $vars + * @return array */ public function add_query_vars( $vars ) { $vars[] = 'wc-api'; + $vars[] = 'wc-api-route'; return $vars; } @@ -39,25 +54,64 @@ class WC_API { * add_endpoint function. * * @access public + * @since 2.0 * @return void */ public function add_endpoint() { + + // REST API + add_rewrite_rule( '^wc-api\/v1/?$', 'index.php?wc-api-route=/', 'top' ); + add_rewrite_rule( '^wc-api\/v1(.*)?', 'index.php?wc-api-route=$matches[1]', 'top' ); + + // legacy API for payment gateway IPNs add_rewrite_endpoint( 'wc-api', EP_ALL ); } + /** - * API request - Trigger any API requests (handy for third party plugins/gateways). + * API request - Trigger any API requests * * @access public + * @since 2.0 * @return void */ - public function api_requests() { + public function handle_api_requests() { global $wp; if ( ! empty( $_GET['wc-api'] ) ) $wp->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'] ) ) { + + // load required files + $this->includes(); + + define('XMLRPC_REQUEST', true); + + define('JSON_REQUEST', true); + + // TODO: should these filters/actions be renamed? + $wp_json_server_class = apply_filters('wp_json_server_class', 'WP_JSON_Server'); + + $this->server = new $wp_json_server_class; + + do_action('wp_json_server_before_serve', $this->server ); + + $this->register_resources( $this->server ); + + // Fire off the request + $this->server->serve_request( $wp->query_vars['wc-api-route'] ); + + exit; + } + + // legacy API requests if ( ! empty( $wp->query_vars['wc-api'] ) ) { + // Buffer, we won't want any output here ob_start(); @@ -76,4 +130,71 @@ 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( 'libraries/wp-api/class-wp-json-responsehandler.php' ); + include_once( 'libraries/wp-api/class-wp-json-server.php' ); + + include_once( 'api/class-wc-api-authentication.php' ); + $this->authentication = new WC_API_Authentication(); + + include_once( 'api/class-wc-api-base.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' ); + } + + /** + * 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 ); + } + } + + + /** + * Remove the users endpoints added by the JSON server + * + * @since 2.1 + * @param $endpoints + * @return array + */ + public function remove_users_endpoint( $endpoints ) { + + foreach ( $endpoints as $path => $endpoint ) { + + if ( ! strncmp( $path, '/user', 5 ) ) + unset( $endpoints[ $path ] ); + } + + return $endpoints; + } + +} From 45fa4507603f74c6e2b84d2bae5a6bf4812d7a1c Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 3 Nov 2013 20:06:28 -0500 Subject: [PATCH 08/43] Add REST API authentication class --- includes/api/class-wc-api-authentication.php | 288 +++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 includes/api/class-wc-api-authentication.php diff --git a/includes/api/class-wc-api-authentication.php b/includes/api/class-wc-api-authentication.php new file mode 100644 index 00000000000..3ea58347f5f --- /dev/null +++ b/includes/api/class-wc-api-authentication.php @@ -0,0 +1,288 @@ +api->server->path ) + return null; + + try { + + if ( is_ssl() ) + $user = $this->perform_ssl_authentication(); + else + $user = $this->perform_oauth_authentication(); + + } catch ( Exception $e ) { + + $user = new WP_Error( 'wc_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 secret key provided is valid + * + * @since 2.1 + * @return mixed + * @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( __( 'Secret Key is missing', 'woocommerce' ), 404 ); + + $consumer_key = $_SERVER['PHP_AUTH_USER']; + $secret_key = $_SERVER['PHP_AUTH_PW']; + + $user = $this->get_user_by_consumer_key( $consumer_key ); + + if ( ! $this->is_secret_key_valid( $user, $secret_key ) ) + throw new Exception( __( 'Secret Key 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/secret keys 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 + * + * @TODO create consumer documentation for generating nonce/signatures for requests + * + * @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 secret key provided for the given user is valid + * + * @since 2.1 + * @param WP_User $user + * @param $secret_key + * @return bool + */ + private function is_secret_key_valid( WP_User $user, $secret_key ) { + + // TODO: consider hashing secret key prior to storing it using wp_hash_password(), but this would prevent user from seeing it more than once + return $user->woocommerce_api_secret_key === $secret_key; + } + + /** + * Verify that the consumer-provided request signature matches our generated signature, this ensures the consumer + * has a valid key/secret key + * + * @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; + } + $query_string = implode( '%26', $query_params ); + + $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_secret_key, 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 $key + * @param $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' ) ); + + $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 ); + } + +} From 7caffcd8f2df1b901788b8674c7c3937f715709c Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 3 Nov 2013 20:06:36 -0500 Subject: [PATCH 09/43] Add REST API base class --- includes/api/class-wc-api-base.php | 171 +++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 includes/api/class-wc-api-base.php diff --git a/includes/api/class-wc-api-base.php b/includes/api/class-wc-api-base.php new file mode 100644 index 00000000000..3a2288c5f50 --- /dev/null +++ b/includes/api/class-wc-api-base.php @@ -0,0 +1,171 @@ +server = $server; + + // automatically register routes for sub-classes + add_filter( 'json_endpoints', array( $this, 'registerRoutes' ) ); + + // remove fields from responses when requests specify certain fields + add_filter( 'woocommerce_api_coupon_response', array( $this, 'filterFields' ), 0, 3 ); + } + + + /** + * 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 mergeQueryArgs( $base_args, $request_args ) { + + $args = array(); + + // TODO: modified_at_min, modified_at_max, date formatting + // TODO: WP 3.7 is required to support date args + if ( ! empty( $request_args['created_at_min'] ) || ! empty( $request_args['created_at_max'] ) ) { + + $args['date_query'] = array( + array( + 'inclusive' => true, + ) + ); + + if ( ! empty( $request_args['created_at_min'] ) ) + $args['date_query'] = array_merge( $args['date_query'], array( 'after' => $request_args['created_at_min'] ) ); + + if ( ! empty( $request_args['created_at_max'] ) ) + $args['date_query'] = array_merge( $args['date_query'], array( 'before' => $request_args['created_at_min'] ) ); + } + + // 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']; + + return array_merge( $base_args, $args ); + } + + + // TODO: this should also work with sub-resources, like product.id + /** + * 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 mixed + */ + public function filterFields( $data, $resource, $fields ) { + + $fields = explode( ',', $fields ); + + if ( empty( $fields ) ) + return $data; + + foreach ( $data as $data_field => $data_value ) { + + if ( ! in_array( $data_field, $fields ) ) + unset( $data[ $data_field ] ); + } + + return $data; + } + + /** + * Delete a given resource + * + * @see WP_JSON_Posts::deletePost + * + * @since 2.1 + * @param int $id the resource ID + * @param string $type the type of resource, either `order`,`coupon`, `product`, 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 deleteResource( $id, $type, $force ) { + + $id = absint( $id ); + + if ( empty( $id ) ) + return new WP_Error( 'woocommerce_api_invalid_id', sprintf( __( 'Invalid %s ID', 'woocommerce' ), $type ), array( 'status' => 404 ) ); + + 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 + + $post = get_post( $id, ARRAY_A ); + + if ( empty( $post['ID'] ) ) + return new WP_Error( 'woocommerce_api_invalid_id', sprintf( __( 'Invalid % ID', 'woocommerce' ), $type ), array( 'status' => 404 ) ); + + $post_type = get_post_type_object( $post['post_type'] ); + + if ( ! current_user_can( $post_type->cap->delete_post, $id ) ) + return new WP_Error( "woocommerce_api_user_cannot_delete_{$type}", sprintf( __( 'You do not have permission to delete this %s', 'woocommerce' ), $type ), array( 'status' => 401 ) ); + + $result = wp_delete_post( $id, $force ); + + if ( ! $result ) + return new WP_Error( "woocommerce_api_cannot_delete_{$type}", sprintf( __( 'The %s cannot be deleted', 'woocommerce' ), $type ), array( 'status' => 500 ) ); + + if ( $force ) { + return array( 'message' => sprintf( __( 'Permanently deleted %s', 'woocommerce' ), $type ) ); + + } else { + + $this->server->send_status( '202' ); + + return array( 'message' => sprintf( __( 'Deleted %s', 'woocommerce' ), $type ) ); + } + } + } + +} From 2bb83bb1d89f63c5dd8a7b2b31b6877fd2b7e957 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Mon, 4 Nov 2013 01:34:21 -0500 Subject: [PATCH 10/43] Make WC_Comments::exclude_order_comments static The WC_Comments class is an orphaned global so there's no way for other code to access it in order to remove the `comment_clauses` filter. This changes the add_filter and associated method to static so other code can properly remove the filter for querying order notes --- includes/class-wc-comments.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/includes/class-wc-comments.php b/includes/class-wc-comments.php index 11ef46685c7..9543e5fc509 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(); From 25407c284e9f8ddbd642ed70da5e4d1cbd0341e1 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Mon, 4 Nov 2013 01:34:53 -0500 Subject: [PATCH 11/43] Trash posts by default instead of permanently deleting --- includes/api/class-wc-api-base.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/api/class-wc-api-base.php b/includes/api/class-wc-api-base.php index 3a2288c5f50..7062afde5fc 100644 --- a/includes/api/class-wc-api-base.php +++ b/includes/api/class-wc-api-base.php @@ -151,7 +151,7 @@ class WC_API_Base { if ( ! current_user_can( $post_type->cap->delete_post, $id ) ) return new WP_Error( "woocommerce_api_user_cannot_delete_{$type}", sprintf( __( 'You do not have permission to delete this %s', 'woocommerce' ), $type ), array( 'status' => 401 ) ); - $result = wp_delete_post( $id, $force ); + $result = ( $force ) ? wp_delete_post( $id, true ) : wp_trash_post( $id ); if ( ! $result ) return new WP_Error( "woocommerce_api_cannot_delete_{$type}", sprintf( __( 'The %s cannot be deleted', 'woocommerce' ), $type ), array( 'status' => 500 ) ); From 27163ceec0306bf1cbf965453657b0d025379e79 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Mon, 4 Nov 2013 01:36:16 -0500 Subject: [PATCH 12/43] REST API base class tweaks --- includes/api/class-wc-api-base.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/includes/api/class-wc-api-base.php b/includes/api/class-wc-api-base.php index 7062afde5fc..1950e197aa8 100644 --- a/includes/api/class-wc-api-base.php +++ b/includes/api/class-wc-api-base.php @@ -35,7 +35,12 @@ class WC_API_Base { add_filter( 'json_endpoints', array( $this, 'registerRoutes' ) ); // remove fields from responses when requests specify certain fields - add_filter( 'woocommerce_api_coupon_response', array( $this, 'filterFields' ), 0, 3 ); + // 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 + add_filter( 'woocommerce_api_order_response', array( $this, 'filterFields' ), 20, 3 ); + add_filter( 'woocommerce_api_coupon_response', array( $this, 'filterFields' ), 20, 3 ); + add_filter( 'woocommerce_api_customer_response', array( $this, 'filterFields' ), 20, 3 ); + add_filter( 'woocommerce_api_product_response', array( $this, 'filterFields' ), 20, 3 ); } @@ -51,7 +56,7 @@ class WC_API_Base { $args = array(); - // TODO: modified_at_min, modified_at_max, date formatting + // TODO: updated_at_min, updated_at_max,s date formatting // TODO: WP 3.7 is required to support date args if ( ! empty( $request_args['created_at_min'] ) || ! empty( $request_args['created_at_max'] ) ) { @@ -83,11 +88,11 @@ class WC_API_Base { return array_merge( $base_args, $args ); } - - // TODO: this should also work with sub-resources, like product.id /** * Restrict the fields included in the response if the request specified certain only certain fields should be returned * + * @TODO this should also work with sub-fields, like billing_address.country + * * @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 @@ -96,11 +101,11 @@ class WC_API_Base { */ public function filterFields( $data, $resource, $fields ) { - $fields = explode( ',', $fields ); - if ( empty( $fields ) ) return $data; + $fields = explode( ',', $fields ); + foreach ( $data as $data_field => $data_value ) { if ( ! in_array( $data_field, $fields ) ) @@ -121,7 +126,7 @@ class WC_API_Base { * @param bool $force true to permanently delete resource, false to move to trash (not supported for `customer`) * @return array|WP_Error */ - protected function deleteResource( $id, $type, $force ) { + protected function deleteResource( $id, $type, $force = false ) { $id = absint( $id ); @@ -143,6 +148,8 @@ class WC_API_Base { $post = get_post( $id, ARRAY_A ); + // TODO: check if provided $type is the same as $post['post_type'] + if ( empty( $post['ID'] ) ) return new WP_Error( 'woocommerce_api_invalid_id', sprintf( __( 'Invalid % ID', 'woocommerce' ), $type ), array( 'status' => 404 ) ); From e2195859746821b71d1a7b2095c70b9dd7f3c1e3 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Mon, 4 Nov 2013 01:36:31 -0500 Subject: [PATCH 13/43] Add initial versions of REST API resource classes --- includes/api/class-wc-api-coupons.php | 216 +++++++++++++ includes/api/class-wc-api-customers.php | 344 ++++++++++++++++++++ includes/api/class-wc-api-orders.php | 398 ++++++++++++++++++++++++ includes/api/class-wc-api-products.php | 226 ++++++++++++++ includes/api/class-wc-api-reports.php | 72 +++++ 5 files changed, 1256 insertions(+) create mode 100644 includes/api/class-wc-api-coupons.php create mode 100644 includes/api/class-wc-api-customers.php create mode 100644 includes/api/class-wc-api-orders.php create mode 100644 includes/api/class-wc-api-products.php create mode 100644 includes/api/class-wc-api-reports.php diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php new file mode 100644 index 00000000000..62d29af1d88 --- /dev/null +++ b/includes/api/class-wc-api-coupons.php @@ -0,0 +1,216 @@ + + * + * @since 2.1 + * @param array $routes + * @return array + */ + public function registerRoutes( $routes ) { + + # GET|POST /coupons + $routes[ $this->base ] = array( + array( array( $this, 'getCoupons' ), WP_JSON_Server::READABLE ), + array( array( $this, 'createCoupon' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + ); + + # GET /coupons/count + $routes[ $this->base . '/count'] = array( + array( array( $this, 'getCouponsCount' ), WP_JSON_SERVER::READABLE ), + ); + + # GET|PUT|DELETE /coupons/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'getCoupon' ), WP_JSON_Server::READABLE ), + array( array( $this, 'editCoupon' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'deleteCoupon' ), WP_JSON_Server::DELETABLE ), + ); + + return $routes; + } + + /** + * Get all coupons + * + * @TODO should we support an extra "code" param for lookup instead of "q" which searches both title & description? + * + * @since 2.1 + * @param string $fields + * @param string $created_at_min + * @param string $created_at_max + * @param string $q search terms + * @param int $limit coupons per response + * @param int $offset + * @return array + */ + public function getCoupons( $fields = null, $created_at_min = null, $created_at_max = null, $q = null, $limit = null, $offset = null ) { + + $request_args = array( + 'created_at_min' => $created_at_min, + 'created_at_max' => $created_at_max, + 'q' => $q, + 'limit' => $limit, + 'offset' => $offset, + ); + + $query = $this->queryCoupons( $request_args ); + + $coupons = array(); + + foreach( $query->posts as $coupon_id ) { + + $coupons[] = $this->getCoupon( $coupon_id, $fields ); + } + + 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 + */ + public function getCoupon( $id, $fields = null ) { + global $wpdb; + + // 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 ( ! $code ) + return new WP_Error( 'wc_api_invalid_coupon_id', __( 'Invalid coupon ID', 'woocommerce' ), array( 'status' => 404 ) ); + + $coupon = new WC_Coupon( $code ); + + // TODO: how to implement coupon meta? + $coupon_data = array( + 'id' => $coupon->id, + 'code' => $coupon->code, + 'type' => $coupon->type, + 'amount' => $coupon->amount, // TODO: should this be formatted? + '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' => $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 ); + } + + /** + * Get the total number of coupons + * + * @since 2.1 + * @param string $created_at_min + * @param string $created_at_max + * @return array + */ + public function getCouponsCount( $created_at_min = null, $created_at_max = null ) { + + $query = $this->queryCoupons( array( 'created_at_min' => $created_at_min, 'created_at_max' => $created_at_max ) ); + + return array( 'count' => $query->found_posts ); + } + + /** + * Create a coupon + * + * @since 2.1 + * @param array $data + * @return array + */ + public function createCoupon( $data ) { + + // 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 editCoupon( $id, $data ) { + + // TODO: implement + return $this->getCoupon( $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 deleteCoupon( $id, $force = false ) { + + return $this->deleteResource( $id, 'coupon', ( 'true' === $force ) ); + } + + /** + * Helper method to get coupon post objects + * + * @since 2.1 + * @param array $args request arguments for filtering query + * @return array + */ + private function queryCoupons( $args ) { + + // set base query arguments + $query_args = array( + 'fields' => 'ids', + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'orderby' => 'title', + ); + + $query_args = $this->mergeQueryArgs( $query_args, $args ); + + // TODO: navigation/total count headers for pagination + + 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..d00cab403fb --- /dev/null +++ b/includes/api/class-wc-api-customers.php @@ -0,0 +1,344 @@ + + * GET /customers//orders + * + * @since 2.1 + * @param array $routes + * @return array + */ + public function registerRoutes( $routes ) { + + # GET|POST /customers + $routes[ $this->base ] = array( + array( array( $this, 'getCustomers' ), WP_JSON_Server::READABLE ), + array( array( $this, 'createCustomer' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + ); + + # GET /customers/count + $routes[ $this->base . '/count'] = array( + array( array( $this, 'getCustomersCount' ), WP_JSON_SERVER::READABLE ), + ); + + # GET|PUT|DELETE /customers/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'getCustomer' ), WP_JSON_Server::READABLE ), + array( array( $this, 'editCustomer' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'deleteCustomer' ), WP_JSON_Server::DELETABLE ), + ); + + # GET /customers//orders + $routes[ $this->base . '/(?P\d+)/orders' ] = array( + array( array( $this, 'getCustomerOrders' ), WP_JSON_Server::READABLE ), + ); + + return $routes; + } + + /** + * Get all customers + * + * @TODO support created_at_min/created_at_max with pre_user_query filter + * + * @since 2.1 + * @param array $fields + * @param string $q search terms + * @param int $limit coupons per response + * @param int $offset + * @return array + */ + public function getCustomers( $fields = null, $q = null, $limit = null, $offset = null ) { + + $request_args = array( + 'q' => $q, + 'limit' => $limit, + 'offset' => $offset, + ); + + $query = $this->queryCustomers( $request_args ); + + $customers = array(); + + foreach( $query->results as $user_id ) { + + $customers[] = $this->getCustomer( $user_id, $fields ); + } + + return array( 'customers' => $customers ); + } + + + /** + * Get the customer for the given ID + * + * @TODO: implement customer meta + * + * @since 2.1 + * @param int $id the customer ID + * @param string $fields + * @return array + */ + public function getCustomer( $id, $fields = null ) { + global $wpdb; + + $id = absint( $id ); + + if ( empty( $id ) ) + return new WP_Error( 'woocommerce_api_invalid_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 ) ); + + // get info about user's last order + $last_order = $wpdb->get_row( "SELECT id, post_date + 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' => $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 ) ? $last_order->post_date : null, + 'orders_count' => $customer->_order_count, + 'total_spent' => $customer->_money_spent, + 'avatar_url' => $this->server->get_avatar( $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 ); + } + + /** + * Get the total number of customers + * + * @TODO support created_at_min/created_at_max with pre_user_query filter + * + * @since 2.1 + * @return array + */ + public function getCustomersCount() { + + $query = $this->queryCustomers(); + + return array( 'count' => $query->get_total() ); + } + + + /** + * Create a customer + * + * @since 2.1 + * @param array $data + * @return array + */ + public function createCustomer( $data ) { + + // TODO: implement - what's the minimum set of data required? + // 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 editCustomer( $id, $data ) { + + // TODO: implement + return $this->getCustomer( $id ); + } + + /** + * Delete a customer + * + * @since 2.1 + * @param int $id the customer ID + * @return array + */ + public function deleteCustomer( $id ) { + + return $this->deleteResource( $id, 'customer' ); + } + + /** + * Get the orders for a customer + * + * @TODO should this support the same parameters as getOrders call? e.g. fields, created_at, pagination, etc + * + * @since 2.1 + * @param int $id the customer ID + * @return array + */ + public function getCustomerOrders( $id ) { + global $wpdb; + + // TODO: DRY this along with duplicate code in getCustomer() + $id = absint( $id ); + + if ( empty( $id ) ) + return new WP_Error( 'woocommerce_api_invalid_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 ) ); + + $order_ids = $wpdb->get_col( "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 = {$id} + AND posts.post_type = 'shop_order' + AND posts.post_status = 'publish' + " ); + + if ( empty( $order_ids ) ) + return array( 'orders' => array() ); + + $orders = array(); + + foreach ( $order_ids as $order_id ) { + $orders[] = WC()->api->WC_API_Orders->getOrder( $order_id ); + } + + return array( 'orders' => $orders ); + } + + /** + * Helper method to get customer user objects + * + * @since 2.1 + * @param array $args request arguments for filtering query + * @return array + */ + private function queryCustomers( $args = array() ) { + + // set base query arguments + $query_args = array( + 'fields' => 'ID', + 'role' => 'customer', + 'orderby' => 'registered', + 'order' => 'DESC', + ); + + // TODO: refactor WP_API_Base::mergeQueryVars to support user query args + + if ( ! empty( $args['q'] ) ) + $query_args['search'] = $args['q']; + + if ( ! empty( $args['limit'] ) ) + $query_args['number'] = $args['limit']; + + if ( ! empty( $args['offset'] ) ) + $query_args['offset'] = $args['offset']; + + // TODO: navigation/total count headers for pagination + + return new WP_User_Query( $query_args ); + } + + + /** + * Add customer data to orders + * + * @TODO should guest orders return more than 'guest'? + * + * @since 2.1 + * @param $order_data + * @param $order + * + */ + public function addCustomerData( $order_data, $order ) { + + if ( 0 == $order->customer_user ) { + + $order_data['customer'] = 'guest'; + + } else { + + $order_data['customer'] = $this->getCustomer( $order->customer_user ); + } + + return $order_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..843855bca4c --- /dev/null +++ b/includes/api/class-wc-api-orders.php @@ -0,0 +1,398 @@ + + * GET /orders//notes + * + * @since 2.1 + * @param array $routes + * @return array + */ + public function registerRoutes( $routes ) { + + # GET|POST /orders + $routes[ $this->base ] = array( + array( array( $this, 'getOrders' ), WP_JSON_Server::READABLE ), + array( array( $this, 'createOrder' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + ); + + # GET /orders/count + $routes[ $this->base . '/count'] = array( + array( array( $this, 'getOrdersCount' ), WP_JSON_SERVER::READABLE ), + ); + + # GET|PUT|DELETE /orders/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'getOrder' ), WP_JSON_Server::READABLE ), + array( array( $this, 'editOrder' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'deleteOrder' ), WP_JSON_Server::DELETABLE ), + ); + + # GET /orders//notes + $routes[ $this->base . '/(?P\d+)/notes' ] = array( + array( array( $this, 'getOrderNotes' ), WP_JSON_Server::READABLE ), + ); + + return $routes; + } + + /** + * Get all orders + * + * @since 2.1 + * @param array $fields + * @param string $status + * @param string $created_at_min + * @param string $created_at_max + * @param string $updated_at_min + * @param string $updated_at_max + * @param string $q search terms + * @param int $limit coupons per response + * @param int $offset + * @return array + */ + public function getOrders( $fields = array(), $status = null, $created_at_min = null, $created_at_max = null, $updated_at_min = null, $updated_at_max = null, $q = null, $limit = null, $offset = null ) { + + $request_args = array( + 'status' => $status, + 'created_at_min' => $created_at_min, + 'created_at_max' => $created_at_max, + 'updated_at_min' => $updated_at_min, + 'updated_at_max' => $updated_at_max, + 'q' => $q, + 'limit' => $limit, + 'offset' => $offset, + ); + + $query = $this->queryOrders( $request_args ); + + $orders = array(); + + foreach( $query->posts as $order_id ) { + + $orders[] = $this->getOrder( $order_id, $fields ); + } + + 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 getOrder( $id, $fields = null ) { + + $id = absint( $id ); + + if ( empty( $id ) ) + return new WP_Error( 'woocommerce_api_invalid_id', __( 'Invalid order ID', 'woocommerce' ), array( 'status' => 404 ) ); + + // invalid IDs return a valid WC_Order object with customer_user equal to a blank string + $order = new WC_Order( $id ); + + // TODO: check post type instead or abstract into generic object/permissions check in base class @see self::getOrderNotes() + if ( '' === $order->customer_user ) + return new WP_Error( 'woocommerce_api_invalid_order', __( 'Invalid order', 'woocommerce' ), array( 'status' => 404 ) ); + + $order_data = array( + 'id' => $order->id, + 'order_number' => $order->get_order_number(), + 'created_at' => $order->order_date, + 'updated_at' => $order->modified_date, + 'completed_at' => $order->completed_date, + 'status' => $order->status, + 'currency' => $order->order_currency, + 'total' => $order->get_total(), + 'total_line_items_quantity' => $order->get_item_count(), + 'total_tax' => $order->get_total_tax(), + 'total_shipping' => $order->get_total_shipping(), + 'cart_tax' => $order->get_cart_tax(), + 'shipping_tax' => $order->get_shipping_tax(), + 'total_discount' => $order->get_total_discount(), + 'cart_discount' => $order->get_cart_discount(), + 'order_discount' => $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(), + ); + + // 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' => $order->get_line_subtotal( $item ), + 'total' => $order->get_line_total( $item ), + 'total_tax' => $order->get_line_tax( $item ), + 'quantity' => $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' => $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' => $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' => $order->get_line_total( $fee_item ), + 'total_tax' => $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' => $coupon_item['discount_amount'], + ); + } + + // ensure line properties exist in response + foreach ( array( 'line_items', 'shipping_lines', 'tax_lines', 'fee_lines', 'coupon_lines' ) as $line ) { + + if ( ! isset( $order_data[ $line ] ) ) + $order_data[ $line ] = array(); + } + + return apply_filters( 'woocommerce_api_order_response', $order_data, $order, $fields ); + } + + /** + * Get the total number of orders + * + * @since 2.1 + * @param string $status + * @param string $created_at_min + * @param string $created_at_max + * @param string $updated_at_min + * @param string $updated_at_max + * @return array + */ + public function getOrdersCount( $status = null, $created_at_min = null, $created_at_max = null, $updated_at_min = null, $updated_at_max = null ) { + + $request_args = array( + 'status' => $status, + 'created_at_min' => $created_at_min, + 'created_at_max' => $created_at_max, + 'updated_at_min' => $updated_at_min, + 'updated_at_max' => $updated_at_max, + ); + + $query = $this->queryOrders( $request_args ); + + return array( 'count' => $query->found_posts ); + } + + /** + * Create an order + * + * @since 2.1 + * @param array $data + * @return array + */ + public function createOrder( $data ) { + + // TODO: implement - a woocommerce_create_new_order() function would be great + + return array(); + } + + /** + * Edit an order + * + * @since 2.1 + * @param int $id the order ID + * @param array $data + * @return array + */ + public function editOrder( $id, $data ) { + + // TODO: implement + + return $this->getOrder( $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 deleteOrder( $id, $force = false ) { + + return $this->deleteResource( $id, 'order', ( 'true' === $force ) ); + } + + /** + * Get the admin order notes for an order + * @param $id + * @return mixed + */ + public function getOrderNotes( $id ) { + + $id = absint( $id ); + + if ( empty( $id ) ) + return new WP_Error( 'woocommerce_api_invalid_id', __( 'Invalid order ID', 'woocommerce' ), array( 'status' => 404 ) ); + + $post = get_post( $id, ARRAY_A ); + + if ( 'shop_order' !== $post['post_type'] ) + return new WP_Error( 'woocommerce_api_invalid_order', __( 'Invalid order', 'woocommerce' ), array( 'status' => 404 ) ); + + $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( + 'created_at' => $note->comment_date, + 'note' => $note->comment_content, + 'customer_note' => get_comment_meta( $note->comment_ID, 'is_customer_note', true ) ? true : false, + ); + } + + return array( 'order_notes' => $order_notes ); + } + + /** + * Helper method to get order post objects + * + * @since 2.1 + * @param array $args request arguments for filtering query + * @return array + */ + private function queryOrders( $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, + ), + ); + } + + $query_args = $this->mergeQueryArgs( $query_args, $args ); + + // TODO: navigation/total count headers for pagination + + 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..065049f9a47 --- /dev/null +++ b/includes/api/class-wc-api-products.php @@ -0,0 +1,226 @@ + + * GET /products//reviews + * + * @since 2.1 + * @param array $routes + * @return array + */ + public function registerRoutes( $routes ) { + + # GET|POST /products + $routes[ $this->base ] = array( + array( array( $this, 'getProducts' ), WP_JSON_Server::READABLE ), + array( array( $this, 'createProduct' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + ); + + # GET /products/count + $routes[ $this->base . '/count'] = array( + array( array( $this, 'getProductsCount' ), WP_JSON_SERVER::READABLE ), + ); + + # GET|PUT|DELETE /products/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'getProduct' ), WP_JSON_Server::READABLE ), + array( array( $this, 'editProduct' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'deleteProduct' ), WP_JSON_Server::DELETABLE ), + ); + + # GET /products//reviews + $routes[ $this->base . '/(?P\d+)/reviews' ] = array( + array( array( $this, 'getProductReviews' ), WP_JSON_Server::READABLE ), + ); + + return $routes; + } + + /** + * Get all products + * + * @since 2.1 + * @param array $fields + * @param string $type + * @param string $created_at_min + * @param string $created_at_max + * @param string $q search terms + * @param int $limit coupons per response + * @param int $offset + * @return array + */ + public function getProducts( $fields = null, $type = null, $created_at_min = null, $created_at_max = null, $q = null, $limit = null, $offset = null ) { + + $request_args = array( + 'type' => $type, + 'created_at_min' => $created_at_min, + 'created_at_max' => $created_at_max, + 'q' => $q, + 'limit' => $limit, + 'offset' => $offset, + ); + + $query = $this->queryProducts( $request_args ); + + $products = array(); + + foreach( $query->posts as $product_id ) { + + $products[] = $this->getProduct( $product_id, $fields ); + } + + 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 getProduct( $id, $fields = null ) { + + $id = absint( $id ); + + if ( empty( $id ) ) + return new WP_Error( 'woocommerce_api_invalid_id', __( 'Invalid product ID', 'woocommerce' ), array( 'status' => 404 ) ); + + $product = get_product( $id ); + + if ( 'product' !== $product->get_post_data()->post_type ) + return new WP_Error( 'woocommerce_api_invalid_product', __( 'Invalid product', 'woocommerce' ), array( 'status' => 404 ) ); + + $product_data = array( + 'id' => $product->id + ); + + return apply_filters( 'woocommerce_api_product_response', $product_data, $product, $fields ); + } + + /** + * Get the total number of orders + * + * @since 2.1 + * @param string $type + * @param string $created_at_min + * @param string $created_at_max + * @return array + */ + public function getProductsCount( $type = null, $created_at_min = null, $created_at_max = null ) { + + $request_args = array( + 'type' => $type, + 'created_at_min' => $created_at_min, + 'created_at_max' => $created_at_max, + ); + + $query = $this->queryProducts( $request_args ); + + return array( 'count' => $query->found_posts ); + } + + + /** + * Create a product + * + * @since 2.1 + * @param array $data + * @return array + */ + public function createProduct( $data ) { + + // TODO: implement - what's the minimum set of data required? woocommerce_create_product() would be nice + + return array(); + } + + /** + * Edit a product + * + * @since 2.1 + * @param int $id the product ID + * @param array $data + * @return array + */ + public function editProduct( $id, $data ) { + + // TODO: implement + + return $this->getProduct( $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 deleteProduct( $id, $force = false ) { + + return $this->deleteResource( $id, 'product', ( 'true' === $force ) ); + } + + /** + * Get the reviews for a product + * @param $id + * @return mixed + */ + public function getProductReviews( $id ) { + + return array(); + } + + /** + * Helper method to get product post objects + * + * @since 2.1 + * @param array $args request arguments for filtering query + * @return array + */ + private function queryProducts( $args ) { + + // set base query arguments + $query_args = array( + 'fields' => 'ids', + 'post_type' => 'product', + 'post_status' => 'publish', + 'post_parent' => 0, + 'meta_query' => array(), + ); + + // TODO: some param to show hidden products, but hide by default + $query_args['meta_query'][] = WC()->query->visibility_meta_query(); + + $query_args = $this->mergeQueryArgs( $query_args, $args ); + + // TODO: navigation/total count headers for pagination + + return new WP_Query( $query_args ); + } + +} diff --git a/includes/api/class-wc-api-reports.php b/includes/api/class-wc-api-reports.php new file mode 100644 index 00000000000..2e865dda725 --- /dev/null +++ b/includes/api/class-wc-api-reports.php @@ -0,0 +1,72 @@ +base ] = array( + array( array( $this, 'getReports' ), WP_JSON_Server::READABLE ), + ); + + # GET /reports/sales + $routes[ $this->base . '/sales'] = array( + array( array( $this, 'getSalesReport' ), WP_JSON_SERVER::READABLE ), + ); + + return $routes; + } + + + /** + * Get a simple listing of available reports + * + * @since 2.1 + * @return array + */ + public function getReports() { + + return array( 'reports' => array( 'sales' ) ); + } + + + /** + * Get the sales report + * + * @since 2.1 + * @return array + */ + public function getSalesReport() { + + // TODO: implement - DRY by abstracting the report classes? + + return array(); + } + +} From 30300bb2a0f30fdd30f63e12c659c2338112eda8 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Wed, 6 Nov 2013 01:23:19 -0500 Subject: [PATCH 14/43] Add get_woocommerce_api_url() method --- includes/wc-core-functions.php | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/includes/wc-core-functions.php b/includes/wc-core-functions.php index e19190112d0..df2c6fb7b57 100644 --- a/includes/wc-core-functions.php +++ b/includes/wc-core-functions.php @@ -336,32 +336,19 @@ function wc_setcookie( $name, $value, $expire = 0 ) { } } -// TODO: not sure if these should be moved to wp-json-server class and made WC-specific or not /** - * Get URL to a JSON endpoint on a site + * Get the URL to the WooCommerce REST API * - * @todo Check if this is even necessary - * @param int $blog_id Blog ID - * @param string $path JSON route - * @param string $scheme Sanitization scheme (usually 'json') - * @return string Full URL to the endpoint + * @since 2.1 + * @param string $path an endpoint to include in the URL + * @return string the URL */ -function get_json_url( $blog_id = null, $path = '', $scheme = 'json' ) { - $url = get_home_url( $blog_id, 'wc-api/v1/', $scheme ); +function get_woocommerce_api_url( $path ) { - if ( !empty( $path ) && is_string( $path ) && strpos( $path, '..' ) === false ) - $url .= '/' . ltrim( $path, '/' ); + $url = get_home_url( null, 'wc-api/v' . WC_API::VERSION . '/', ( 'yes' === get_option( 'woocommerce_force_ssl_checkout' ) ) ? 'https' : 'http' ); - return apply_filters( 'json_url', $url, $path, $blog_id ); -} - -/** - * Get URL to a JSON endpoint - * - * @param string $path JSON route - * @param string $scheme Sanitization scheme (usually 'json') - * @return string Full URL to the endpoint - */ -function json_url( $path = '', $scheme = 'json' ) { - return get_json_url( null, $path, $scheme ); + if ( ! empty( $path ) && is_string( $path ) ) + $url .= ltrim( $path, '/' ); + + return $url; } From 16259304825bf1c6a49736a1cbc6e6e15edb5ed3 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Wed, 6 Nov 2013 01:24:06 -0500 Subject: [PATCH 15/43] Remove WP-API library --- .../wp-api/class-wp-json-responsehandler.php | 45 -- .../libraries/wp-api/class-wp-json-server.php | 686 ------------------ 2 files changed, 731 deletions(-) delete mode 100755 includes/libraries/wp-api/class-wp-json-responsehandler.php delete mode 100644 includes/libraries/wp-api/class-wp-json-server.php diff --git a/includes/libraries/wp-api/class-wp-json-responsehandler.php b/includes/libraries/wp-api/class-wp-json-responsehandler.php deleted file mode 100755 index a3fe4b345ba..00000000000 --- a/includes/libraries/wp-api/class-wp-json-responsehandler.php +++ /dev/null @@ -1,45 +0,0 @@ - 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(); - - /** - * Check the authentication headers if supplied - * - * @return WP_Error|WP_User|null WP_User object indicates successful login, WP_Error indicates unsuccessful login and null indicates no authentication provided - */ - public function check_authentication() { - $user = apply_filters( 'json_check_authentication', null ); - if ( ! is_null( $user ) && ( is_a( $user, 'WP_User' ) || is_wp_error( $user ) ) ) { - wp_set_current_user( $user->ID ); - return $user; - } - - if ( !isset( $_SERVER['PHP_AUTH_USER'] ) ) - return; - - $username = $_SERVER['PHP_AUTH_USER']; - $password = $_SERVER['PHP_AUTH_PW']; - - $user = wp_authenticate( $username, $password ); - - if ( is_wp_error( $user ) ) - return $user; - - wp_set_current_user( $user->ID ); - 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 - * - * @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; - } - - /** - * Get an appropriate error representation in JSON - * - * Note: This should only be used in {@see WP_JSON_Server::serve_request()}, - * as it cannot handle WP_Error internally. All callbacks and other internal - * methods should instead return a WP_Error with the data set to an array - * that includes a 'status' key, with the value being the HTTP status to - * send. - * - * @param string $code WP_Error-style code - * @param string $message Human-readable message - * @param int $status HTTP status code to send - * @return string JSON representation of the error - */ - protected function json_error( $code, $message, $status = null ) { - if ( $status ) - $this->send_status( $status ); - - $error = compact( 'code', 'message' ); - return json_encode( array( $error ) ); - } - - /** - * 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. - * - * @uses WP_JSON_Server::dispatch() - */ - public function serve_request( $path = null ) { - $this->header( 'Content-Type', 'application/json; charset=' . get_option( 'blog_charset' ), true ); - - // Proper filter for turning off the JSON API. It is on by default. - $enabled = apply_filters( 'json_enabled', true ); - $jsonp_enabled = apply_filters( 'json_jsonp_enabled', true ); - - if ( ! $enabled ) { - echo $this->json_error( 'json_disabled', 'The JSON API is disabled on this site.', 404 ); - return false; - } - if ( isset($_GET['_jsonp']) ) { - if ( ! $jsonp_enabled ) { - echo $this->json_error( 'json_callback_disabled', 'JSONP support is disabled on this site.', 400 ); - return false; - } - - // Check for invalid characters (only alphanumeric allowed) - if ( preg_match( '/\W/', $_GET['_jsonp'] ) ) { - echo $this->json_error( 'json_callback_invalid', 'The JSONP callback function is invalid.', 400 ); - return false; - } - } - - 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'] ); - } - - $result = $this->check_authentication(); - - if ( ! is_wp_error( $result ) ) { - $result = $this->dispatch(); - } - - 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( 'json_serve_request', false, $result, $path, $this->method ); - - if ( ! $served ) { - if ( 'HEAD' === $this->method ) - return; - - if ( isset($_GET['_jsonp']) ) - echo $_GET['_jsonp'] . '(' . json_encode( $result ) . ')'; - else - echo json_encode( $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() - * - * @return array `'/path/regex' => array( $callback, $bitmask )` or `'/path/regex' => array( array( $callback, $bitmask ), ...)` - */ - public function getRoutes() { - $endpoints = array( - // Meta endpoints - '/' => array( array( $this, 'getIndex' ), self::READABLE ), - - // Users - '/users' => array( - array( '__return_null', self::READABLE ), - array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ), - ), - // /users/me is an alias, and simply redirects to /users/ - '/users/me' => array( '__return_null', self::ALLMETHODS ), - '/users/(?P\d+)' => array( - array( '__return_null', self::READABLE ), - array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ), - ), - ); - - $endpoints = apply_filters( 'json_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 - * - * @param string $path Requested route - * @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( 'json_unsupported_method', __( 'Unsupported request method' ), array( 'status' => 400 ) ); - } - foreach ( $this->getRoutes() 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', $this->path, $args ); - - if ( !$match ) - continue; - - if ( ! is_callable( $callback ) ) - return new WP_Error( 'json_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_JSON ) { - $data = json_decode( $this->get_raw_data(), true ); - $args = array_merge( $args, array( 'data' => $data ) ); - } - elseif ( $supported & self::ACCEPT_RAW ) { - $data = $this->get_raw_data(); - } - - $args['_method'] = $method; - $args['_route'] = $route; - $args['_path'] = $this->path; - $args['_headers'] = $this->headers; - $args['_files'] = $this->files; - - $args = apply_filters( 'json_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( 'json_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 - * - * @param callback $callback - * @param array $params - * @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[] = $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( 'json_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. - * - * @todo Should we generate text documentation too based on PHPDoc? - * - * @return array Index entity - */ - public function getIndex() { - // General site data - $available = array( - 'name' => get_option( 'blogname' ), - 'description' => get_option( 'blogdescription' ), - 'URL' => get_option( 'siteurl' ), - 'routes' => array(), - 'meta' => array( - 'links' => array( - 'help' => 'https://github.com/rmccue/WP-API', - 'profile' => 'https://raw.github.com/rmccue/WP-API/master/docs/schema.json', - ), - ), - ); - - // Find the available routes - foreach ( $this->getRoutes() 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_JSON ) - $data['accepts_json'] = true; - - // For non-variable routes, generate links - if ( strpos( $route, '<' ) === false ) { - $data['meta'] = array( - 'self' => json_url( $route ), - ); - } - } - } - $available['routes'][$route] = apply_filters( 'json_endpoints_description', $data ); - } - return apply_filters( 'json_index', $available ); - } - - /** - * Send a HTTP status code - * - * @param int $code HTTP status - */ - public function send_status( $code ) { - status_header( $code ); - } - - /** - * Send a HTTP header - * - * @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 - * - * @todo Make this safe for <>"';, - * @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 - * - * @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 navigation-related headers for post collections - * - * @param WP_Query $query - */ - public function query_navigation_headers( $query ) { - $max_page = $query->max_num_pages; - $paged = $query->get('paged'); - - if ( !$paged ) - $paged = 1; - - $nextpage = intval($paged) + 1; - - if ( ! $query->is_single() ) { - if ( $paged > 1 ) { - $request = remove_query_arg( 'page' ); - $request = add_query_arg( 'page', $paged - 1, $request ); - $this->link_header( 'prev', $request ); - } - - if ( $nextpage <= $max_page ) { - $request = remove_query_arg( 'page' ); - $request = add_query_arg( 'page', $nextpage, $request ); - $this->link_header( 'next', $request ); - } - } - - $this->header( 'X-WP-Total', $query->found_posts ); - $this->header( 'X-WP-TotalPages', $max_page ); - - do_action('json_query_navigation_headers', $this, $query); - } - - - /** - * Retrieve the raw request entity (body) - * - * @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 timestamp into a DateTime - * - * @param string $date RFC3339 timestamp - * @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ? - * @return DateTime - */ - public function parse_date( $date, $force_utc = false ) { - // Default timezone to the server's current one - $timezone = self::get_timezone(); - if ( $force_utc ) { - $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); - $timezone = new DateTimeZone( 'UTC' ); - } - - // Strip millisecond precision (a full stop followed by one or more digits) - if ( strpos( $date, '.' ) !== false ) { - $date = preg_replace( '/\.\d+/', '', $date ); - } - $datetime = DateTime::createFromFormat( DateTime::RFC3339, $date ); - - return $datetime; - } - - /** - * Get a local date with its GMT equivalent, in MySQL datetime format - * - * @param string $date RFC3339 timestamp - * @param boolean $force_utc Should we force UTC timestamp? - * @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s) - */ - public function get_date_with_gmt( $date, $force_utc = false ) { - $datetime = $this->parse_date( $date, $force_utc ); - - $datetime->setTimezone( self::get_timezone() ); - $local = $datetime->format( 'Y-m-d H:i:s' ); - - $datetime->setTimezone( new DateTimeZone( 'UTC' ) ); - $utc = $datetime->format('Y-m-d H:i:s'); - - return array( $local, $utc ); - } - - /** - * Retrieve the avatar for a user who provided a user ID or email address. - * - * {@see get_avatar()} doesn't return just the URL, so we have to - * reimplement this here. - * - * @todo Rework how we do this. Copying it is a hack. - * - * @since 2.5 - * @param string $email Email address - * @return string tag for the user's avatar - */ - public function get_avatar( $email ) { - if ( ! get_option( 'show_avatars' ) ) - return false; - - $email_hash = md5( strtolower( trim( $email ) ) ); - - if ( is_ssl() ) { - $host = 'https://secure.gravatar.com'; - } else { - if ( !empty($email) ) - $host = sprintf( 'http://%d.gravatar.com', ( hexdec( $email_hash[0] ) % 2 ) ); - else - $host = 'http://0.gravatar.com'; - } - - $avatar = "$host/avatar/$email_hash&d=404"; - - $rating = get_option( 'avatar_rating' ); - if ( !empty( $rating ) ) - $avatar .= "&r={$rating}"; - - return apply_filters( 'get_avatar', $avatar, $email, '96', '404', '' ); - } - - /** - * Get the timezone object for the site - * - * @return DateTimeZone - */ - public function get_timezone() { - static $zone = null; - if ($zone !== null) - return $zone; - - $tzstring = get_option( 'timezone_string' ); - if ( ! $tzstring ) { - // Create a UTC+- zone if no timezone string exists - $current_offset = get_option( 'gmt_offset' ); - if ( 0 == $current_offset ) - $tzstring = 'UTC'; - elseif ($current_offset < 0) - $tzstring = 'Etc/GMT' . $current_offset; - else - $tzstring = 'Etc/GMT+' . $current_offset; - } - $zone = new DateTimeZone( $tzstring ); - return $zone; - } - - /** - * Extract headers from a PHP-style $_SERVER array - * - * @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; - } -} From 58c57eb6f190611f2ef7043789387f255167c046 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Wed, 6 Nov 2013 01:49:08 -0500 Subject: [PATCH 16/43] Add REST API request/response interface --- includes/api/interface-wc-api-handler.php | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 includes/api/interface-wc-api-handler.php diff --git a/includes/api/interface-wc-api-handler.php b/includes/api/interface-wc-api-handler.php new file mode 100644 index 00000000000..a4f8edc6412 --- /dev/null +++ b/includes/api/interface-wc-api-handler.php @@ -0,0 +1,45 @@ + Date: Wed, 6 Nov 2013 01:49:37 -0500 Subject: [PATCH 17/43] Add REST API JSON request/response handler --- includes/api/class-wc-api-json-handler.php | 73 ++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 includes/api/class-wc-api-json-handler.php 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..9bad7855bbd --- /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 ); + } + +} From 8637da63c08e0a991465dfb9bc6b551d1cf75143 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Wed, 6 Nov 2013 01:49:51 -0500 Subject: [PATCH 18/43] Add REST API XML request/response handler stub --- includes/api/class-wc-api-xml-handler.php | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 includes/api/class-wc-api-xml-handler.php 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 @@ + Date: Wed, 6 Nov 2013 01:53:07 -0500 Subject: [PATCH 19/43] Add new WC_API_Server class Based on WP_JSON_Server, this class add response handling based on endpoint suffixes (.json or .xml) or the ACCEPT header. It also removes some unneeded functionality and renames filters so as to not conflict with users who may have WP-API installed already. Finally, instead of no authentication required by default, authentication is always required unless specifically disabled via filters. --- includes/api/class-wc-api-server.php | 700 +++++++++++++++++++++++++++ 1 file changed, 700 insertions(+) create mode 100644 includes/api/class-wc-api-server.php diff --git a/includes/api/class-wc-api-server.php b/includes/api/class-wc-api-server.php new file mode 100644 index 00000000000..67e1e35ec52 --- /dev/null +++ b/includes/api/class-wc-api-server.php @@ -0,0 +1,700 @@ + 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 ); + + // TODO: can we prevent wc_cookie from being sent for API requests? + + // the API is enabled by default TODO: implement check for enabled setting here + if ( ! apply_filters( 'woocommerce_api_enabled', true, $this ) ) { + + $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', $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[] = $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( + 'currency' => get_woocommerce_currency(), + 'weight_unit' => get_option( 'woocommerce_weight_unit' ), + 'dimension_unit' => get_option( 'woocommerce_dimension_unit' ), + 'supports_ssl' => ( '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 navigation-related headers for post collections + * + * @since 2.1 + * @param WP_Query $query + */ + public function query_navigation_headers( $query ) { + $max_page = $query->max_num_pages; + $paged = $query->get('paged'); + + if ( !$paged ) + $paged = 1; + + $nextpage = intval($paged) + 1; + + if ( ! $query->is_single() ) { + if ( $paged > 1 ) { + $request = remove_query_arg( 'page' ); + $request = add_query_arg( 'page', $paged - 1, $request ); + $this->link_header( 'prev', $request ); + } + + if ( $nextpage <= $max_page ) { + $request = remove_query_arg( 'page' ); + $request = add_query_arg( 'page', $nextpage, $request ); + $this->link_header( 'next', $request ); + } + } + + $this->header( 'X-WP-Total', $query->found_posts ); + $this->header( 'X-WP-TotalPages', $max_page ); + + do_action('woocommerce_api_query_navigation_headers', $this, $query); + } + + + /** + * 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 timestamp into a DateTime + * + * @param string $date RFC3339 timestamp + * @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ? + * @return DateTime + */ + public function parse_date( $date, $force_utc = false ) { + // Default timezone to the server's current one + $timezone = self::get_timezone(); + if ( $force_utc ) { + $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); + $timezone = new DateTimeZone( 'UTC' ); + } + + // Strip millisecond precision (a full stop followed by one or more digits) + if ( strpos( $date, '.' ) !== false ) { + $date = preg_replace( '/\.\d+/', '', $date ); + } + $datetime = DateTime::createFromFormat( DateTime::RFC3339, $date ); + + return $datetime; + } + + /** + * Get a local date with its GMT equivalent, in MySQL datetime format + * + * @param string $date RFC3339 timestamp + * @param boolean $force_utc Should we force UTC timestamp? + * @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s) + */ + public function get_date_with_gmt( $date, $force_utc = false ) { + $datetime = $this->parse_date( $date, $force_utc ); + + $datetime->setTimezone( self::get_timezone() ); + $local = $datetime->format( 'Y-m-d H:i:s' ); + + $datetime->setTimezone( new DateTimeZone( 'UTC' ) ); + $utc = $datetime->format('Y-m-d H:i:s'); + + return array( $local, $utc ); + } + + /** + * Get the timezone object for the site + * + * @return DateTimeZone + */ + public function get_timezone() { + static $zone = null; + if ($zone !== null) + return $zone; + + $tzstring = get_option( 'timezone_string' ); + if ( ! $tzstring ) { + // Create a UTC+- zone if no timezone string exists + $current_offset = get_option( 'gmt_offset' ); + if ( 0 == $current_offset ) + $tzstring = 'UTC'; + elseif ($current_offset < 0) + $tzstring = 'Etc/GMT' . $current_offset; + else + $tzstring = 'Etc/GMT+' . $current_offset; + } + $zone = new DateTimeZone( $tzstring ); + return $zone; + } + + /** + * 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; + } +} From c3fa52b0b54bae9d321f42fe8036c76ce490e7bf Mon Sep 17 00:00:00 2001 From: Max Rice Date: Wed, 6 Nov 2013 01:54:19 -0500 Subject: [PATCH 20/43] Update API classes to use new WC_API_Server class --- includes/api/class-wc-api-authentication.php | 26 +++---------- includes/api/class-wc-api-base.php | 8 ++-- includes/api/class-wc-api-coupons.php | 12 +++--- includes/api/class-wc-api-customers.php | 39 ++++++++++++++------ includes/api/class-wc-api-orders.php | 14 +++---- includes/api/class-wc-api-products.php | 14 +++---- includes/api/class-wc-api-reports.php | 4 +- 7 files changed, 59 insertions(+), 58 deletions(-) diff --git a/includes/api/class-wc-api-authentication.php b/includes/api/class-wc-api-authentication.php index 3ea58347f5f..99930768296 100644 --- a/includes/api/class-wc-api-authentication.php +++ b/includes/api/class-wc-api-authentication.php @@ -21,28 +21,12 @@ class WC_API_Authentication { public function __construct() { // this filter can be removed in order to provide unauthenticated access to the API for testing, etc - add_filter( 'json_check_authentication', array( $this, 'authenticate' ) ); - - add_filter( 'json_index', array( $this, 'maybe_declare_ssl_support' ) ); + add_filter( 'woocommerce_api_check_authentication', array( $this, 'authenticate' ) ); // TODO: provide API key based permissions check using $args = apply_filters( 'json_dispatch_args', $args, $callback ); // TODO: allow unauthenticated access to /products endpoint } - /** - * Add "supports_ssl" capabilities to API index so consumers can determine the proper authentication method - * - * @since 2.1 - * @param array $capabilities - * @return array - */ - public function maybe_declare_ssl_support( $capabilities ) { - - $capabilities['supports_ssl'] = ( 'yes' === get_option( 'woocommerce_force_ssl_checkout' ) ); - - return $capabilities; - } - /** * Authenticate the request. The authentication method varies based on whether the request was made over SSL or not. * @@ -54,7 +38,7 @@ class WC_API_Authentication { // allow access to the index by default if ( '/' === WC()->api->server->path ) - return null; + return new WP_User(0); try { @@ -65,7 +49,7 @@ class WC_API_Authentication { } catch ( Exception $e ) { - $user = new WP_Error( 'wc_api_authentication_error', $e->getMessage(), array( 'status' => $e->getCode() ) ); + $user = new WP_Error( 'woocommerce_api_authentication_error', $e->getMessage(), array( 'status' => $e->getCode() ) ); } return $user; @@ -214,9 +198,9 @@ class WC_API_Authentication { $query_params = array(); foreach ( $params as $param_key => $param_value ) { - $query_params[] = $param_key . '%3D' . $param_value; + $query_params[] = $param_key . '%3D' . $param_value; // join with equals sign } - $query_string = implode( '%26', $query_params ); + $query_string = implode( '%26', $query_params ); // join with ampersand $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string; diff --git a/includes/api/class-wc-api-base.php b/includes/api/class-wc-api-base.php index 1950e197aa8..25f4adeb5ca 100644 --- a/includes/api/class-wc-api-base.php +++ b/includes/api/class-wc-api-base.php @@ -14,7 +14,7 @@ if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly class WC_API_Base { - /** @var \WP_JSON_Server the API server */ + /** @var WC_API_Server the API server */ protected $server; /** @var string sub-classes override this to set a resource-specific base route */ @@ -24,15 +24,15 @@ class WC_API_Base { * Setup class * * @since 2.1 - * @param WP_JSON_Server $server + * @param WC_API_Server $server * @return WC_API_Base */ - public function __construct( WP_JSON_Server $server ) { + public function __construct( WC_API_Server $server ) { $this->server = $server; // automatically register routes for sub-classes - add_filter( 'json_endpoints', array( $this, 'registerRoutes' ) ); + add_filter( 'woocommerce_api_endpoints', array( $this, 'registerRoutes' ) ); // 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) diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php index 62d29af1d88..447218e0322 100644 --- a/includes/api/class-wc-api-coupons.php +++ b/includes/api/class-wc-api-coupons.php @@ -33,20 +33,20 @@ class WC_API_Coupons extends WC_API_Base { # GET|POST /coupons $routes[ $this->base ] = array( - array( array( $this, 'getCoupons' ), WP_JSON_Server::READABLE ), - array( array( $this, 'createCoupon' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'getCoupons' ), WC_API_Server::READABLE ), + array( array( $this, 'createCoupon' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), ); # GET /coupons/count $routes[ $this->base . '/count'] = array( - array( array( $this, 'getCouponsCount' ), WP_JSON_SERVER::READABLE ), + array( array( $this, 'getCouponsCount' ), WC_API_Server::READABLE ), ); # GET|PUT|DELETE /coupons/ $routes[ $this->base . '/(?P\d+)' ] = array( - array( array( $this, 'getCoupon' ), WP_JSON_Server::READABLE ), - array( array( $this, 'editCoupon' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( array( $this, 'deleteCoupon' ), WP_JSON_Server::DELETABLE ), + array( array( $this, 'getCoupon' ), WC_API_Server::READABLE ), + array( array( $this, 'editCoupon' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'deleteCoupon' ), WC_API_Server::DELETABLE ), ); return $routes; diff --git a/includes/api/class-wc-api-customers.php b/includes/api/class-wc-api-customers.php index d00cab403fb..a1ce47e36b1 100644 --- a/includes/api/class-wc-api-customers.php +++ b/includes/api/class-wc-api-customers.php @@ -22,10 +22,10 @@ class WC_API_Customers extends WC_API_Base { * Setup class, overridden to provide customer data to order response * * @since 2.1 - * @param WP_JSON_Server $server + * @param WC_API_Server $server * @return WC_API_Customers */ - public function __construct( WP_JSON_Server $server ) { + public function __construct( WC_API_Server $server ) { parent::__construct( $server ); @@ -49,25 +49,25 @@ class WC_API_Customers extends WC_API_Base { # GET|POST /customers $routes[ $this->base ] = array( - array( array( $this, 'getCustomers' ), WP_JSON_Server::READABLE ), - array( array( $this, 'createCustomer' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'getCustomers' ), WC_API_SERVER::READABLE ), + array( array( $this, 'createCustomer' ), WC_API_SERVER::CREATABLE | WC_API_SERVER::ACCEPT_DATA ), ); # GET /customers/count $routes[ $this->base . '/count'] = array( - array( array( $this, 'getCustomersCount' ), WP_JSON_SERVER::READABLE ), + array( array( $this, 'getCustomersCount' ), WC_API_SERVER::READABLE ), ); # GET|PUT|DELETE /customers/ $routes[ $this->base . '/(?P\d+)' ] = array( - array( array( $this, 'getCustomer' ), WP_JSON_Server::READABLE ), - array( array( $this, 'editCustomer' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( array( $this, 'deleteCustomer' ), WP_JSON_Server::DELETABLE ), + array( array( $this, 'getCustomer' ), WC_API_SERVER::READABLE ), + array( array( $this, 'editCustomer' ), WC_API_SERVER::EDITABLE | WC_API_SERVER::ACCEPT_DATA ), + array( array( $this, 'deleteCustomer' ), WC_API_SERVER::DELETABLE ), ); # GET /customers//orders $routes[ $this->base . '/(?P\d+)/orders' ] = array( - array( array( $this, 'getCustomerOrders' ), WP_JSON_Server::READABLE ), + array( array( $this, 'getCustomerOrders' ), WC_API_SERVER::READABLE ), ); return $routes; @@ -151,7 +151,7 @@ class WC_API_Customers extends WC_API_Base { 'last_order_date' => is_object( $last_order ) ? $last_order->post_date : null, 'orders_count' => $customer->_order_count, 'total_spent' => $customer->_money_spent, - 'avatar_url' => $this->server->get_avatar( $customer->customer_email ), + 'avatar_url' => $this->get_avatar_url( $customer->customer_email ), 'billing_address' => array( 'first_name' => $customer->billing_first_name, 'last_name' => $customer->billing_last_name, @@ -316,7 +316,6 @@ class WC_API_Customers extends WC_API_Base { return new WP_User_Query( $query_args ); } - /** * Add customer data to orders * @@ -341,4 +340,22 @@ class WC_API_Customers extends WC_API_Base { return $order_data; } + /** + * Wrapper for @see get_avatar() which doesn't simply return + * the URL so we need to pluck it from the HTML img tag + * + * @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; + } + } diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php index 843855bca4c..de545ad266e 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -33,25 +33,25 @@ class WC_API_Orders extends WC_API_Base { # GET|POST /orders $routes[ $this->base ] = array( - array( array( $this, 'getOrders' ), WP_JSON_Server::READABLE ), - array( array( $this, 'createOrder' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'getOrders' ), WC_API_Server::READABLE ), + array( array( $this, 'createOrder' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), ); # GET /orders/count $routes[ $this->base . '/count'] = array( - array( array( $this, 'getOrdersCount' ), WP_JSON_SERVER::READABLE ), + array( array( $this, 'getOrdersCount' ), WC_API_Server::READABLE ), ); # GET|PUT|DELETE /orders/ $routes[ $this->base . '/(?P\d+)' ] = array( - array( array( $this, 'getOrder' ), WP_JSON_Server::READABLE ), - array( array( $this, 'editOrder' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( array( $this, 'deleteOrder' ), WP_JSON_Server::DELETABLE ), + array( array( $this, 'getOrder' ), WC_API_Server::READABLE ), + array( array( $this, 'editOrder' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'deleteOrder' ), WC_API_Server::DELETABLE ), ); # GET /orders//notes $routes[ $this->base . '/(?P\d+)/notes' ] = array( - array( array( $this, 'getOrderNotes' ), WP_JSON_Server::READABLE ), + array( array( $this, 'getOrderNotes' ), WC_API_Server::READABLE ), ); return $routes; diff --git a/includes/api/class-wc-api-products.php b/includes/api/class-wc-api-products.php index 065049f9a47..e880ee775c4 100644 --- a/includes/api/class-wc-api-products.php +++ b/includes/api/class-wc-api-products.php @@ -33,25 +33,25 @@ class WC_API_Products extends WC_API_Base { # GET|POST /products $routes[ $this->base ] = array( - array( array( $this, 'getProducts' ), WP_JSON_Server::READABLE ), - array( array( $this, 'createProduct' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'getProducts' ), WC_API_Server::READABLE ), + array( array( $this, 'createProduct' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), ); # GET /products/count $routes[ $this->base . '/count'] = array( - array( array( $this, 'getProductsCount' ), WP_JSON_SERVER::READABLE ), + array( array( $this, 'getProductsCount' ), WC_API_Server::READABLE ), ); # GET|PUT|DELETE /products/ $routes[ $this->base . '/(?P\d+)' ] = array( - array( array( $this, 'getProduct' ), WP_JSON_Server::READABLE ), - array( array( $this, 'editProduct' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( array( $this, 'deleteProduct' ), WP_JSON_Server::DELETABLE ), + array( array( $this, 'getProduct' ), WC_API_Server::READABLE ), + array( array( $this, 'editProduct' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'deleteProduct' ), WC_API_Server::DELETABLE ), ); # GET /products//reviews $routes[ $this->base . '/(?P\d+)/reviews' ] = array( - array( array( $this, 'getProductReviews' ), WP_JSON_Server::READABLE ), + array( array( $this, 'getProductReviews' ), WC_API_Server::READABLE ), ); return $routes; diff --git a/includes/api/class-wc-api-reports.php b/includes/api/class-wc-api-reports.php index 2e865dda725..8a156bbf49f 100644 --- a/includes/api/class-wc-api-reports.php +++ b/includes/api/class-wc-api-reports.php @@ -32,12 +32,12 @@ class WC_API_Reports extends WC_API_Base { # GET /reports $routes[ $this->base ] = array( - array( array( $this, 'getReports' ), WP_JSON_Server::READABLE ), + array( array( $this, 'getReports' ), WC_API_Server::READABLE ), ); # GET /reports/sales $routes[ $this->base . '/sales'] = array( - array( array( $this, 'getSalesReport' ), WP_JSON_SERVER::READABLE ), + array( array( $this, 'getSalesReport' ), WC_API_Server::READABLE ), ); return $routes; From 0a6b26802414f2c75a06f42d98a9db42d3c782fd Mon Sep 17 00:00:00 2001 From: Max Rice Date: Wed, 6 Nov 2013 01:56:11 -0500 Subject: [PATCH 21/43] Update core WC_API class to use new WC_API_Server class --- includes/class-wc-api.php | 46 ++++++++++----------------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/includes/class-wc-api.php b/includes/class-wc-api.php index d1038b67886..aad5303336b 100644 --- a/includes/class-wc-api.php +++ b/includes/class-wc-api.php @@ -31,9 +31,6 @@ class WC_API { // handle REST/legacy API request add_action( 'parse_request', array( $this, 'handle_api_requests'), 0 ); - - // TODO: should this be done via this filter or in wp-json-server class? - add_filter( 'json_endpoints', array( $this, 'remove_users_endpoint' ), 0 ); } /** @@ -87,24 +84,18 @@ class WC_API { // REST API request if ( ! empty( $wp->query_vars['wc-api-route'] ) ) { + define( 'WC_API_REQUEST', true ); + // load required files $this->includes(); - define('XMLRPC_REQUEST', true); - - define('JSON_REQUEST', true); - - // TODO: should these filters/actions be renamed? - $wp_json_server_class = apply_filters('wp_json_server_class', 'WP_JSON_Server'); - - $this->server = new $wp_json_server_class; - - do_action('wp_json_server_before_serve', $this->server ); + $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( $wp->query_vars['wc-api-route'] ); + $this->server->serve_request(); exit; } @@ -143,8 +134,10 @@ class WC_API { include_once( ABSPATH . WPINC . '/class-IXR.php' ); include_once( ABSPATH . WPINC . '/class-wp-xmlrpc-server.php' ); - include_once( 'libraries/wp-api/class-wp-json-responsehandler.php' ); - include_once( 'libraries/wp-api/class-wp-json-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(); @@ -155,6 +148,8 @@ class WC_API { 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 } /** @@ -178,23 +173,4 @@ class WC_API { } } - - /** - * Remove the users endpoints added by the JSON server - * - * @since 2.1 - * @param $endpoints - * @return array - */ - public function remove_users_endpoint( $endpoints ) { - - foreach ( $endpoints as $path => $endpoint ) { - - if ( ! strncmp( $path, '/user', 5 ) ) - unset( $endpoints[ $path ] ); - } - - return $endpoints; - } - } From 6c3eb169dbcbbbb1a8d966f99ccada5299d5d8ed Mon Sep 17 00:00:00 2001 From: Max Rice Date: Wed, 6 Nov 2013 01:56:38 -0500 Subject: [PATCH 22/43] Define and use WC_API::VERSION constant for endpoint versioning --- includes/class-wc-api.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/includes/class-wc-api.php b/includes/class-wc-api.php index aad5303336b..a6e1c1a50f1 100644 --- a/includes/class-wc-api.php +++ b/includes/class-wc-api.php @@ -14,6 +14,14 @@ if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly class WC_API { + /** This is the major version for the REST API and takes + * first-order position in endpoint URLs + */ + const VERSION = 1; + + /** @var WC_API_Server the REST API server */ + public $server; + /** * Setup class * @@ -57,8 +65,8 @@ class WC_API { public function add_endpoint() { // REST API - add_rewrite_rule( '^wc-api\/v1/?$', 'index.php?wc-api-route=/', 'top' ); - add_rewrite_rule( '^wc-api\/v1(.*)?', 'index.php?wc-api-route=$matches[1]', 'top' ); + add_rewrite_rule( '^wc-api\/v' . self::VERSION . '/?$', 'index.php?wc-api-route=/', 'top' ); + add_rewrite_rule( '^wc-api\/v' . self::VERSION .'(.*)?', 'index.php?wc-api-route=$matches[1]', 'top' ); // legacy API for payment gateway IPNs add_rewrite_endpoint( 'wc-api', EP_ALL ); From 23e6b22cfd9dc7991f6d6827cd200f7f122545e1 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sat, 9 Nov 2013 16:20:23 -0500 Subject: [PATCH 23/43] Rename API base resource class --- includes/api/class-wc-api-coupons.php | 2 +- includes/api/class-wc-api-customers.php | 2 +- includes/api/class-wc-api-orders.php | 2 +- includes/api/class-wc-api-products.php | 2 +- includes/api/class-wc-api-reports.php | 2 +- .../{class-wc-api-base.php => class-wc-api-resource.php} | 6 +++--- includes/class-wc-api.php | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) rename includes/api/{class-wc-api-base.php => class-wc-api-resource.php} (98%) diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php index 447218e0322..300b35601e6 100644 --- a/includes/api/class-wc-api-coupons.php +++ b/includes/api/class-wc-api-coupons.php @@ -13,7 +13,7 @@ if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly -class WC_API_Coupons extends WC_API_Base { +class WC_API_Coupons extends WC_API_Resource { /** @var string $base the route base */ protected $base = '/coupons'; diff --git a/includes/api/class-wc-api-customers.php b/includes/api/class-wc-api-customers.php index a1ce47e36b1..85df176186d 100644 --- a/includes/api/class-wc-api-customers.php +++ b/includes/api/class-wc-api-customers.php @@ -12,7 +12,7 @@ if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly -class WC_API_Customers extends WC_API_Base { +class WC_API_Customers extends WC_API_Resource { /** @var string $base the route base */ protected $base = '/customers'; diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php index de545ad266e..86ebb7ab2a9 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -12,7 +12,7 @@ if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly -class WC_API_Orders extends WC_API_Base { +class WC_API_Orders extends WC_API_Resource { /** @var string $base the route base */ protected $base = '/orders'; diff --git a/includes/api/class-wc-api-products.php b/includes/api/class-wc-api-products.php index e880ee775c4..2c0498e2f19 100644 --- a/includes/api/class-wc-api-products.php +++ b/includes/api/class-wc-api-products.php @@ -12,7 +12,7 @@ if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly -class WC_API_Products extends WC_API_Base { +class WC_API_Products extends WC_API_Resource { /** @var string $base the route base */ protected $base = '/products'; diff --git a/includes/api/class-wc-api-reports.php b/includes/api/class-wc-api-reports.php index 8a156bbf49f..eef5e99d73f 100644 --- a/includes/api/class-wc-api-reports.php +++ b/includes/api/class-wc-api-reports.php @@ -13,7 +13,7 @@ if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly -class WC_API_Reports extends WC_API_Base { +class WC_API_Reports extends WC_API_Resource { /** @var string $base the route base */ protected $base = '/reports'; diff --git a/includes/api/class-wc-api-base.php b/includes/api/class-wc-api-resource.php similarity index 98% rename from includes/api/class-wc-api-base.php rename to includes/api/class-wc-api-resource.php index 25f4adeb5ca..efae3e96e73 100644 --- a/includes/api/class-wc-api-base.php +++ b/includes/api/class-wc-api-resource.php @@ -1,6 +1,6 @@ authentication = new WC_API_Authentication(); - include_once( 'api/class-wc-api-base.php' ); + 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' ); From a909140cb16f3147abda81f9bd53a84a729aab7d Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 10 Nov 2013 14:01:12 -0500 Subject: [PATCH 24/43] Fix typo --- includes/class-wc-comments.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-wc-comments.php b/includes/class-wc-comments.php index 9543e5fc509..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( __CLASS__, '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' ) ); } From 1f5db980468738ff6d5082ab31035059ff63b820 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 10 Nov 2013 18:28:58 -0500 Subject: [PATCH 25/43] Add date created filtering to /customers endpoint --- includes/api/class-wc-api-customers.php | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/includes/api/class-wc-api-customers.php b/includes/api/class-wc-api-customers.php index 85df176186d..f65d21e1f5b 100644 --- a/includes/api/class-wc-api-customers.php +++ b/includes/api/class-wc-api-customers.php @@ -17,6 +17,11 @@ class WC_API_Customers extends WC_API_Resource { /** @var string $base the route base */ protected $base = '/customers'; + /** @var string $created_at_min for date filtering */ + private $created_at_min = null; + + /** @var string $created_at_max for date filtering */ + private $created_at_max = null; /** * Setup class, overridden to provide customer data to order response @@ -31,6 +36,8 @@ class WC_API_Customers extends WC_API_Resource { // add customer data to order responses add_filter( 'woocommerce_api_order_response', array( $this, 'addCustomerData' ), 10, 2 ); + // modify WP_User_Query to support created_at date filtering + add_action( 'pre_user_query', array( $this, 'modify_user_query' ) ); } /** @@ -312,6 +319,13 @@ class WC_API_Customers extends WC_API_Resource { $query_args['offset'] = $args['offset']; // TODO: navigation/total count headers for pagination + if ( ! empty( $args['created_at_min'] ) ) + $this->created_at_min = $args['created_at_min']; + + if ( ! empty( $args['created_at_max'] ) ) + $this->created_at_max = $args['created_at_max']; + + // TODO: support page argument - requires custom implementation as WP_User_Query has no built-in pagination like WP_Query return new WP_User_Query( $query_args ); } @@ -340,6 +354,21 @@ class WC_API_Customers extends WC_API_Resource { 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 DATE(user_registered) >= '%s'", date( 'Y-m-d H:i:s', strtotime( $this->created_at_min ) ) ); // TODO: date formatting + + if ( $this->created_at_max ) + $query->query_where .= sprintf( " AND DATE(user_registered) <= '%s'", date( 'Y-m-d H:i:s', strtotime( $this->created_at_max ) ) ); // TODO: date formatting + } + /** * Wrapper for @see get_avatar() which doesn't simply return * the URL so we need to pluck it from the HTML img tag From 9c7791e3d842feb097ede9be97f4a1130ee186eb Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 10 Nov 2013 18:29:37 -0500 Subject: [PATCH 26/43] urldecode parameters before passing to resource methods --- includes/api/class-wc-api-server.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/includes/api/class-wc-api-server.php b/includes/api/class-wc-api-server.php index 67e1e35ec52..c2f2cc909c8 100644 --- a/includes/api/class-wc-api-server.php +++ b/includes/api/class-wc-api-server.php @@ -394,7 +394,8 @@ class WC_API_Server { foreach ( $wanted as $param ) { if ( isset( $provided[ $param->getName() ] ) ) { // We have this parameters in the list to choose from - $ordered_parameters[] = $provided[ $param->getName() ]; + + $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 From a13a95e452f1836c6acf689936b1a86cff4245b6 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 10 Nov 2013 19:29:36 -0500 Subject: [PATCH 27/43] Update API resource classes Change method naming to snake_case and implement initial `current_user_can()` permission checks --- includes/api/class-wc-api-coupons.php | 120 ++++++++----- includes/api/class-wc-api-customers.php | 201 +++++++++++++-------- includes/api/class-wc-api-orders.php | 178 ++++++++---------- includes/api/class-wc-api-products.php | 138 +++++++------- includes/api/class-wc-api-reports.php | 10 +- includes/api/class-wc-api-resource.php | 229 +++++++++++++++++++----- 6 files changed, 537 insertions(+), 339 deletions(-) diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php index 300b35601e6..8d0e2dc8ee6 100644 --- a/includes/api/class-wc-api-coupons.php +++ b/includes/api/class-wc-api-coupons.php @@ -29,24 +29,29 @@ class WC_API_Coupons extends WC_API_Resource { * @param array $routes * @return array */ - public function registerRoutes( $routes ) { + public function register_routes( $routes ) { # GET|POST /coupons $routes[ $this->base ] = array( - array( array( $this, 'getCoupons' ), WC_API_Server::READABLE ), - array( array( $this, 'createCoupon' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), + 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, 'getCouponsCount' ), WC_API_Server::READABLE ), + array( array( $this, 'get_coupons_count' ), WC_API_Server::READABLE ), ); # GET|PUT|DELETE /coupons/ $routes[ $this->base . '/(?P\d+)' ] = array( - array( array( $this, 'getCoupon' ), WC_API_Server::READABLE ), - array( array( $this, 'editCoupon' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), - array( array( $this, 'deleteCoupon' ), WC_API_Server::DELETABLE ), + 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/ TODO: should looking up coupon codes containing spaces or dashes be supported? OR all-digit coupon codes + $routes[ $this->base . '/(?P\w+)' ] = array( + array( array( $this, 'get_coupon_by_code' ), WC_API_Server::READABLE ), ); return $routes; @@ -55,36 +60,27 @@ class WC_API_Coupons extends WC_API_Resource { /** * Get all coupons * - * @TODO should we support an extra "code" param for lookup instead of "q" which searches both title & description? - * * @since 2.1 * @param string $fields - * @param string $created_at_min - * @param string $created_at_max - * @param string $q search terms - * @param int $limit coupons per response - * @param int $offset + * @param array $filter * @return array */ - public function getCoupons( $fields = null, $created_at_min = null, $created_at_max = null, $q = null, $limit = null, $offset = null ) { + public function get_coupons( $fields = null, $filter = array() ) { - $request_args = array( - 'created_at_min' => $created_at_min, - 'created_at_max' => $created_at_max, - 'q' => $q, - 'limit' => $limit, - 'offset' => $offset, - ); - - $query = $this->queryCoupons( $request_args ); + $query = $this->query_coupons( $filter ); $coupons = array(); foreach( $query->posts as $coupon_id ) { - $coupons[] = $this->getCoupon( $coupon_id, $fields ); + if ( ! $this->is_readable( $coupon_id ) ) + continue; + + $coupons[] = $this->get_coupon( $coupon_id, $fields ); } + $this->server->query_navigation_headers( $query ); + return array( 'coupons' => $coupons ); } @@ -94,25 +90,29 @@ class WC_API_Coupons extends WC_API_Resource { * @since 2.1 * @param int $id the coupon ID * @param string $fields fields to include in response - * @return array + * @return array|WP_Error */ - public function getCoupon( $id, $fields = null ) { + 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 ( ! $code ) - return new WP_Error( 'wc_api_invalid_coupon_id', __( 'Invalid coupon ID', 'woocommerce' ), array( 'status' => 404 ) ); + 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 ); - // TODO: how to implement coupon meta? $coupon_data = array( 'id' => $coupon->id, 'code' => $coupon->code, 'type' => $coupon->type, - 'amount' => $coupon->amount, // TODO: should this be formatted? + 'amount' => (string) number_format( $coupon->amount, 2 ), 'individual_use' => $coupon->individual_use, 'product_ids' => $coupon->product_ids, 'exclude_product_ids' => $coupon->exclude_product_ids, @@ -137,17 +137,36 @@ class WC_API_Coupons extends WC_API_Resource { * Get the total number of coupons * * @since 2.1 - * @param string $created_at_min - * @param string $created_at_max + * @param array $filter * @return array */ - public function getCouponsCount( $created_at_min = null, $created_at_max = null ) { + public function get_coupons_count( $filter = array() ) { - $query = $this->queryCoupons( array( 'created_at_min' => $created_at_min, 'created_at_max' => $created_at_max ) ); + $query = $this->query_coupons( $filter ); + + // TODO: permissions? return array( 'count' => $query->found_posts ); } + /** + * Get the coupon for the given code + * + * @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 * @@ -155,7 +174,9 @@ class WC_API_Coupons extends WC_API_Resource { * @param array $data * @return array */ - public function createCoupon( $data ) { + public function create_coupon( $data ) { + + // TODO: permissions check // TODO: implement - what's the minimum set of data required? @@ -170,10 +191,15 @@ class WC_API_Coupons extends WC_API_Resource { * @param array $data * @return array */ - public function editCoupon( $id, $data ) { + 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->getCoupon( $id ); + return $this->get_coupon( $id ); } /** @@ -184,9 +210,14 @@ class WC_API_Coupons extends WC_API_Resource { * @param bool $force true to permanently delete coupon, false to move to trash * @return array */ - public function deleteCoupon( $id, $force = false ) { + public function delete_coupon( $id, $force = false ) { - return $this->deleteResource( $id, 'coupon', ( 'true' === $force ) ); + $id = $this->validate_request( $id, 'shop_coupon', 'delete' ); + + if ( is_wp_error( $id ) ) + return $id; + + return $this->delete( $id, 'shop_coupon', ( 'true' === $force ) ); } /** @@ -194,21 +225,18 @@ class WC_API_Coupons extends WC_API_Resource { * * @since 2.1 * @param array $args request arguments for filtering query - * @return array + * @return WP_Query */ - private function queryCoupons( $args ) { + private function query_coupons( $args ) { // set base query arguments $query_args = array( 'fields' => 'ids', 'post_type' => 'shop_coupon', 'post_status' => 'publish', - 'orderby' => 'title', ); - $query_args = $this->mergeQueryArgs( $query_args, $args ); - - // TODO: navigation/total count headers for pagination + $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 index f65d21e1f5b..f4a4ec941c2 100644 --- a/includes/api/class-wc-api-customers.php +++ b/includes/api/class-wc-api-customers.php @@ -35,7 +35,8 @@ class WC_API_Customers extends WC_API_Resource { parent::__construct( $server ); // add customer data to order responses - add_filter( 'woocommerce_api_order_response', array( $this, 'addCustomerData' ), 10, 2 ); + add_filter( 'woocommerce_api_order_response', array( $this, 'add_customer_data' ), 10, 2 ); + // modify WP_User_Query to support created_at date filtering add_action( 'pre_user_query', array( $this, 'modify_user_query' ) ); } @@ -52,29 +53,29 @@ class WC_API_Customers extends WC_API_Resource { * @param array $routes * @return array */ - public function registerRoutes( $routes ) { + public function register_routes( $routes ) { # GET|POST /customers $routes[ $this->base ] = array( - array( array( $this, 'getCustomers' ), WC_API_SERVER::READABLE ), - array( array( $this, 'createCustomer' ), WC_API_SERVER::CREATABLE | WC_API_SERVER::ACCEPT_DATA ), + 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, 'getCustomersCount' ), WC_API_SERVER::READABLE ), + array( array( $this, 'get_customers_count' ), WC_API_SERVER::READABLE ), ); # GET|PUT|DELETE /customers/ $routes[ $this->base . '/(?P\d+)' ] = array( - array( array( $this, 'getCustomer' ), WC_API_SERVER::READABLE ), - array( array( $this, 'editCustomer' ), WC_API_SERVER::EDITABLE | WC_API_SERVER::ACCEPT_DATA ), - array( array( $this, 'deleteCustomer' ), WC_API_SERVER::DELETABLE ), + 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, 'getCustomerOrders' ), WC_API_SERVER::READABLE ), + array( array( $this, 'get_customer_orders' ), WC_API_SERVER::READABLE ), ); return $routes; @@ -83,60 +84,48 @@ class WC_API_Customers extends WC_API_Resource { /** * Get all customers * - * @TODO support created_at_min/created_at_max with pre_user_query filter - * * @since 2.1 * @param array $fields - * @param string $q search terms - * @param int $limit coupons per response - * @param int $offset + * @param array $filter * @return array */ - public function getCustomers( $fields = null, $q = null, $limit = null, $offset = null ) { + public function get_customers( $fields = null, $filter = array() ) { - $request_args = array( - 'q' => $q, - 'limit' => $limit, - 'offset' => $offset, - ); - - $query = $this->queryCustomers( $request_args ); + $query = $this->query_customers( $filter ); $customers = array(); foreach( $query->results as $user_id ) { - $customers[] = $this->getCustomer( $user_id, $fields ); + if ( ! $this->is_readable( $user_id ) ) + continue; + + $customers[] = $this->get_customer( $user_id, $fields ); } + // TODO: add navigation/total count headers for pagination + return array( 'customers' => $customers ); } - /** * Get the customer for the given ID * - * @TODO: implement customer meta - * * @since 2.1 * @param int $id the customer ID * @param string $fields * @return array */ - public function getCustomer( $id, $fields = null ) { + public function get_customer( $id, $fields = null ) { global $wpdb; - $id = absint( $id ); + $id = $this->validate_request( $id, 'customer', 'read' ); - if ( empty( $id ) ) - return new WP_Error( 'woocommerce_api_invalid_id', __( 'Invalid customer ID', 'woocommerce' ), array( 'status' => 404 ) ); + if ( is_wp_error( $id ) ) + return $id; - // 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 ) ); - // get info about user's last order $last_order = $wpdb->get_row( "SELECT id, post_date FROM $wpdb->posts AS posts @@ -157,7 +146,7 @@ class WC_API_Customers extends WC_API_Resource { 'last_order_id' => is_object( $last_order ) ? $last_order->id : null, 'last_order_date' => is_object( $last_order ) ? $last_order->post_date : null, 'orders_count' => $customer->_order_count, - 'total_spent' => $customer->_money_spent, + 'total_spent' => (string) number_format( $customer->_money_spent, 2 ), 'avatar_url' => $this->get_avatar_url( $customer->customer_email ), 'billing_address' => array( 'first_name' => $customer->billing_first_name, @@ -191,14 +180,15 @@ class WC_API_Customers extends WC_API_Resource { /** * Get the total number of customers * - * @TODO support created_at_min/created_at_max with pre_user_query filter - * * @since 2.1 + * @param array $filter * @return array */ - public function getCustomersCount() { + public function get_customers_count( $filter = array() ) { - $query = $this->queryCustomers(); + $query = $this->query_customers( $filter ); + + // TODO: permissions? return array( 'count' => $query->get_total() ); } @@ -211,10 +201,12 @@ class WC_API_Customers extends WC_API_Resource { * @param array $data * @return array */ - public function createCustomer( $data ) { + public function create_customer( $data ) { - // TODO: implement - what's the minimum set of data required? - // woocommerce_create_new_customer() + 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(); } @@ -227,10 +219,16 @@ class WC_API_Customers extends WC_API_Resource { * @param array $data * @return array */ - public function editCustomer( $id, $data ) { + public function edit_customer( $id, $data ) { + + $id = $this->validate_request( $id, 'customer', 'edit' ); + + if ( ! is_wp_error( $id ) ) + return $id; // TODO: implement - return $this->getCustomer( $id ); + + return $this->get_customer( $id ); } /** @@ -240,43 +238,40 @@ class WC_API_Customers extends WC_API_Resource { * @param int $id the customer ID * @return array */ - public function deleteCustomer( $id ) { + public function delete_customer( $id ) { - return $this->deleteResource( $id, 'customer' ); + $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 * - * @TODO should this support the same parameters as getOrders call? e.g. fields, created_at, pagination, etc - * * @since 2.1 * @param int $id the customer ID + * @param string $fields fields to include in response * @return array */ - public function getCustomerOrders( $id ) { + public function get_customer_orders( $id, $fields = null ) { global $wpdb; - // TODO: DRY this along with duplicate code in getCustomer() - $id = absint( $id ); + $id = $this->validate_request( $id, 'customer', 'read' ); - if ( empty( $id ) ) - return new WP_Error( 'woocommerce_api_invalid_id', __( 'Invalid customer ID', 'woocommerce' ), array( 'status' => 404 ) ); + if ( is_wp_error( $id ) ) + return $id; - // 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 ) ); - - $order_ids = $wpdb->get_col( "SELECT 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 = {$id} + 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() ); @@ -284,7 +279,7 @@ class WC_API_Customers extends WC_API_Resource { $orders = array(); foreach ( $order_ids as $order_id ) { - $orders[] = WC()->api->WC_API_Orders->getOrder( $order_id ); + $orders[] = WC()->api->WC_API_Orders->get_order( $order_id, $fields ); } return array( 'orders' => $orders ); @@ -297,18 +292,15 @@ class WC_API_Customers extends WC_API_Resource { * @param array $args request arguments for filtering query * @return array */ - private function queryCustomers( $args = array() ) { + private function query_customers( $args = array() ) { // set base query arguments $query_args = array( 'fields' => 'ID', 'role' => 'customer', 'orderby' => 'registered', - 'order' => 'DESC', ); - // TODO: refactor WP_API_Base::mergeQueryVars to support user query args - if ( ! empty( $args['q'] ) ) $query_args['search'] = $args['q']; @@ -318,7 +310,6 @@ class WC_API_Customers extends WC_API_Resource { if ( ! empty( $args['offset'] ) ) $query_args['offset'] = $args['offset']; - // TODO: navigation/total count headers for pagination if ( ! empty( $args['created_at_min'] ) ) $this->created_at_min = $args['created_at_min']; @@ -333,14 +324,12 @@ class WC_API_Customers extends WC_API_Resource { /** * Add customer data to orders * - * @TODO should guest orders return more than 'guest'? - * * @since 2.1 * @param $order_data * @param $order - * + * @return array */ - public function addCustomerData( $order_data, $order ) { + public function add_customer_data( $order_data, $order ) { if ( 0 == $order->customer_user ) { @@ -348,7 +337,7 @@ class WC_API_Customers extends WC_API_Resource { } else { - $order_data['customer'] = $this->getCustomer( $order->customer_user ); + $order_data['customer'] = $this->get_customer( $order->customer_user ); } return $order_data; @@ -373,6 +362,7 @@ class WC_API_Customers extends WC_API_Resource { * 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 */ @@ -382,9 +372,72 @@ class WC_API_Customers extends WC_API_Resource { $dom->loadHTML( get_avatar( $email ) ); - $url = $dom->getElementsByTagName('img')->item(0)->getAttribute('src'); + $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-orders.php b/includes/api/class-wc-api-orders.php index 86ebb7ab2a9..68fab61ae0c 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -20,7 +20,7 @@ class WC_API_Orders extends WC_API_Resource { /** * Register the routes for this class * - * GET|POST /orders + * GET /orders * GET /orders/count * GET|PUT|DELETE /orders/ * GET /orders//notes @@ -29,29 +29,28 @@ class WC_API_Orders extends WC_API_Resource { * @param array $routes * @return array */ - public function registerRoutes( $routes ) { + public function register_routes( $routes ) { - # GET|POST /orders + # GET /orders $routes[ $this->base ] = array( - array( array( $this, 'getOrders' ), WC_API_Server::READABLE ), - array( array( $this, 'createOrder' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'get_orders' ), WC_API_Server::READABLE ), ); # GET /orders/count $routes[ $this->base . '/count'] = array( - array( array( $this, 'getOrdersCount' ), WC_API_Server::READABLE ), + array( array( $this, 'get_orders_count' ), WC_API_Server::READABLE ), ); # GET|PUT|DELETE /orders/ $routes[ $this->base . '/(?P\d+)' ] = array( - array( array( $this, 'getOrder' ), WC_API_Server::READABLE ), - array( array( $this, 'editOrder' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), - array( array( $this, 'deleteOrder' ), WC_API_Server::DELETABLE ), + 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, 'getOrderNotes' ), WC_API_Server::READABLE ), + array( array( $this, 'get_order_notes' ), WC_API_Server::READABLE ), ); return $routes; @@ -61,39 +60,27 @@ class WC_API_Orders extends WC_API_Resource { * Get all orders * * @since 2.1 - * @param array $fields + * @param string $fields + * @param array $filter * @param string $status - * @param string $created_at_min - * @param string $created_at_max - * @param string $updated_at_min - * @param string $updated_at_max - * @param string $q search terms - * @param int $limit coupons per response - * @param int $offset * @return array */ - public function getOrders( $fields = array(), $status = null, $created_at_min = null, $created_at_max = null, $updated_at_min = null, $updated_at_max = null, $q = null, $limit = null, $offset = null ) { + public function get_orders( $fields = null, $filter = array(), $status = null ) { - $request_args = array( - 'status' => $status, - 'created_at_min' => $created_at_min, - 'created_at_max' => $created_at_max, - 'updated_at_min' => $updated_at_min, - 'updated_at_max' => $updated_at_max, - 'q' => $q, - 'limit' => $limit, - 'offset' => $offset, - ); + if ( ! empty( $status ) ) + $filter['status'] = $status; - $query = $this->queryOrders( $request_args ); + $query = $this->query_orders( $filter ); $orders = array(); foreach( $query->posts as $order_id ) { - $orders[] = $this->getOrder( $order_id, $fields ); + $orders[] = $this->get_order( $order_id, $fields ); } + $this->server->query_navigation_headers( $query ); + return array( 'orders' => $orders ); } @@ -106,20 +93,16 @@ class WC_API_Orders extends WC_API_Resource { * @param array $fields * @return array */ - public function getOrder( $id, $fields = null ) { + public function get_order( $id, $fields = null ) { - $id = absint( $id ); + // ensure order ID is valid & user has permission to read + $id = $this->validate_request( $id, 'shop_order', 'read' ); - if ( empty( $id ) ) - return new WP_Error( 'woocommerce_api_invalid_id', __( 'Invalid order ID', 'woocommerce' ), array( 'status' => 404 ) ); + if ( is_wp_error( $id ) ) + return $id; - // invalid IDs return a valid WC_Order object with customer_user equal to a blank string $order = new WC_Order( $id ); - // TODO: check post type instead or abstract into generic object/permissions check in base class @see self::getOrderNotes() - if ( '' === $order->customer_user ) - return new WP_Error( 'woocommerce_api_invalid_order', __( 'Invalid order', 'woocommerce' ), array( 'status' => 404 ) ); - $order_data = array( 'id' => $order->id, 'order_number' => $order->get_order_number(), @@ -128,15 +111,15 @@ class WC_API_Orders extends WC_API_Resource { 'completed_at' => $order->completed_date, 'status' => $order->status, 'currency' => $order->order_currency, - 'total' => $order->get_total(), - 'total_line_items_quantity' => $order->get_item_count(), - 'total_tax' => $order->get_total_tax(), - 'total_shipping' => $order->get_total_shipping(), - 'cart_tax' => $order->get_cart_tax(), - 'shipping_tax' => $order->get_shipping_tax(), - 'total_discount' => $order->get_total_discount(), - 'cart_discount' => $order->get_cart_discount(), - 'order_discount' => $order->get_order_discount(), + 'total' => (string) $order->get_total(), + 'total_line_items_quantity' => (string) $order->get_item_count(), + 'total_tax' => (string) $order->get_total_tax(), + 'total_shipping' => (string) $order->get_total_shipping(), + 'cart_tax' => (string) $order->get_cart_tax(), + 'shipping_tax' => (string) $order->get_shipping_tax(), + 'total_discount' => (string) $order->get_total_discount(), + 'cart_discount' => (string) $order->get_cart_discount(), + 'order_discount' => (string) $order->get_order_discount(), 'shipping_methods' => $order->get_shipping_method(), 'payment_details' => array( 'method_id' => $order->payment_method, @@ -181,10 +164,10 @@ class WC_API_Orders extends WC_API_Resource { $order_data['line_items'][] = array( 'id' => $item_id, - 'subtotal' => $order->get_line_subtotal( $item ), - 'total' => $order->get_line_total( $item ), - 'total_tax' => $order->get_line_tax( $item ), - 'quantity' => $item['qty'], + 'subtotal' => (string) $order->get_line_subtotal( $item ), + 'total' => (string) $order->get_line_total( $item ), + 'total_tax' => (string) $order->get_line_tax( $item ), + 'quantity' => (string) $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, @@ -199,7 +182,7 @@ class WC_API_Orders extends WC_API_Resource { 'id' => $shipping_item_id, 'method_id' => $shipping_item['method_id'], 'method_title' => $shipping_item['name'], - 'total' => $shipping_item['cost'], + 'total' => (string) number_format( $shipping_item['cost'], 2 ) ); } @@ -209,7 +192,7 @@ class WC_API_Orders extends WC_API_Resource { $order_data['tax_lines'][] = array( 'code' => $tax_code, 'title' => $tax->label, - 'total' => $tax->amount, + 'total' => (string) $tax->amount, 'compound' => (bool) $tax->is_compound, ); } @@ -221,8 +204,8 @@ class WC_API_Orders extends WC_API_Resource { 'id' => $fee_item_id, 'title' => $fee_item['name'], 'tax_class' => ( ! empty( $fee_item['tax_class'] ) ) ? $fee_item['tax_class'] : null, - 'total' => $order->get_line_total( $fee_item ), - 'total_tax' => $order->get_line_tax( $fee_item ), + 'total' => (string) $order->get_line_total( $fee_item ), + 'total_tax' => (string) $order->get_line_tax( $fee_item ), ); } @@ -232,7 +215,7 @@ class WC_API_Orders extends WC_API_Resource { $order_data['coupon_lines'] = array( 'id' => $coupon_item_id, 'code' => $coupon_item['name'], - 'amount' => $coupon_item['discount_amount'], + 'amount' => (string) number_format( $coupon_item['discount_amount'], 2), ); } @@ -251,40 +234,21 @@ class WC_API_Orders extends WC_API_Resource { * * @since 2.1 * @param string $status - * @param string $created_at_min - * @param string $created_at_max - * @param string $updated_at_min - * @param string $updated_at_max + * @param array $filter * @return array */ - public function getOrdersCount( $status = null, $created_at_min = null, $created_at_max = null, $updated_at_min = null, $updated_at_max = null ) { + public function get_orders_count( $status = null, $filter = array() ) { - $request_args = array( - 'status' => $status, - 'created_at_min' => $created_at_min, - 'created_at_max' => $created_at_max, - 'updated_at_min' => $updated_at_min, - 'updated_at_max' => $updated_at_max, - ); + if ( ! empty( $status ) ) + $filter['status'] = $status; - $query = $this->queryOrders( $request_args ); + $query = $this->query_orders( $filter ); + + // TODO: permissions? return array( 'count' => $query->found_posts ); } - /** - * Create an order - * - * @since 2.1 - * @param array $data - * @return array - */ - public function createOrder( $data ) { - - // TODO: implement - a woocommerce_create_new_order() function would be great - - return array(); - } /** * Edit an order @@ -294,11 +258,16 @@ class WC_API_Orders extends WC_API_Resource { * @param array $data * @return array */ - public function editOrder( $id, $data ) { + public function edit_order( $id, $data ) { + + $id = $this->validate_request( $id, 'shop_order', 'write' ); + + if ( is_wp_error( $id ) ) + return $id; // TODO: implement - return $this->getOrder( $id ); + return $this->get_order( $id ); } /** @@ -309,9 +278,11 @@ class WC_API_Orders extends WC_API_Resource { * @param bool $force true to permanently delete order, false to move to trash * @return array */ - public function deleteOrder( $id, $force = false ) { + public function delete_order( $id, $force = false ) { - return $this->deleteResource( $id, 'order', ( 'true' === $force ) ); + $id = $this->validate_request( $id, 'shop_order', 'delete' ); + + return $this->delete( $id, 'order', ( 'true' === $force ) ); } /** @@ -319,17 +290,13 @@ class WC_API_Orders extends WC_API_Resource { * @param $id * @return mixed */ - public function getOrderNotes( $id ) { + public function get_order_notes( $id ) { - $id = absint( $id ); + // ensure ID is valid order ID + $id = $this->validate_request( $id, 'order', 'read' ); - if ( empty( $id ) ) - return new WP_Error( 'woocommerce_api_invalid_id', __( 'Invalid order ID', 'woocommerce' ), array( 'status' => 404 ) ); - - $post = get_post( $id, ARRAY_A ); - - if ( 'shop_order' !== $post['post_type'] ) - return new WP_Error( 'woocommerce_api_invalid_order', __( 'Invalid order', 'woocommerce' ), array( 'status' => 404 ) ); + if ( is_wp_error( $id ) ) + return $id; $args = array( 'post_id' => $id, @@ -337,7 +304,6 @@ class WC_API_Orders extends WC_API_Resource { 'type' => 'order_note' ); - remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments', 10, 1 ) ); $notes = get_comments( $args ); @@ -349,7 +315,7 @@ class WC_API_Orders extends WC_API_Resource { foreach ( $notes as $note ) { $order_notes[] = array( - 'created_at' => $note->comment_date, + 'created_at' => $note->comment_date_gmt, // TODO: date formatting 'note' => $note->comment_content, 'customer_note' => get_comment_meta( $note->comment_ID, 'is_customer_note', true ) ? true : false, ); @@ -363,9 +329,9 @@ class WC_API_Orders extends WC_API_Resource { * * @since 2.1 * @param array $args request arguments for filtering query - * @return array + * @return WP_Query */ - private function queryOrders( $args ) { + private function query_orders( $args ) { // set base query arguments $query_args = array( @@ -382,15 +348,15 @@ class WC_API_Orders extends WC_API_Resource { $query_args['tax_query'] = array( array( 'taxonomy' => 'shop_order_status', - 'field' => 'slug', - 'terms' => $statuses, + 'field' => 'slug', + 'terms' => $statuses, ), ); + + unset( $args['status'] ); } - $query_args = $this->mergeQueryArgs( $query_args, $args ); - - // TODO: navigation/total count headers for pagination + $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 index 2c0498e2f19..c443e795713 100644 --- a/includes/api/class-wc-api-products.php +++ b/includes/api/class-wc-api-products.php @@ -20,7 +20,7 @@ class WC_API_Products extends WC_API_Resource { /** * Register the routes for this class * - * GET|POST /products + * GET /products * GET /products/count * GET|PUT|DELETE /products/ * GET /products//reviews @@ -29,29 +29,28 @@ class WC_API_Products extends WC_API_Resource { * @param array $routes * @return array */ - public function registerRoutes( $routes ) { + public function register_routes( $routes ) { - # GET|POST /products + # GET /products $routes[ $this->base ] = array( - array( array( $this, 'getProducts' ), WC_API_Server::READABLE ), - array( array( $this, 'createProduct' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'get_products' ), WC_API_Server::READABLE ), ); # GET /products/count $routes[ $this->base . '/count'] = array( - array( array( $this, 'getProductsCount' ), WC_API_Server::READABLE ), + array( array( $this, 'get_products_count' ), WC_API_Server::READABLE ), ); # GET|PUT|DELETE /products/ $routes[ $this->base . '/(?P\d+)' ] = array( - array( array( $this, 'getProduct' ), WC_API_Server::READABLE ), - array( array( $this, 'editProduct' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), - array( array( $this, 'deleteProduct' ), WC_API_Server::DELETABLE ), + 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, 'getProductReviews' ), WC_API_Server::READABLE ), + array( array( $this, 'get_product_reviews' ), WC_API_Server::READABLE ), ); return $routes; @@ -61,35 +60,30 @@ class WC_API_Products extends WC_API_Resource { * Get all products * * @since 2.1 - * @param array $fields + * @param string $fields * @param string $type - * @param string $created_at_min - * @param string $created_at_max - * @param string $q search terms - * @param int $limit coupons per response - * @param int $offset + * @param array $filter * @return array */ - public function getProducts( $fields = null, $type = null, $created_at_min = null, $created_at_max = null, $q = null, $limit = null, $offset = null ) { + public function get_products( $fields = null, $type = null, $filter = array() ) { - $request_args = array( - 'type' => $type, - 'created_at_min' => $created_at_min, - 'created_at_max' => $created_at_max, - 'q' => $q, - 'limit' => $limit, - 'offset' => $offset, - ); + if ( ! empty( $type ) ) + $filter['type'] = $type; - $query = $this->queryProducts( $request_args ); + $query = $this->query_products( $filter ); $products = array(); foreach( $query->posts as $product_id ) { - $products[] = $this->getProduct( $product_id, $fields ); + if ( ! $this->is_readable( $product_id ) ) + continue; + + $products[] = $this->get_product( $product_id, $fields ); } + $this->server->query_navigation_headers( $query ); + return array( 'products' => $products ); } @@ -101,18 +95,15 @@ class WC_API_Products extends WC_API_Resource { * @param string $fields * @return array */ - public function getProduct( $id, $fields = null ) { + public function get_product( $id, $fields = null ) { - $id = absint( $id ); + $id = $this->validate_request( $id, 'product', 'read' ); - if ( empty( $id ) ) - return new WP_Error( 'woocommerce_api_invalid_id', __( 'Invalid product ID', 'woocommerce' ), array( 'status' => 404 ) ); + if ( is_wp_error( $id ) ) + return $id; $product = get_product( $id ); - if ( 'product' !== $product->get_post_data()->post_type ) - return new WP_Error( 'woocommerce_api_invalid_product', __( 'Invalid product', 'woocommerce' ), array( 'status' => 404 ) ); - $product_data = array( 'id' => $product->id ); @@ -125,38 +116,21 @@ class WC_API_Products extends WC_API_Resource { * * @since 2.1 * @param string $type - * @param string $created_at_min - * @param string $created_at_max + * @param array $filter * @return array */ - public function getProductsCount( $type = null, $created_at_min = null, $created_at_max = null ) { + public function get_products_count( $type = null, $filter = array() ) { - $request_args = array( - 'type' => $type, - 'created_at_min' => $created_at_min, - 'created_at_max' => $created_at_max, - ); + if ( ! empty( $type ) ) + $filter['type'] = $type; - $query = $this->queryProducts( $request_args ); + // TODO: permissions? + + $query = $this->query_products( $filter ); return array( 'count' => $query->found_posts ); } - - /** - * Create a product - * - * @since 2.1 - * @param array $data - * @return array - */ - public function createProduct( $data ) { - - // TODO: implement - what's the minimum set of data required? woocommerce_create_product() would be nice - - return array(); - } - /** * Edit a product * @@ -165,11 +139,16 @@ class WC_API_Products extends WC_API_Resource { * @param array $data * @return array */ - public function editProduct( $id, $data ) { + public function edit_product( $id, $data ) { + + $id = $this->validate_request( $id, 'product', 'edit' ); + + if ( is_wp_error( $id ) ) + return $id; // TODO: implement - return $this->getProduct( $id ); + return $this->get_product( $id ); } /** @@ -180,9 +159,14 @@ class WC_API_Products extends WC_API_Resource { * @param bool $force true to permanently delete order, false to move to trash * @return array */ - public function deleteProduct( $id, $force = false ) { + public function delete_product( $id, $force = false ) { - return $this->deleteResource( $id, 'product', ( 'true' === $force ) ); + $id = $this->validate_request( $id, 'product', 'delete' ); + + if ( is_wp_error( $id ) ) + return $id; + + return $this->delete( $id, 'product', ( 'true' === $force ) ); } /** @@ -190,7 +174,14 @@ class WC_API_Products extends WC_API_Resource { * @param $id * @return mixed */ - public function getProductReviews( $id ) { + public function get_product_reviews( $id ) { + + $id = $this->validate_request( $id, 'product', 'read' ); + + if ( is_wp_error( $id ) ) + return $id; + + // TODO: implement return array(); } @@ -200,9 +191,9 @@ class WC_API_Products extends WC_API_Resource { * * @since 2.1 * @param array $args request arguments for filtering query - * @return array + * @return WP_Query */ - private function queryProducts( $args ) { + private function query_products( $args ) { // set base query arguments $query_args = array( @@ -213,12 +204,23 @@ class WC_API_Products extends WC_API_Resource { 'meta_query' => array(), ); + if ( ! empty( $args['type'] ) ) { + + $query_args['tax_query'] = array( + array( + 'taxonomy' => 'product_type', + 'field' => 'slug', + 'terms' => $args['type'], + ), + ); + + unset( $args['type'] ); + } + // TODO: some param to show hidden products, but hide by default $query_args['meta_query'][] = WC()->query->visibility_meta_query(); - $query_args = $this->mergeQueryArgs( $query_args, $args ); - - // TODO: navigation/total count headers for pagination + $query_args = $this->merge_query_args( $query_args, $args ); return new WP_Query( $query_args ); } diff --git a/includes/api/class-wc-api-reports.php b/includes/api/class-wc-api-reports.php index eef5e99d73f..2b2a8966f79 100644 --- a/includes/api/class-wc-api-reports.php +++ b/includes/api/class-wc-api-reports.php @@ -28,16 +28,16 @@ class WC_API_Reports extends WC_API_Resource { * @param array $routes * @return array */ - public function registerRoutes( $routes ) { + public function register_routes( $routes ) { # GET /reports $routes[ $this->base ] = array( - array( array( $this, 'getReports' ), WC_API_Server::READABLE ), + array( array( $this, 'get_reports' ), WC_API_Server::READABLE ), ); # GET /reports/sales $routes[ $this->base . '/sales'] = array( - array( array( $this, 'getSalesReport' ), WC_API_Server::READABLE ), + array( array( $this, 'get_sales_report' ), WC_API_Server::READABLE ), ); return $routes; @@ -50,7 +50,7 @@ class WC_API_Reports extends WC_API_Resource { * @since 2.1 * @return array */ - public function getReports() { + public function get_reports() { return array( 'reports' => array( 'sales' ) ); } @@ -62,7 +62,7 @@ class WC_API_Reports extends WC_API_Resource { * @since 2.1 * @return array */ - public function getSalesReport() { + public function get_sales_report() { // TODO: implement - DRY by abstracting the report classes? diff --git a/includes/api/class-wc-api-resource.php b/includes/api/class-wc-api-resource.php index efae3e96e73..0c8f45275d8 100644 --- a/includes/api/class-wc-api-resource.php +++ b/includes/api/class-wc-api-resource.php @@ -32,17 +32,75 @@ class WC_API_Resource { $this->server = $server; // automatically register routes for sub-classes - add_filter( 'woocommerce_api_endpoints', array( $this, 'registerRoutes' ) ); + 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 - add_filter( 'woocommerce_api_order_response', array( $this, 'filterFields' ), 20, 3 ); - add_filter( 'woocommerce_api_coupon_response', array( $this, 'filterFields' ), 20, 3 ); - add_filter( 'woocommerce_api_customer_response', array( $this, 'filterFields' ), 20, 3 ); - add_filter( 'woocommerce_api_product_response', array( $this, 'filterFields' ), 20, 3 ); + 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 ); + + // validate post type + if ( $type !== $post['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 @@ -52,25 +110,32 @@ class WC_API_Resource { * @param array $request_args arguments provided in the request * @return array */ - protected function mergeQueryArgs( $base_args, $request_args ) { + protected function merge_query_args( $base_args, $request_args ) { $args = array(); - // TODO: updated_at_min, updated_at_max,s date formatting - // TODO: WP 3.7 is required to support date args - if ( ! empty( $request_args['created_at_min'] ) || ! empty( $request_args['created_at_max'] ) ) { + // TODO: convert all dates from provided timezone into UTC + // TODO: return all dates in provided timezone, else UTC + // 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( - array( - 'inclusive' => true, - ) - ); + $args['date_query'] = array(); + // resources created after specified date if ( ! empty( $request_args['created_at_min'] ) ) - $args['date_query'] = array_merge( $args['date_query'], array( 'after' => $request_args['created_at_min'] ) ); + $args['date_query'][] = array( 'column' => 'post_date_gmt', 'after' => $request_args['created_at_min'], 'inclusive' => true ); + // resources created before specified date if ( ! empty( $request_args['created_at_max'] ) ) - $args['date_query'] = array_merge( $args['date_query'], array( 'before' => $request_args['created_at_min'] ) ); + $args['date_query'][] = array( 'column' => 'post_date_gmt', 'before' => $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' => $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' => $request_args['updated_at_max'], 'inclusive' => true ); } // search @@ -85,9 +150,35 @@ class WC_API_Resource { if ( ! empty( $request_args['offset'] ) ) $args['offset'] = $request_args['offset']; + // resource page + if ( empty( $request_args['page'] ) ) + $args['paged'] = 1; + else + $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'] ) { + + // TODO: implement + } + + return $data; + } + /** * Restrict the fields included in the response if the request specified certain only certain fields should be returned * @@ -99,7 +190,7 @@ class WC_API_Resource { * @param array|string the requested list of fields to include in the response * @return mixed */ - public function filterFields( $data, $resource, $fields ) { + public function filter_response_fields( $data, $resource, $fields ) { if ( empty( $fields ) ) return $data; @@ -118,20 +209,18 @@ class WC_API_Resource { /** * Delete a given resource * - * @see WP_JSON_Posts::deletePost - * * @since 2.1 * @param int $id the resource ID - * @param string $type the type of resource, either `order`,`coupon`, `product`, or `customer` + * @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 deleteResource( $id, $type, $force = false ) { + protected function delete( $id, $type, $force = false ) { - $id = absint( $id ); - - if ( empty( $id ) ) - return new WP_Error( 'woocommerce_api_invalid_id', sprintf( __( 'Invalid %s ID', 'woocommerce' ), $type ), array( 'status' => 404 ) ); + if ( 'shop_order' === $type || 'shop_coupon' === $type ) + $resource_name = str_replace( 'shop_', '', $type ); + else + $resource_name = $type; if ( 'customer' === $type ) { @@ -146,33 +235,93 @@ class WC_API_Resource { // delete order/coupon/product - $post = get_post( $id, ARRAY_A ); - - // TODO: check if provided $type is the same as $post['post_type'] - - if ( empty( $post['ID'] ) ) - return new WP_Error( 'woocommerce_api_invalid_id', sprintf( __( 'Invalid % ID', 'woocommerce' ), $type ), array( 'status' => 404 ) ); - - $post_type = get_post_type_object( $post['post_type'] ); - - if ( ! current_user_can( $post_type->cap->delete_post, $id ) ) - return new WP_Error( "woocommerce_api_user_cannot_delete_{$type}", sprintf( __( 'You do not have permission to delete this %s', 'woocommerce' ), $type ), array( 'status' => 401 ) ); - $result = ( $force ) ? wp_delete_post( $id, true ) : wp_trash_post( $id ); if ( ! $result ) - return new WP_Error( "woocommerce_api_cannot_delete_{$type}", sprintf( __( 'The %s cannot be deleted', 'woocommerce' ), $type ), array( 'status' => 500 ) ); + 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' ), $type ) ); + return array( 'message' => sprintf( __( 'Permanently deleted %s', 'woocommerce' ), $resource_name ) ); } else { $this->server->send_status( '202' ); - return array( 'message' => sprintf( __( 'Deleted %s', 'woocommerce' ), $type ) ); + 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; + } + } From 00c65b9cc3294ef17a04ffd59c22e4e30a0184f8 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Sun, 10 Nov 2013 19:30:59 -0500 Subject: [PATCH 28/43] Add site timezone to API index --- includes/api/class-wc-api-authentication.php | 1 - includes/api/class-wc-api-server.php | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/api/class-wc-api-authentication.php b/includes/api/class-wc-api-authentication.php index 99930768296..e6783dc75c3 100644 --- a/includes/api/class-wc-api-authentication.php +++ b/includes/api/class-wc-api-authentication.php @@ -165,7 +165,6 @@ class WC_API_Authentication { */ private function is_secret_key_valid( WP_User $user, $secret_key ) { - // TODO: consider hashing secret key prior to storing it using wp_hash_password(), but this would prevent user from seeing it more than once return $user->woocommerce_api_secret_key === $secret_key; } diff --git a/includes/api/class-wc-api-server.php b/includes/api/class-wc-api-server.php index c2f2cc909c8..d6167fcd4f4 100644 --- a/includes/api/class-wc-api-server.php +++ b/includes/api/class-wc-api-server.php @@ -426,6 +426,7 @@ class WC_API_Server { 'URL' => get_option( 'siteurl' ), 'routes' => array(), 'meta' => array( + 'timezone' => $this->get_timezone(), 'currency' => get_woocommerce_currency(), 'weight_unit' => get_option( 'woocommerce_weight_unit' ), 'dimension_unit' => get_option( 'woocommerce_dimension_unit' ), @@ -527,7 +528,7 @@ class WC_API_Server { $paged = 1; $nextpage = intval($paged) + 1; - + // TODO: change from `page` query arg to filter[page] arg if ( ! $query->is_single() ) { if ( $paged > 1 ) { $request = remove_query_arg( 'page' ); @@ -586,7 +587,7 @@ class WC_API_Server { if ( strpos( $date, '.' ) !== false ) { $date = preg_replace( '/\.\d+/', '', $date ); } - $datetime = DateTime::createFromFormat( DateTime::RFC3339, $date ); + $datetime = DateTime::createFromFormat( DateTime::RFC3339, $date ); // TODO: rewrite, PHP 5.3+ required for this return $datetime; } From 674ea420123cde15395760febfb3ae6fd2ad6e4b Mon Sep 17 00:00:00 2001 From: Max Rice Date: Thu, 14 Nov 2013 12:40:35 -0500 Subject: [PATCH 29/43] Implement GET endpoint for products and reviews Part of #4055 --- includes/api/class-wc-api-products.php | 325 ++++++++++++++++++++++++- includes/api/class-wc-api-resource.php | 5 +- 2 files changed, 321 insertions(+), 9 deletions(-) diff --git a/includes/api/class-wc-api-products.php b/includes/api/class-wc-api-products.php index c443e795713..49d8734a569 100644 --- a/includes/api/class-wc-api-products.php +++ b/includes/api/class-wc-api-products.php @@ -104,9 +104,20 @@ class WC_API_Products extends WC_API_Resource { $product = get_product( $id ); - $product_data = array( - 'id' => $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 ); } @@ -171,19 +182,42 @@ class WC_API_Products extends WC_API_Resource { /** * Get the reviews for a product - * @param $id - * @return mixed + * + * @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 ) { + public function get_product_reviews( $id, $fields = null ) { $id = $this->validate_request( $id, 'product', 'read' ); if ( is_wp_error( $id ) ) return $id; - // TODO: implement + $args = array( + 'post_id' => $id, + 'approve' => 'approve', + ); - return array(); + $comments = get_comments( $args ); + + $reviews = array(); + + foreach ( $comments as $comment ) { + + $reviews[] = array( + 'id' => $comment->comment_ID, + 'created_at' => $comment->comment_date_gmt, // TODO: date formatting + '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 apply_filters( 'woocommerce_api_product_reviews_response', array( 'product_reviews' => $reviews ), $id, $fields, $comments, $this->server ); } /** @@ -225,4 +259,279 @@ class WC_API_Products extends WC_API_Resource { 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' => $product->get_post_data()->post_date_gmt, // TODO: date formatting + 'updated_at' => $product->get_post_data()->post_modified_gmt, // TODO: date formatting + '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' => (string) $product->get_price(), + 'regular_price' => (string) $product->get_regular_price(), + 'sale_price' => (string) $product->get_sale_price(), + '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' => (string) $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(), + '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' => $product->get_average_rating(), + 'rating_count' => $product->get_rating_count(), + 'related_ids' => array_values( $product->get_related() ), + 'upsell_ids' => $product->get_upsells(), + 'cross_sell_ids' => $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' => $product->download_limit, + 'download_expiry' => $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' => $variation->get_post_data()->post_date_gmt, // TODO: date formatting + 'updated_at' => $variation->get_post_data()->post_modified_gmt, // TODO: date formatting + 'downloadable' => $variation->is_downloadable(), + 'virtual' => $variation->is_virtual(), + 'permalink' => $variation->get_permalink(), + 'sku' => $variation->get_sku(), + 'price' => (string) $variation->get_price(), + 'regular_price' => (string) $variation->get_regular_price(), + 'sale_price' => (string) $variation->get_sale_price(), + 'taxable' => $variation->is_taxable(), + 'tax_status' => $variation->get_tax_status(), + 'tax_class' => $variation->get_tax_class(), + 'stock_quantity' => (string) $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(), + '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' => $product->download_limit, + 'download_expiry' => $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' => $attachment_post->post_date_gmt, // TODO: date formatting + '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' => gmdate( 'Y-m-d H:i:s' ), // TODO: date formatting + '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-resource.php b/includes/api/class-wc-api-resource.php index 0c8f45275d8..1faee34d259 100644 --- a/includes/api/class-wc-api-resource.php +++ b/includes/api/class-wc-api-resource.php @@ -75,8 +75,11 @@ class WC_API_Resource { $post = get_post( $id, ARRAY_A ); + // TODO: redo this check, it's a bit janky + $post_type = ( 'product_variation' === $post['post_type'] ) ? 'product' : $post['post_type']; + // validate post type - if ( $type !== $post['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 From 451bc073397f73a6dddf3d6a0bf44f75c7552f0f Mon Sep 17 00:00:00 2001 From: Max Rice Date: Thu, 14 Nov 2013 12:42:42 -0500 Subject: [PATCH 30/43] Tweak GET /order response Part of #4055 --- includes/api/class-wc-api-orders.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php index 68fab61ae0c..b017b5e617e 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -155,6 +155,11 @@ class WC_API_Orders extends WC_API_Resource { '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 @@ -219,13 +224,6 @@ class WC_API_Orders extends WC_API_Resource { ); } - // ensure line properties exist in response - foreach ( array( 'line_items', 'shipping_lines', 'tax_lines', 'fee_lines', 'coupon_lines' ) as $line ) { - - if ( ! isset( $order_data[ $line ] ) ) - $order_data[ $line ] = array(); - } - return apply_filters( 'woocommerce_api_order_response', $order_data, $order, $fields ); } @@ -315,6 +313,7 @@ class WC_API_Orders extends WC_API_Resource { foreach ( $notes as $note ) { $order_notes[] = array( + 'id' => $note->comment_ID, 'created_at' => $note->comment_date_gmt, // TODO: date formatting 'note' => $note->comment_content, 'customer_note' => get_comment_meta( $note->comment_ID, 'is_customer_note', true ) ? true : false, From 709f0da16f0298724f78bfad5df3e24057faaebd Mon Sep 17 00:00:00 2001 From: Max Rice Date: Thu, 14 Nov 2013 12:48:20 -0500 Subject: [PATCH 31/43] Improve response filters Part of #4055 --- includes/api/class-wc-api-coupons.php | 2 +- includes/api/class-wc-api-customers.php | 4 ++-- includes/api/class-wc-api-orders.php | 11 +++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php index 8d0e2dc8ee6..e0ced4f188e 100644 --- a/includes/api/class-wc-api-coupons.php +++ b/includes/api/class-wc-api-coupons.php @@ -130,7 +130,7 @@ class WC_API_Coupons extends WC_API_Resource { 'customer_email' => $coupon->customer_email, ); - return apply_filters( 'woocommerce_api_coupon_response', $coupon_data, $coupon, $fields ); + return apply_filters( 'woocommerce_api_coupon_response', $coupon_data, $coupon, $fields, $this->server ); } /** diff --git a/includes/api/class-wc-api-customers.php b/includes/api/class-wc-api-customers.php index f4a4ec941c2..55cf0978dc2 100644 --- a/includes/api/class-wc-api-customers.php +++ b/includes/api/class-wc-api-customers.php @@ -174,7 +174,7 @@ class WC_API_Customers extends WC_API_Resource { ), ); - return apply_filters( 'woocommerce_api_customer_response', $customer_data, $customer, $fields ); + return apply_filters( 'woocommerce_api_customer_response', $customer_data, $customer, $fields, $this->server ); } /** @@ -282,7 +282,7 @@ class WC_API_Customers extends WC_API_Resource { $orders[] = WC()->api->WC_API_Orders->get_order( $order_id, $fields ); } - return array( 'orders' => $orders ); + return apply_filters( 'woocommerce_api_customer_orders_response', array( 'orders' => $orders ), $id, $fields, $order_ids, $this->server ); } /** diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php index b017b5e617e..1f836e9a8d1 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -285,10 +285,13 @@ class WC_API_Orders extends WC_API_Resource { /** * Get the admin order notes for an order - * @param $id - * @return mixed + * + * @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 ) { + public function get_order_notes( $id, $fields = null ) { // ensure ID is valid order ID $id = $this->validate_request( $id, 'order', 'read' ); @@ -320,7 +323,7 @@ class WC_API_Orders extends WC_API_Resource { ); } - return array( 'order_notes' => $order_notes ); + return apply_filters( 'woocommerce_api_order_notes_response', array( 'order_notes' => $order_notes ), $id, $fields, $notes, $this->server ); } /** From ec55e43c24a52340a4558ab22d38fcfeb8907331 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Thu, 14 Nov 2013 13:55:45 -0500 Subject: [PATCH 32/43] Update API index response Part of #4055 --- includes/api/class-wc-api-server.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/includes/api/class-wc-api-server.php b/includes/api/class-wc-api-server.php index d6167fcd4f4..7b557657c47 100644 --- a/includes/api/class-wc-api-server.php +++ b/includes/api/class-wc-api-server.php @@ -426,11 +426,13 @@ class WC_API_Server { 'URL' => get_option( 'siteurl' ), 'routes' => array(), 'meta' => array( - 'timezone' => $this->get_timezone(), + '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' ), - 'supports_ssl' => ( 'yes' === get_option( 'woocommerce_force_ssl_checkout' ) ), + '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 From 5ab8e085ad3aefb912676738dda1fed234a23ec3 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Thu, 14 Nov 2013 13:56:05 -0500 Subject: [PATCH 33/43] Update /coupons/code endpoint Part of #4055 --- includes/api/class-wc-api-coupons.php | 5 +++-- includes/api/class-wc-api-server.php | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php index e0ced4f188e..1e1dff93cd4 100644 --- a/includes/api/class-wc-api-coupons.php +++ b/includes/api/class-wc-api-coupons.php @@ -49,8 +49,8 @@ class WC_API_Coupons extends WC_API_Resource { array( array( $this, 'delete_coupon' ), WC_API_Server::DELETABLE ), ); - # GET /coupons/ TODO: should looking up coupon codes containing spaces or dashes be supported? OR all-digit coupon codes - $routes[ $this->base . '/(?P\w+)' ] = array( + # GET /coupons/code/ + $routes[ $this->base . '/code/(?P\w[\w\s\-]*)' ] = array( // note that coupon codes can contain spaces, dashes and underscores array( array( $this, 'get_coupon_by_code' ), WC_API_Server::READABLE ), ); @@ -152,6 +152,7 @@ class WC_API_Coupons extends WC_API_Resource { /** * 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 diff --git a/includes/api/class-wc-api-server.php b/includes/api/class-wc-api-server.php index 7b557657c47..772b2675fc4 100644 --- a/includes/api/class-wc-api-server.php +++ b/includes/api/class-wc-api-server.php @@ -326,7 +326,7 @@ class WC_API_Server { if ( !( $supported & $method ) ) continue; - $match = preg_match( '@^' . $route . '$@i', $this->path, $args ); + $match = preg_match( '@^' . $route . '$@i', urldecode( $this->path ), $args ); if ( !$match ) continue; From c1854b26b946d9ca7e01ddd9f7cad4176bd84422 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Fri, 15 Nov 2013 00:21:19 -0500 Subject: [PATCH 34/43] Implement GET /reports/sales endpoint Part of #4055 --- includes/api/class-wc-api-reports.php | 301 +++++++++++++++++++++++++- 1 file changed, 296 insertions(+), 5 deletions(-) diff --git a/includes/api/class-wc-api-reports.php b/includes/api/class-wc-api-reports.php index 2b2a8966f79..6b42fa58f79 100644 --- a/includes/api/class-wc-api-reports.php +++ b/includes/api/class-wc-api-reports.php @@ -18,6 +18,9 @@ class WC_API_Reports extends WC_API_Resource { /** @var string $base the route base */ protected $base = '/reports'; + /** @var WC_Admin_Report instance */ + private $report; + /** * Register the routes for this class * @@ -43,7 +46,6 @@ class WC_API_Reports extends WC_API_Resource { return $routes; } - /** * Get a simple listing of available reports * @@ -55,18 +57,307 @@ class WC_API_Reports extends WC_API_Resource { 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() { + public function get_sales_report( $fields = null, $filter = array() ) { - // TODO: implement - DRY by abstracting the report classes? + // check user permissions + $check = $this->validate_request(); - return array(); + 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' + ), + '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' => 0, + 'orders' => 0, + 'items' => 0, + 'tax' => 0, + 'shipping' => 0, + 'discount' => 0, + ); + } + + // 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'] = $order->total_sales; + $period_totals[ $time ]['orders'] = $order->total_orders; + $period_totals[ $time ]['tax'] = 1; + $period_totals[ $time ]['shipping'] = $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'] = $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'] = $discount->discount_amount; + } + + $sales_data = array( + 'sales' => $totals->sales, + 'average' => (string) number_format( $totals->sales / ( $this->report->chart_interval + 1 ), 2 ), + 'orders' => absint( $totals->order_count ), + 'items' => $total_items, + 'tax' => (string) number_format( $totals->tax + $totals->shipping_tax, 2 ), + 'shipping' => $totals->shipping, + 'discount' => is_null( $total_discount ) ? 0 : $total_discount, + 'totals' => $period_totals, + 'totals_grouped_by' => $this->report->chart_groupby, + ); + + return apply_filters( 'woocommerce_api_sales_report_response', array( 'sales' => $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'] = $filter['date_min']; // TODO: date formatting? + $_GET['end_date'] = $filter['date_max']; // TODO: date formatting + + } else { + + // default custom range to today + $_GET['start_date'] = $_GET['end_date'] = date( 'Y-m-d', current_time( 'timestamp' ) ); + } + } + + // TODO: handle invalid periods (e.g. `decade`) + + $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; + } + } } From 87ff36db123136b1ada111798e4a8e7781111551 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Mon, 18 Nov 2013 16:47:38 -0500 Subject: [PATCH 35/43] Properly handle datetimes Part of #4055 --- includes/api/class-wc-api-coupons.php | 13 ++- includes/api/class-wc-api-customers.php | 23 ++-- includes/api/class-wc-api-json-handler.php | 2 +- includes/api/class-wc-api-orders.php | 51 +++++---- includes/api/class-wc-api-products.php | 61 +++++------ includes/api/class-wc-api-reports.php | 69 +++++++----- includes/api/class-wc-api-resource.php | 12 +-- includes/api/class-wc-api-server.php | 117 +++++++++++---------- includes/wc-formatting-functions.php | 47 ++++++++- 9 files changed, 236 insertions(+), 159 deletions(-) diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php index 1e1dff93cd4..b5e6d5ad8ba 100644 --- a/includes/api/class-wc-api-coupons.php +++ b/includes/api/class-wc-api-coupons.php @@ -49,8 +49,8 @@ class WC_API_Coupons extends WC_API_Resource { array( array( $this, 'delete_coupon' ), WC_API_Server::DELETABLE ), ); - # GET /coupons/code/ - $routes[ $this->base . '/code/(?P\w[\w\s\-]*)' ] = array( // note that coupon codes can contain spaces, dashes and underscores + # 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 ), ); @@ -108,11 +108,15 @@ class WC_API_Coupons extends WC_API_Resource { $coupon = new WC_Coupon( $code ); + $coupon_post = get_post( $coupon->id ); + $coupon_data = array( 'id' => $coupon->id, 'code' => $coupon->code, 'type' => $coupon->type, - 'amount' => (string) number_format( $coupon->amount, 2 ), + '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, @@ -120,7 +124,7 @@ class WC_API_Coupons extends WC_API_Resource { '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' => $coupon->expiry_date, + '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, @@ -200,6 +204,7 @@ class WC_API_Coupons extends WC_API_Resource { return $id; // TODO: implement + return $this->get_coupon( $id ); } diff --git a/includes/api/class-wc-api-customers.php b/includes/api/class-wc-api-customers.php index 55cf0978dc2..61653e569d4 100644 --- a/includes/api/class-wc-api-customers.php +++ b/includes/api/class-wc-api-customers.php @@ -103,7 +103,7 @@ class WC_API_Customers extends WC_API_Resource { $customers[] = $this->get_customer( $user_id, $fields ); } - // TODO: add navigation/total count headers for pagination + // TODO: add navigation/total count headers for pagination return array( 'customers' => $customers ); } @@ -127,7 +127,7 @@ class WC_API_Customers extends WC_API_Resource { $customer = new WP_User( $id ); // get info about user's last order - $last_order = $wpdb->get_row( "SELECT id, post_date + $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' @@ -138,15 +138,15 @@ class WC_API_Customers extends WC_API_Resource { $customer_data = array( 'id' => $customer->ID, - 'created_at' => $customer->user_registered, + '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 ) ? $last_order->post_date : null, - 'orders_count' => $customer->_order_count, - 'total_spent' => (string) number_format( $customer->_money_spent, 2 ), + '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, @@ -188,7 +188,8 @@ class WC_API_Customers extends WC_API_Resource { $query = $this->query_customers( $filter ); - // TODO: permissions? + 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' => $query->get_total() ); } @@ -311,10 +312,10 @@ class WC_API_Customers extends WC_API_Resource { $query_args['offset'] = $args['offset']; if ( ! empty( $args['created_at_min'] ) ) - $this->created_at_min = $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 = $args['created_at_max']; + $this->created_at_max = $this->server->parse_datetime( $args['created_at_max'] ); // TODO: support page argument - requires custom implementation as WP_User_Query has no built-in pagination like WP_Query @@ -352,10 +353,10 @@ class WC_API_Customers extends WC_API_Resource { public function modify_user_query( $query ) { if ( $this->created_at_min ) - $query->query_where .= sprintf( " AND DATE(user_registered) >= '%s'", date( 'Y-m-d H:i:s', strtotime( $this->created_at_min ) ) ); // TODO: date formatting + $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 DATE(user_registered) <= '%s'", date( 'Y-m-d H:i:s', strtotime( $this->created_at_max ) ) ); // TODO: date formatting + $query->query_where .= sprintf( " AND user_registered <= STR_TO_DATE( '%s', '%%Y-%%m-%%d %%h:%%i:%%s' )", esc_sql( $this->created_at_max ) ); } /** diff --git a/includes/api/class-wc-api-json-handler.php b/includes/api/class-wc-api-json-handler.php index 9bad7855bbd..c08ebd4789a 100644 --- a/includes/api/class-wc-api-json-handler.php +++ b/includes/api/class-wc-api-json-handler.php @@ -61,7 +61,7 @@ class WC_API_JSON_Handler implements WC_API_Handler { WC()->api->server->send_status( 400 ); - $data = array( array( 'code' => 'woocommerce_api_json_callback_invalid', __( 'The JSONP callback function is invalid', 'woocommerce' ) ) );; + $data = array( array( 'code' => 'woocommerce_api_json_callback_invalid', __( 'The JSONP callback function is invalid', 'woocommerce' ) ) ); } return $_GET['_jsonp'] . '(' . json_encode( $data ) . ')'; diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php index 1f836e9a8d1..67ae8aaac08 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -76,6 +76,9 @@ class WC_API_Orders extends WC_API_Resource { foreach( $query->posts as $order_id ) { + if ( ! $this->is_readable( $order_id ) ) + continue; + $orders[] = $this->get_order( $order_id, $fields ); } @@ -103,23 +106,25 @@ class WC_API_Orders extends WC_API_Resource { $order = new WC_Order( $id ); + $order_post = get_post( $id ); + $order_data = array( 'id' => $order->id, 'order_number' => $order->get_order_number(), - 'created_at' => $order->order_date, - 'updated_at' => $order->modified_date, - 'completed_at' => $order->completed_date, + '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' => (string) $order->get_total(), - 'total_line_items_quantity' => (string) $order->get_item_count(), - 'total_tax' => (string) $order->get_total_tax(), - 'total_shipping' => (string) $order->get_total_shipping(), - 'cart_tax' => (string) $order->get_cart_tax(), - 'shipping_tax' => (string) $order->get_shipping_tax(), - 'total_discount' => (string) $order->get_total_discount(), - 'cart_discount' => (string) $order->get_cart_discount(), - 'order_discount' => (string) $order->get_order_discount(), + '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, @@ -169,10 +174,10 @@ class WC_API_Orders extends WC_API_Resource { $order_data['line_items'][] = array( 'id' => $item_id, - 'subtotal' => (string) $order->get_line_subtotal( $item ), - 'total' => (string) $order->get_line_total( $item ), - 'total_tax' => (string) $order->get_line_tax( $item ), - 'quantity' => (string) $item['qty'], + '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, @@ -187,7 +192,7 @@ class WC_API_Orders extends WC_API_Resource { 'id' => $shipping_item_id, 'method_id' => $shipping_item['method_id'], 'method_title' => $shipping_item['name'], - 'total' => (string) number_format( $shipping_item['cost'], 2 ) + 'total' => woocommerce_format_decimal( $shipping_item['cost'] ), ); } @@ -197,7 +202,7 @@ class WC_API_Orders extends WC_API_Resource { $order_data['tax_lines'][] = array( 'code' => $tax_code, 'title' => $tax->label, - 'total' => (string) $tax->amount, + 'total' => woocommerce_format_decimal( $tax->amount ), 'compound' => (bool) $tax->is_compound, ); } @@ -209,8 +214,8 @@ class WC_API_Orders extends WC_API_Resource { 'id' => $fee_item_id, 'title' => $fee_item['name'], 'tax_class' => ( ! empty( $fee_item['tax_class'] ) ) ? $fee_item['tax_class'] : null, - 'total' => (string) $order->get_line_total( $fee_item ), - 'total_tax' => (string) $order->get_line_tax( $fee_item ), + 'total' => woocommerce_format_decimal( $order->get_line_total( $fee_item ) ), + 'total_tax' => woocommerce_format_decimal( $order->get_line_tax( $fee_item ) ), ); } @@ -220,7 +225,7 @@ class WC_API_Orders extends WC_API_Resource { $order_data['coupon_lines'] = array( 'id' => $coupon_item_id, 'code' => $coupon_item['name'], - 'amount' => (string) number_format( $coupon_item['discount_amount'], 2), + 'amount' => woocommerce_format_decimal( $coupon_item['discount_amount'] ), ); } @@ -263,7 +268,7 @@ class WC_API_Orders extends WC_API_Resource { if ( is_wp_error( $id ) ) return $id; - // TODO: implement + // TODO: implement, especially for status change return $this->get_order( $id ); } @@ -317,7 +322,7 @@ class WC_API_Orders extends WC_API_Resource { $order_notes[] = array( 'id' => $note->comment_ID, - 'created_at' => $note->comment_date_gmt, // TODO: date formatting + '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, ); diff --git a/includes/api/class-wc-api-products.php b/includes/api/class-wc-api-products.php index 49d8734a569..68b2d563b84 100644 --- a/includes/api/class-wc-api-products.php +++ b/includes/api/class-wc-api-products.php @@ -208,7 +208,7 @@ class WC_API_Products extends WC_API_Resource { $reviews[] = array( 'id' => $comment->comment_ID, - 'created_at' => $comment->comment_date_gmt, // TODO: date formatting + '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, @@ -240,20 +240,19 @@ class WC_API_Products extends WC_API_Resource { if ( ! empty( $args['type'] ) ) { + $types = explode( ',', $args['type'] ); + $query_args['tax_query'] = array( array( 'taxonomy' => 'product_type', 'field' => 'slug', - 'terms' => $args['type'], + 'terms' => $types, ), ); unset( $args['type'] ); } - // TODO: some param to show hidden products, but hide by default - $query_args['meta_query'][] = WC()->query->visibility_meta_query(); - $query_args = $this->merge_query_args( $query_args, $args ); return new WP_Query( $query_args ); @@ -271,23 +270,23 @@ class WC_API_Products extends WC_API_Resource { return array( 'title' => $product->get_title(), 'id' => (int) $product->is_type( 'variation' ) ? $product->get_variation_id() : $product->id, - 'created_at' => $product->get_post_data()->post_date_gmt, // TODO: date formatting - 'updated_at' => $product->get_post_data()->post_modified_gmt, // TODO: date formatting + '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' => (string) $product->get_price(), - 'regular_price' => (string) $product->get_regular_price(), - 'sale_price' => (string) $product->get_sale_price(), + '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' => (string) $product->get_stock_quantity(), + 'stock_quantity' => (int) $product->get_stock_quantity(), 'in_stock' => $product->is_in_stock(), 'backorders_allowed' => $product->backorders_allowed(), 'backordered' => $product->is_on_backorder(), @@ -297,7 +296,7 @@ class WC_API_Products extends WC_API_Resource { 'visible' => $product->is_visible(), 'catalog_visibility' => $product->visibility, 'on_sale' => $product->is_on_sale(), - 'weight' => $product->get_weight(), + 'weight' => $product->get_weight() ? woocommerce_format_decimal( $product->get_weight() ) : null, 'dimensions' => array( 'length' => $product->length, 'width' => $product->width, @@ -311,18 +310,18 @@ class WC_API_Products extends WC_API_Resource { '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' => $product->get_average_rating(), - 'rating_count' => $product->get_rating_count(), - 'related_ids' => array_values( $product->get_related() ), - 'upsell_ids' => $product->get_upsells(), - 'cross_sell_ids' => $product->get_cross_sells(), + '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' => $product->download_limit, - 'download_expiry' => $product->download_expiry, + '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(), @@ -350,25 +349,25 @@ class WC_API_Products extends WC_API_Resource { $variations[] = array( 'id' => $variation->get_variation_id(), - 'created_at' => $variation->get_post_data()->post_date_gmt, // TODO: date formatting - 'updated_at' => $variation->get_post_data()->post_modified_gmt, // TODO: date formatting + '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' => (string) $variation->get_price(), - 'regular_price' => (string) $variation->get_regular_price(), - 'sale_price' => (string) $variation->get_sale_price(), + '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' => (string) $variation->get_stock_quantity(), + '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(), + 'weight' => $variation->get_weight() ? woocommerce_format_decimal( $variation->get_weight() ) : null, 'dimensions' => array( 'length' => $variation->length, 'width' => $variation->width, @@ -380,8 +379,8 @@ class WC_API_Products extends WC_API_Resource { 'image' => $this->get_images( $variation ), 'attributes' => $this->get_attributes( $variation ), 'downloads' => $this->get_downloads( $variation ), - 'download_limit' => $product->download_limit, - 'download_expiry' => $product->download_expiry, + 'download_limit' => (int) $product->download_limit, + 'download_expiry' => (int) $product->download_expiry, ); } @@ -438,7 +437,8 @@ class WC_API_Products extends WC_API_Resource { $images[] = array( 'id' => (int) $attachment_id, - 'created_at' => $attachment_post->post_date_gmt, // TODO: date formatting + '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 ), @@ -451,7 +451,8 @@ class WC_API_Products extends WC_API_Resource { $images[] = array( 'id' => 0, - 'created_at' => gmdate( 'Y-m-d H:i:s' ), // TODO: date formatting + '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' ), diff --git a/includes/api/class-wc-api-reports.php b/includes/api/class-wc-api-reports.php index 6b42fa58f79..723883be168 100644 --- a/includes/api/class-wc-api-reports.php +++ b/includes/api/class-wc-api-reports.php @@ -156,6 +156,16 @@ class WC_API_Reports extends WC_API_Resource { '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', @@ -245,61 +255,61 @@ class WC_API_Reports extends WC_API_Resource { } $period_totals[ $time ] = array( - 'sales' => 0, + 'sales' => woocommerce_format_decimal( 0.00 ), 'orders' => 0, 'items' => 0, - 'tax' => 0, - 'shipping' => 0, - 'discount' => 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 ) ); + $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'] = $order->total_sales; - $period_totals[ $time ]['orders'] = $order->total_orders; - $period_totals[ $time ]['tax'] = 1; - $period_totals[ $time ]['shipping'] = $order->total_shipping; + $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 ) ); + $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'] = $order_item->order_item_count; + $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 ) ); + $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'] = $discount->discount_amount; + $period_totals[ $time ]['discount'] = woocommerce_format_decimal( $discount->discount_amount ); } $sales_data = array( - 'sales' => $totals->sales, - 'average' => (string) number_format( $totals->sales / ( $this->report->chart_interval + 1 ), 2 ), - 'orders' => absint( $totals->order_count ), + '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' => (string) number_format( $totals->tax + $totals->shipping_tax, 2 ), - 'shipping' => $totals->shipping, - 'discount' => is_null( $total_discount ) ? 0 : $total_discount, - 'totals' => $period_totals, + '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 apply_filters( 'woocommerce_api_sales_report_response', array( 'sales' => $sales_data ), 'sales', $fields, $this->report, $this->server ); @@ -325,17 +335,28 @@ class WC_API_Reports extends WC_API_Resource { 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'] = $filter['date_min']; // TODO: date formatting? - $_GET['end_date'] = $filter['date_max']; // TODO: date formatting + $_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' ) ); } - } - // TODO: handle invalid periods (e.g. `decade`) + } 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'] ); } diff --git a/includes/api/class-wc-api-resource.php b/includes/api/class-wc-api-resource.php index 1faee34d259..0b27c8db5cf 100644 --- a/includes/api/class-wc-api-resource.php +++ b/includes/api/class-wc-api-resource.php @@ -75,7 +75,7 @@ class WC_API_Resource { $post = get_post( $id, ARRAY_A ); - // TODO: redo this check, it's a bit janky + // 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 @@ -117,8 +117,6 @@ class WC_API_Resource { $args = array(); - // TODO: convert all dates from provided timezone into UTC - // TODO: return all dates in provided timezone, else UTC // 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'] ) ) { @@ -126,19 +124,19 @@ class WC_API_Resource { // resources created after specified date if ( ! empty( $request_args['created_at_min'] ) ) - $args['date_query'][] = array( 'column' => 'post_date_gmt', 'after' => $request_args['created_at_min'], 'inclusive' => true ); + $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' => $request_args['created_at_max'], 'inclusive' => true ); + $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' => $request_args['updated_at_min'], 'inclusive' => true ); + $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' => $request_args['updated_at_max'], 'inclusive' => true ); + $args['date_query'][] = array( 'column' => 'post_modified_gmt', 'before' => $this->server->parse_datetime( $request_args['updated_at_max'] ), 'inclusive' => true ); } // search diff --git a/includes/api/class-wc-api-server.php b/includes/api/class-wc-api-server.php index 772b2675fc4..0a4c5860537 100644 --- a/includes/api/class-wc-api-server.php +++ b/includes/api/class-wc-api-server.php @@ -571,71 +571,72 @@ class WC_API_Server { } /** - * Parse an RFC3339 timestamp into a DateTime + * Parse an RFC3339 datetime into a MySQl datetime * - * @param string $date RFC3339 timestamp - * @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ? - * @return 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_date( $date, $force_utc = false ) { - // Default timezone to the server's current one - $timezone = self::get_timezone(); - if ( $force_utc ) { - $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); + 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' ); } - // Strip millisecond precision (a full stop followed by one or more digits) - if ( strpos( $date, '.' ) !== false ) { - $date = preg_replace( '/\.\d+/', '', $date ); + 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' ); } - $datetime = DateTime::createFromFormat( DateTime::RFC3339, $date ); // TODO: rewrite, PHP 5.3+ required for this - return $datetime; - } - - /** - * Get a local date with its GMT equivalent, in MySQL datetime format - * - * @param string $date RFC3339 timestamp - * @param boolean $force_utc Should we force UTC timestamp? - * @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s) - */ - public function get_date_with_gmt( $date, $force_utc = false ) { - $datetime = $this->parse_date( $date, $force_utc ); - - $datetime->setTimezone( self::get_timezone() ); - $local = $datetime->format( 'Y-m-d H:i:s' ); - - $datetime->setTimezone( new DateTimeZone( 'UTC' ) ); - $utc = $datetime->format('Y-m-d H:i:s'); - - return array( $local, $utc ); - } - - /** - * Get the timezone object for the site - * - * @return DateTimeZone - */ - public function get_timezone() { - static $zone = null; - if ($zone !== null) - return $zone; - - $tzstring = get_option( 'timezone_string' ); - if ( ! $tzstring ) { - // Create a UTC+- zone if no timezone string exists - $current_offset = get_option( 'gmt_offset' ); - if ( 0 == $current_offset ) - $tzstring = 'UTC'; - elseif ($current_offset < 0) - $tzstring = 'Etc/GMT' . $current_offset; - else - $tzstring = 'Etc/GMT+' . $current_offset; - } - $zone = new DateTimeZone( $tzstring ); - return $zone; + return $date->format( 'Y-m-d\TH:i:s\Z' ); } /** diff --git a/includes/wc-formatting-functions.php b/includes/wc-formatting-functions.php index 617f2a029be..211d67df032 100644 --- a/includes/wc-formatting-functions.php +++ b/includes/wc-formatting-functions.php @@ -363,6 +363,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' ) ) { /** @@ -517,4 +562,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 +} From 490dc758a97cfd84fbcae4987dbb21b290a95c2c Mon Sep 17 00:00:00 2001 From: Max Rice Date: Mon, 18 Nov 2013 21:06:45 -0500 Subject: [PATCH 36/43] Update pagination args/headers Part of #4055 --- includes/api/class-wc-api-coupons.php | 7 ++- includes/api/class-wc-api-customers.php | 57 +++++++++++++---- includes/api/class-wc-api-orders.php | 7 ++- includes/api/class-wc-api-products.php | 7 ++- includes/api/class-wc-api-resource.php | 5 +- includes/api/class-wc-api-server.php | 83 ++++++++++++++++++------- 6 files changed, 120 insertions(+), 46 deletions(-) diff --git a/includes/api/class-wc-api-coupons.php b/includes/api/class-wc-api-coupons.php index b5e6d5ad8ba..52341fdd4ab 100644 --- a/includes/api/class-wc-api-coupons.php +++ b/includes/api/class-wc-api-coupons.php @@ -63,9 +63,12 @@ class WC_API_Coupons extends WC_API_Resource { * @since 2.1 * @param string $fields * @param array $filter + * @param int $page * @return array */ - public function get_coupons( $fields = null, $filter = array() ) { + public function get_coupons( $fields = null, $filter = array(), $page = 1 ) { + + $filter['page'] = $page; $query = $this->query_coupons( $filter ); @@ -79,7 +82,7 @@ class WC_API_Coupons extends WC_API_Resource { $coupons[] = $this->get_coupon( $coupon_id, $fields ); } - $this->server->query_navigation_headers( $query ); + $this->server->add_pagination_headers( $query ); return array( 'coupons' => $coupons ); } diff --git a/includes/api/class-wc-api-customers.php b/includes/api/class-wc-api-customers.php index 61653e569d4..ee626e2d22d 100644 --- a/includes/api/class-wc-api-customers.php +++ b/includes/api/class-wc-api-customers.php @@ -87,15 +87,18 @@ class WC_API_Customers extends WC_API_Resource { * @since 2.1 * @param array $fields * @param array $filter + * @param int $page * @return array */ - public function get_customers( $fields = null, $filter = array() ) { + public function get_customers( $fields = null, $filter = array(), $page = 1 ) { + + $filter['page'] = $page; $query = $this->query_customers( $filter ); $customers = array(); - foreach( $query->results as $user_id ) { + foreach( $query->get_results() as $user_id ) { if ( ! $this->is_readable( $user_id ) ) continue; @@ -103,7 +106,7 @@ class WC_API_Customers extends WC_API_Resource { $customers[] = $this->get_customer( $user_id, $fields ); } - // TODO: add navigation/total count headers for pagination + $this->server->add_pagination_headers( $query ); return array( 'customers' => $customers ); } @@ -191,7 +194,7 @@ class WC_API_Customers extends WC_API_Resource { 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' => $query->get_total() ); + return array( 'count' => count( $query->get_results() ) ); } @@ -289,37 +292,65 @@ class WC_API_Customers extends WC_API_Resource { /** * 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, ); - if ( ! empty( $args['q'] ) ) + // search + if ( ! empty( $args['q'] ) ) { $query_args['search'] = $args['q']; + } - if ( ! empty( $args['limit'] ) ) - $query_args['number'] = $args['limit']; + // limit number of users returned + if ( ! empty( $args['limit'] ) ) { - if ( ! empty( $args['offset'] ) ) - $query_args['offset'] = $args['offset']; + $query_args['number'] = absint( $args['limit'] ); - if ( ! empty( $args['created_at_min'] ) ) + $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'] ) ) + if ( ! empty( $args['created_at_max'] ) ) { $this->created_at_max = $this->server->parse_datetime( $args['created_at_max'] ); + } - // TODO: support page argument - requires custom implementation as WP_User_Query has no built-in pagination like WP_Query + $query = new WP_User_Query( $query_args ); - return 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; } /** diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php index 67ae8aaac08..cd27f37a5fd 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -63,13 +63,16 @@ class WC_API_Orders extends WC_API_Resource { * @param string $fields * @param array $filter * @param string $status + * @param int $page * @return array */ - public function get_orders( $fields = null, $filter = array(), $status = null ) { + 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(); @@ -82,7 +85,7 @@ class WC_API_Orders extends WC_API_Resource { $orders[] = $this->get_order( $order_id, $fields ); } - $this->server->query_navigation_headers( $query ); + $this->server->add_pagination_headers( $query ); return array( 'orders' => $orders ); } diff --git a/includes/api/class-wc-api-products.php b/includes/api/class-wc-api-products.php index 68b2d563b84..d90ae6e0cea 100644 --- a/includes/api/class-wc-api-products.php +++ b/includes/api/class-wc-api-products.php @@ -63,13 +63,16 @@ class WC_API_Products extends WC_API_Resource { * @param string $fields * @param string $type * @param array $filter + * @param int $page * @return array */ - public function get_products( $fields = null, $type = null, $filter = 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(); @@ -82,7 +85,7 @@ class WC_API_Products extends WC_API_Resource { $products[] = $this->get_product( $product_id, $fields ); } - $this->server->query_navigation_headers( $query ); + $this->server->add_pagination_headers( $query ); return array( 'products' => $products ); } diff --git a/includes/api/class-wc-api-resource.php b/includes/api/class-wc-api-resource.php index 0b27c8db5cf..8cb663e0ff8 100644 --- a/includes/api/class-wc-api-resource.php +++ b/includes/api/class-wc-api-resource.php @@ -152,10 +152,7 @@ class WC_API_Resource { $args['offset'] = $request_args['offset']; // resource page - if ( empty( $request_args['page'] ) ) - $args['paged'] = 1; - else - $args['paged'] = absint( $request_args['page'] ); + $args['paged'] = absint( $request_args['page'] ); return array_merge( $base_args, $args ); } diff --git a/includes/api/class-wc-api-server.php b/includes/api/class-wc-api-server.php index 0a4c5860537..9437d4058e2 100644 --- a/includes/api/class-wc-api-server.php +++ b/includes/api/class-wc-api-server.php @@ -517,40 +517,77 @@ class WC_API_Server { } /** - * Send navigation-related headers for post collections + * Send pagination headers for resources * * @since 2.1 - * @param WP_Query $query + * @param WP_Query|WP_User_Query $query */ - public function query_navigation_headers( $query ) { - $max_page = $query->max_num_pages; - $paged = $query->get('paged'); + public function add_pagination_headers( $query ) { - if ( !$paged ) - $paged = 1; + // WP_User_Query + if ( is_a( $query, 'WP_User_Query' ) ) { - $nextpage = intval($paged) + 1; - // TODO: change from `page` query arg to filter[page] arg - if ( ! $query->is_single() ) { - if ( $paged > 1 ) { - $request = remove_query_arg( 'page' ); - $request = add_query_arg( 'page', $paged - 1, $request ); - $this->link_header( 'prev', $request ); - } + $page = $query->page; + $single = count( $query->get_results() ) > 1; + $total = $query->get_total(); + $total_pages = $query->total_pages; - if ( $nextpage <= $max_page ) { - $request = remove_query_arg( 'page' ); - $request = add_query_arg( 'page', $nextpage, $request ); - $this->link_header( 'next', $request ); - } + // 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; } - $this->header( 'X-WP-Total', $query->found_posts ); - $this->header( 'X-WP-TotalPages', $max_page ); + if ( ! $page ) + $page = 1; - do_action('woocommerce_api_query_navigation_headers', $this, $query); + $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) From 31303693251b411d641e9aa0d8d129b431a41018 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Mon, 18 Nov 2013 21:59:13 -0500 Subject: [PATCH 37/43] Update filter_response_fields() to support sub-field inclusion Part of #4055 --- includes/api/class-wc-api-customers.php | 2 +- includes/api/class-wc-api-orders.php | 2 +- includes/api/class-wc-api-products.php | 2 +- includes/api/class-wc-api-reports.php | 3 +- includes/api/class-wc-api-resource.php | 38 +++++++++++++++++++++---- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/includes/api/class-wc-api-customers.php b/includes/api/class-wc-api-customers.php index ee626e2d22d..fdd6d64e6e0 100644 --- a/includes/api/class-wc-api-customers.php +++ b/includes/api/class-wc-api-customers.php @@ -286,7 +286,7 @@ class WC_API_Customers extends WC_API_Resource { $orders[] = WC()->api->WC_API_Orders->get_order( $order_id, $fields ); } - return apply_filters( 'woocommerce_api_customer_orders_response', array( 'orders' => $orders ), $id, $fields, $order_ids, $this->server ); + return array( 'orders' => apply_filters( 'woocommerce_api_customer_orders_response', $orders, $id, $fields, $order_ids, $this->server ) ); } /** diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php index cd27f37a5fd..d91ddf94a2e 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -331,7 +331,7 @@ class WC_API_Orders extends WC_API_Resource { ); } - return apply_filters( 'woocommerce_api_order_notes_response', array( 'order_notes' => $order_notes ), $id, $fields, $notes, $this->server ); + return array( 'order_notes' => apply_filters( 'woocommerce_api_order_notes_response', $order_notes, $id, $fields, $notes, $this->server ) ); } /** diff --git a/includes/api/class-wc-api-products.php b/includes/api/class-wc-api-products.php index d90ae6e0cea..9b599b6fa11 100644 --- a/includes/api/class-wc-api-products.php +++ b/includes/api/class-wc-api-products.php @@ -220,7 +220,7 @@ class WC_API_Products extends WC_API_Resource { ); } - return apply_filters( 'woocommerce_api_product_reviews_response', array( 'product_reviews' => $reviews ), $id, $fields, $comments, $this->server ); + return array( 'product_reviews' => apply_filters( 'woocommerce_api_product_reviews_response', $reviews, $id, $fields, $comments, $this->server ) ); } /** diff --git a/includes/api/class-wc-api-reports.php b/includes/api/class-wc-api-reports.php index 723883be168..a1939a9b1bb 100644 --- a/includes/api/class-wc-api-reports.php +++ b/includes/api/class-wc-api-reports.php @@ -312,7 +312,7 @@ class WC_API_Reports extends WC_API_Resource { 'totals' => $period_totals, ); - return apply_filters( 'woocommerce_api_sales_report_response', array( 'sales' => $sales_data ), 'sales', $fields, $this->report, $this->server ); + return array( 'sales' => apply_filters( 'woocommerce_api_report_response', $sales_data, 'sales', $fields, $this->report, $this->server ) ); } /** @@ -376,6 +376,7 @@ class WC_API_Reports extends WC_API_Resource { 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 index 8cb663e0ff8..c66bed7edb4 100644 --- a/includes/api/class-wc-api-resource.php +++ b/includes/api/class-wc-api-resource.php @@ -180,13 +180,11 @@ class WC_API_Resource { /** * Restrict the fields included in the response if the request specified certain only certain fields should be returned * - * @TODO this should also work with sub-fields, like billing_address.country - * * @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 mixed + * @return array response data */ public function filter_response_fields( $data, $resource, $fields ) { @@ -194,11 +192,41 @@ class WC_API_Resource { 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 ( ! in_array( $data_field, $fields ) ) - unset( $data[ $data_field ] ); + // 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; From 48e8363db04aba9c9d30163b9781ac58b8e4d72a Mon Sep 17 00:00:00 2001 From: Max Rice Date: Mon, 18 Nov 2013 22:58:10 -0500 Subject: [PATCH 38/43] Add meta to resource responses Part of #4055 --- includes/api/class-wc-api-orders.php | 2 +- includes/api/class-wc-api-products.php | 2 +- includes/api/class-wc-api-resource.php | 54 ++++++++++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/includes/api/class-wc-api-orders.php b/includes/api/class-wc-api-orders.php index d91ddf94a2e..7650fd83be9 100644 --- a/includes/api/class-wc-api-orders.php +++ b/includes/api/class-wc-api-orders.php @@ -232,7 +232,7 @@ class WC_API_Orders extends WC_API_Resource { ); } - return apply_filters( 'woocommerce_api_order_response', $order_data, $order, $fields ); + return apply_filters( 'woocommerce_api_order_response', $order_data, $order, $fields, $this->server ); } /** diff --git a/includes/api/class-wc-api-products.php b/includes/api/class-wc-api-products.php index 9b599b6fa11..aad0bd933fd 100644 --- a/includes/api/class-wc-api-products.php +++ b/includes/api/class-wc-api-products.php @@ -122,7 +122,7 @@ class WC_API_Products extends WC_API_Resource { $product_data['parent'] = $this->get_product_data( $product->parent ); } - return apply_filters( 'woocommerce_api_product_response', $product_data, $product, $fields ); + return apply_filters( 'woocommerce_api_product_response', $product_data, $product, $fields, $this->server ); } /** diff --git a/includes/api/class-wc-api-resource.php b/includes/api/class-wc-api-resource.php index c66bed7edb4..770b62faded 100644 --- a/includes/api/class-wc-api-resource.php +++ b/includes/api/class-wc-api-resource.php @@ -157,10 +157,9 @@ class WC_API_Resource { 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. + * `_meta` attribute (e.g. `order_meta`) as a list of key/value pairs * * @since 2.1 * @param array $data the resource data @@ -169,9 +168,56 @@ class WC_API_Resource { */ public function maybe_add_meta( $data, $resource ) { - if ( isset( $this->server->params['GET']['filter']['meta'] ) && 'true' === $this->server->params['GET']['filter']['meta'] ) { + 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] ); + } + } - // TODO: implement } return $data; From dcdca4d4d289028225de0393e1741d9595e861eb Mon Sep 17 00:00:00 2001 From: Max Rice Date: Tue, 19 Nov 2013 03:01:14 -0500 Subject: [PATCH 39/43] Remove API settings tab Part of #4055 --- includes/admin/class-wc-admin-settings.php | 1 - .../settings/class-wc-settings-rest-api.php | 116 ------------------ 2 files changed, 117 deletions(-) delete mode 100644 includes/admin/settings/class-wc-settings-rest-api.php diff --git a/includes/admin/class-wc-admin-settings.php b/includes/admin/class-wc-admin-settings.php index b91007ecbb1..3748c84af65 100644 --- a/includes/admin/class-wc-admin-settings.php +++ b/includes/admin/class-wc-admin-settings.php @@ -35,7 +35,6 @@ class WC_Admin_Settings { $settings[] = include( 'settings/class-wc-settings-shipping.php' ); $settings[] = include( 'settings/class-wc-settings-tax.php' ); $settings[] = include( 'settings/class-wc-settings-emails.php' ); - $settings[] = include( 'settings/class-wc-settings-rest-api.php' ); $settings[] = include( 'settings/class-wc-settings-integrations.php' ); $settings = apply_filters( 'woocommerce_get_settings_pages', $settings ); diff --git a/includes/admin/settings/class-wc-settings-rest-api.php b/includes/admin/settings/class-wc-settings-rest-api.php deleted file mode 100644 index 17c9a353440..00000000000 --- a/includes/admin/settings/class-wc-settings-rest-api.php +++ /dev/null @@ -1,116 +0,0 @@ -id = 'api'; - $this->label = __( 'API', 'woocommerce' ); - - add_filter( 'woocommerce_settings_tabs_array', array( $this, 'add_settings_page' ), 20 ); - add_action( 'woocommerce_settings_' . $this->id, array( $this, 'output' ) ); - add_action( 'woocommerce_settings_save_' . $this->id, array( $this, 'save' ) ); - add_action( 'woocommerce_sections_' . $this->id, array( $this, 'output_sections' ) ); - } - - /** - * Get sections - * - * @return array - */ - public function get_sections() { - $sections = array( - '' => __( 'API Options', 'woocommerce' ), - 'log' => __( 'Log', 'woocommerce' ) - ); - - return $sections; - } - - /** - * Output the settings - */ - public function output() { - global $current_section; - - $settings = $this->get_settings( $current_section ); - - WC_Admin_Settings::output_fields( $settings ); - } - - /** - * Save settings - */ - public function save() { - global $current_section; - - $settings = $this->get_settings( $current_section ); - WC_Admin_Settings::save_fields( $settings ); - } - - /** - * Get settings array - * - * @param string $current_section - * @return array - */ - public function get_settings( $current_section = '' ) { - - if ( $current_section == 'log' ) { - - // TODO: implement log display - - } else { - - return apply_filters( 'woocommerce_api_settings', array( - - array( 'title' => __( 'General Options', 'woocommerce' ), 'type' => 'title', 'desc' => '', 'id' => 'general_options' ), - - array( - 'title' => __( 'Enable API', 'woocommerce' ), - 'id' => 'woocommerce_api_enabled', - 'type' => 'checkbox', - 'default' => 'yes', - ), - - array( - 'title' => __( 'Allow read-only public access to Products endpoint', 'woocommerce' ), - 'desc' => __( 'This enables read-only public access to the products endpoint', 'woocommerce' ), - 'id' => 'woocommerce_api_public_products_endpoint', - 'type' => 'checkbox', - 'default' => 'no', - ), - - array( 'type' => 'sectionend', 'id' => 'general_options' ), - - array( 'title' => __( 'API Keys', 'woocommerce' ), 'type' => 'title', 'id' => 'api_key_options' ), - - // TODO: implement key management here - - array( 'type' => 'sectionend', 'id' => 'digital_download_options' ), - - )); - } - } -} - -endif; - -return new WC_Settings_REST_API(); From 001ce0fc3ef35ed62f2dc055148ac54f895f9646 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Tue, 19 Nov 2013 03:01:46 -0500 Subject: [PATCH 40/43] Add option to enable/disable API Part of #4055 --- includes/admin/settings/class-wc-settings-general.php | 10 +++++++++- includes/api/class-wc-api-server.php | 6 ++---- 2 files changed, 11 insertions(+), 5 deletions(-) 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-server.php b/includes/api/class-wc-api-server.php index 9437d4058e2..c65ee4bb801 100644 --- a/includes/api/class-wc-api-server.php +++ b/includes/api/class-wc-api-server.php @@ -203,10 +203,8 @@ class WC_API_Server { $this->header( 'Content-Type', $this->handler->get_content_type(), true ); - // TODO: can we prevent wc_cookie from being sent for API requests? - - // the API is enabled by default TODO: implement check for enabled setting here - if ( ! apply_filters( 'woocommerce_api_enabled', true, $this ) ) { + // the API is enabled by default + if ( ! apply_filters( 'woocommerce_api_enabled', true, $this ) || ( 'no' === get_option( 'woocommerce_api_enabled' ) ) ) { $this->send_status( 404 ); From f542ce87a16150062025600501f279476eb66760 Mon Sep 17 00:00:00 2001 From: Max Rice Date: Tue, 19 Nov 2013 03:02:28 -0500 Subject: [PATCH 41/43] Add key management UX Part of #4055 --- includes/admin/class-wc-admin-profile.php | 116 +++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) 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(); From 61fb0f760a8672b58780ed5b16b41501d1c42aff Mon Sep 17 00:00:00 2001 From: Max Rice Date: Tue, 19 Nov 2013 03:03:39 -0500 Subject: [PATCH 42/43] Update authentication to use new API key meta names Part of #4055 --- includes/api/class-wc-api-authentication.php | 39 +++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/includes/api/class-wc-api-authentication.php b/includes/api/class-wc-api-authentication.php index e6783dc75c3..cb780ee20f7 100644 --- a/includes/api/class-wc-api-authentication.php +++ b/includes/api/class-wc-api-authentication.php @@ -22,9 +22,6 @@ class WC_API_Authentication { // this filter can be removed in order to provide unauthenticated access to the API for testing, etc add_filter( 'woocommerce_api_check_authentication', array( $this, 'authenticate' ) ); - - // TODO: provide API key based permissions check using $args = apply_filters( 'json_dispatch_args', $args, $callback ); - // TODO: allow unauthenticated access to /products endpoint } /** @@ -57,10 +54,10 @@ class WC_API_Authentication { /** * 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 secret key provided is valid + * by simply looking up the user associated with the given consumer key and confirming the consumer secret provided is valid * * @since 2.1 - * @return mixed + * @return WP_User * @throws Exception */ private function perform_ssl_authentication() { @@ -69,15 +66,15 @@ class WC_API_Authentication { throw new Exception( __( 'Consumer Key is missing', 'woocommerce' ), 404 ); if ( empty( $_SERVER['PHP_AUTH_PW'] ) ) - throw new Exception( __( 'Secret Key is missing', 'woocommerce' ), 404 ); + throw new Exception( __( 'Consumer Secret is missing', 'woocommerce' ), 404 ); - $consumer_key = $_SERVER['PHP_AUTH_USER']; - $secret_key = $_SERVER['PHP_AUTH_PW']; + $consumer_key = $_SERVER['PHP_AUTH_USER']; + $consumer_secret = $_SERVER['PHP_AUTH_PW']; $user = $this->get_user_by_consumer_key( $consumer_key ); - if ( ! $this->is_secret_key_valid( $user, $secret_key ) ) - throw new Exception( __( 'Secret Key is invalid', 'woocommerce'), 401 ); + if ( ! $this->is_consumer_secret_valid( $user, $consumer_secret ) ) + throw new Exception( __( 'Consumer Secret is invalid', 'woocommerce'), 401 ); return $user; } @@ -89,13 +86,11 @@ class WC_API_Authentication { * * 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/secret keys are used + * 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 * - * @TODO create consumer documentation for generating nonce/signatures for requests - * * @link http://tools.ietf.org/html/rfc5849 for the full spec * @since 2.1 * @return WP_User @@ -156,21 +151,21 @@ class WC_API_Authentication { } /** - * Check if the secret key provided for the given user is valid + * Check if the consumer secret provided for the given user is valid * * @since 2.1 * @param WP_User $user - * @param $secret_key + * @param string $consumer_secret * @return bool */ - private function is_secret_key_valid( WP_User $user, $secret_key ) { + private function is_consumer_secret_valid( WP_User $user, $consumer_secret ) { - return $user->woocommerce_api_secret_key === $secret_key; + 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 key + * has a valid key/secret * * @param WP_User $user * @param array $params the request parameters @@ -208,7 +203,7 @@ class WC_API_Authentication { $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) ); - $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $user->woocommerce_api_secret_key, true ) ); + $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 ); @@ -220,8 +215,8 @@ class WC_API_Authentication { * * @since 2.1 * @see rawurlencode() - * @param $key - * @param $value + * @param string $key + * @param string $value */ private function normalize_parameters( &$key, &$value ) { @@ -254,7 +249,7 @@ class WC_API_Authentication { $used_nonces = array(); if ( in_array( $nonce, $used_nonces ) ) - throw new Exception( __( 'Invalid nonce - nonce has already been used', 'woocommerce' ) ); + throw new Exception( __( 'Invalid nonce - nonce has already been used', 'woocommerce' ), 401 ); $used_nonces[ $timestamp ] = $nonce; From ef22f032750682f1520350c05cc5a184ec789d3a Mon Sep 17 00:00:00 2001 From: Max Rice Date: Tue, 19 Nov 2013 03:04:00 -0500 Subject: [PATCH 43/43] Add API key-specific permission check Part of #4055 --- includes/api/class-wc-api-authentication.php | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/includes/api/class-wc-api-authentication.php b/includes/api/class-wc-api-authentication.php index cb780ee20f7..c40e006f3ee 100644 --- a/includes/api/class-wc-api-authentication.php +++ b/includes/api/class-wc-api-authentication.php @@ -44,6 +44,9 @@ class WC_API_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() ) ); @@ -263,4 +266,33 @@ class WC_API_Authentication { 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; + } + } }