class-wc-api-resource.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. <?php
  2. /**
  3. * WooCommerce API Resource class
  4. *
  5. * Provides shared functionality for resource-specific API classes
  6. *
  7. * @author WooThemes
  8. * @category API
  9. * @package WooCommerce/API
  10. * @since 2.1
  11. */
  12. if ( ! defined( 'ABSPATH' ) ) {
  13. exit; // Exit if accessed directly
  14. }
  15. class WC_API_Resource {
  16. /** @var WC_API_Server the API server */
  17. protected $server;
  18. /** @var string sub-classes override this to set a resource-specific base route */
  19. protected $base;
  20. /**
  21. * Setup class
  22. *
  23. * @since 2.1
  24. * @param WC_API_Server $server
  25. */
  26. public function __construct( WC_API_Server $server ) {
  27. $this->server = $server;
  28. // automatically register routes for sub-classes
  29. add_filter( 'woocommerce_api_endpoints', array( $this, 'register_routes' ) );
  30. // maybe add meta to top-level resource responses
  31. foreach ( array( 'order', 'coupon', 'customer', 'product', 'report' ) as $resource ) {
  32. add_filter( "woocommerce_api_{$resource}_response", array( $this, 'maybe_add_meta' ), 15, 2 );
  33. }
  34. $response_names = array(
  35. 'order',
  36. 'coupon',
  37. 'customer',
  38. 'product',
  39. 'report',
  40. 'customer_orders',
  41. 'customer_downloads',
  42. 'order_note',
  43. 'order_refund',
  44. 'product_reviews',
  45. 'product_category',
  46. 'tax',
  47. 'tax_class',
  48. );
  49. foreach ( $response_names as $name ) {
  50. /**
  51. * Remove fields from responses when requests specify certain fields
  52. * note these are hooked at a later priority so data added via
  53. * filters (e.g. customer data to the order response) still has the
  54. * fields filtered properly
  55. */
  56. add_filter( "woocommerce_api_{$name}_response", array( $this, 'filter_response_fields' ), 20, 3 );
  57. }
  58. }
  59. /**
  60. * Validate the request by checking:
  61. *
  62. * 1) the ID is a valid integer
  63. * 2) the ID returns a valid post object and matches the provided post type
  64. * 3) the current user has the proper permissions to read/edit/delete the post
  65. *
  66. * @since 2.1
  67. * @param string|int $id the post ID
  68. * @param string $type the post type, either `shop_order`, `shop_coupon`, or `product`
  69. * @param string $context the context of the request, either `read`, `edit` or `delete`
  70. * @return int|WP_Error valid post ID or WP_Error if any of the checks fails
  71. */
  72. protected function validate_request( $id, $type, $context ) {
  73. if ( 'shop_order' === $type || 'shop_coupon' === $type || 'shop_webhook' === $type ) {
  74. $resource_name = str_replace( 'shop_', '', $type );
  75. } else {
  76. $resource_name = $type;
  77. }
  78. $id = absint( $id );
  79. // Validate ID
  80. if ( empty( $id ) ) {
  81. return new WP_Error( "woocommerce_api_invalid_{$resource_name}_id", sprintf( __( 'Invalid %s ID', 'woocommerce' ), $type ), array( 'status' => 404 ) );
  82. }
  83. // Only custom post types have per-post type/permission checks
  84. if ( 'customer' !== $type ) {
  85. $post = get_post( $id );
  86. if ( null === $post ) {
  87. return new WP_Error( "woocommerce_api_no_{$resource_name}_found", sprintf( __( 'No %1$s found with the ID equal to %2$s', 'woocommerce' ), $resource_name, $id ), array( 'status' => 404 ) );
  88. }
  89. // For checking permissions, product variations are the same as the product post type
  90. $post_type = ( 'product_variation' === $post->post_type ) ? 'product' : $post->post_type;
  91. // Validate post type
  92. if ( $type !== $post_type ) {
  93. return new WP_Error( "woocommerce_api_invalid_{$resource_name}", sprintf( __( 'Invalid %s', 'woocommerce' ), $resource_name ), array( 'status' => 404 ) );
  94. }
  95. // Validate permissions
  96. switch ( $context ) {
  97. case 'read':
  98. if ( ! $this->is_readable( $post ) ) {
  99. 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 ) );
  100. }
  101. break;
  102. case 'edit':
  103. if ( ! $this->is_editable( $post ) ) {
  104. 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 ) );
  105. }
  106. break;
  107. case 'delete':
  108. if ( ! $this->is_deletable( $post ) ) {
  109. 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 ) );
  110. }
  111. break;
  112. }
  113. }
  114. return $id;
  115. }
  116. /**
  117. * Add common request arguments to argument list before WP_Query is run
  118. *
  119. * @since 2.1
  120. * @param array $base_args required arguments for the query (e.g. `post_type`, etc)
  121. * @param array $request_args arguments provided in the request
  122. * @return array
  123. */
  124. protected function merge_query_args( $base_args, $request_args ) {
  125. $args = array();
  126. // date
  127. 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'] ) ) {
  128. $args['date_query'] = array();
  129. // resources created after specified date
  130. if ( ! empty( $request_args['created_at_min'] ) ) {
  131. $args['date_query'][] = array( 'column' => 'post_date_gmt', 'after' => $this->server->parse_datetime( $request_args['created_at_min'] ), 'inclusive' => true );
  132. }
  133. // resources created before specified date
  134. if ( ! empty( $request_args['created_at_max'] ) ) {
  135. $args['date_query'][] = array( 'column' => 'post_date_gmt', 'before' => $this->server->parse_datetime( $request_args['created_at_max'] ), 'inclusive' => true );
  136. }
  137. // resources updated after specified date
  138. if ( ! empty( $request_args['updated_at_min'] ) ) {
  139. $args['date_query'][] = array( 'column' => 'post_modified_gmt', 'after' => $this->server->parse_datetime( $request_args['updated_at_min'] ), 'inclusive' => true );
  140. }
  141. // resources updated before specified date
  142. if ( ! empty( $request_args['updated_at_max'] ) ) {
  143. $args['date_query'][] = array( 'column' => 'post_modified_gmt', 'before' => $this->server->parse_datetime( $request_args['updated_at_max'] ), 'inclusive' => true );
  144. }
  145. }
  146. // search
  147. if ( ! empty( $request_args['q'] ) ) {
  148. $args['s'] = $request_args['q'];
  149. }
  150. // resources per response
  151. if ( ! empty( $request_args['limit'] ) ) {
  152. $args['posts_per_page'] = $request_args['limit'];
  153. }
  154. // resource offset
  155. if ( ! empty( $request_args['offset'] ) ) {
  156. $args['offset'] = $request_args['offset'];
  157. }
  158. // order (ASC or DESC, ASC by default)
  159. if ( ! empty( $request_args['order'] ) ) {
  160. $args['order'] = $request_args['order'];
  161. }
  162. // orderby
  163. if ( ! empty( $request_args['orderby'] ) ) {
  164. $args['orderby'] = $request_args['orderby'];
  165. // allow sorting by meta value
  166. if ( ! empty( $request_args['orderby_meta_key'] ) ) {
  167. $args['meta_key'] = $request_args['orderby_meta_key'];
  168. }
  169. }
  170. // allow post status change
  171. if ( ! empty( $request_args['post_status'] ) ) {
  172. $args['post_status'] = $request_args['post_status'];
  173. unset( $request_args['post_status'] );
  174. }
  175. // filter by a list of post id
  176. if ( ! empty( $request_args['in'] ) ) {
  177. $args['post__in'] = explode( ',', $request_args['in'] );
  178. unset( $request_args['in'] );
  179. }
  180. // exclude by a list of post id
  181. if ( ! empty( $request_args['not_in'] ) ) {
  182. $args['post__not_in'] = explode( ',', $request_args['not_in'] );
  183. unset( $request_args['not_in'] );
  184. }
  185. // resource page
  186. $args['paged'] = ( isset( $request_args['page'] ) ) ? absint( $request_args['page'] ) : 1;
  187. $args = apply_filters( 'woocommerce_api_query_args', $args, $request_args );
  188. return array_merge( $base_args, $args );
  189. }
  190. /**
  191. * Add meta to resources when requested by the client. Meta is added as a top-level
  192. * `<resource_name>_meta` attribute (e.g. `order_meta`) as a list of key/value pairs
  193. *
  194. * @since 2.1
  195. * @param array $data the resource data
  196. * @param object $resource the resource object (e.g WC_Order)
  197. * @return mixed
  198. */
  199. public function maybe_add_meta( $data, $resource ) {
  200. if ( isset( $this->server->params['GET']['filter']['meta'] ) && 'true' === $this->server->params['GET']['filter']['meta'] && is_object( $resource ) ) {
  201. // don't attempt to add meta more than once
  202. if ( preg_grep( '/[a-z]+_meta/', array_keys( $data ) ) ) {
  203. return $data;
  204. }
  205. // define the top-level property name for the meta
  206. switch ( get_class( $resource ) ) {
  207. case 'WC_Order':
  208. $meta_name = 'order_meta';
  209. break;
  210. case 'WC_Coupon':
  211. $meta_name = 'coupon_meta';
  212. break;
  213. case 'WP_User':
  214. $meta_name = 'customer_meta';
  215. break;
  216. default:
  217. $meta_name = 'product_meta';
  218. break;
  219. }
  220. if ( is_a( $resource, 'WP_User' ) ) {
  221. // customer meta
  222. $meta = (array) get_user_meta( $resource->ID );
  223. } else {
  224. // coupon/order/product meta
  225. $meta = (array) get_post_meta( $resource->get_id() );
  226. }
  227. foreach ( $meta as $meta_key => $meta_value ) {
  228. // don't add hidden meta by default
  229. if ( ! is_protected_meta( $meta_key ) ) {
  230. $data[ $meta_name ][ $meta_key ] = maybe_unserialize( $meta_value[0] );
  231. }
  232. }
  233. }
  234. return $data;
  235. }
  236. /**
  237. * Restrict the fields included in the response if the request specified certain only certain fields should be returned
  238. *
  239. * @since 2.1
  240. * @param array $data the response data
  241. * @param object $resource the object that provided the response data, e.g. WC_Coupon or WC_Order
  242. * @param array|string the requested list of fields to include in the response
  243. * @return array response data
  244. */
  245. public function filter_response_fields( $data, $resource, $fields ) {
  246. if ( ! is_array( $data ) || empty( $fields ) ) {
  247. return $data;
  248. }
  249. $fields = explode( ',', $fields );
  250. $sub_fields = array();
  251. // get sub fields
  252. foreach ( $fields as $field ) {
  253. if ( false !== strpos( $field, '.' ) ) {
  254. list( $name, $value ) = explode( '.', $field );
  255. $sub_fields[ $name ] = $value;
  256. }
  257. }
  258. // iterate through top-level fields
  259. foreach ( $data as $data_field => $data_value ) {
  260. // if a field has sub-fields and the top-level field has sub-fields to filter
  261. if ( is_array( $data_value ) && in_array( $data_field, array_keys( $sub_fields ) ) ) {
  262. // iterate through each sub-field
  263. foreach ( $data_value as $sub_field => $sub_field_value ) {
  264. // remove non-matching sub-fields
  265. if ( ! in_array( $sub_field, $sub_fields ) ) {
  266. unset( $data[ $data_field ][ $sub_field ] );
  267. }
  268. }
  269. } else {
  270. // remove non-matching top-level fields
  271. if ( ! in_array( $data_field, $fields ) ) {
  272. unset( $data[ $data_field ] );
  273. }
  274. }
  275. }
  276. return $data;
  277. }
  278. /**
  279. * Delete a given resource
  280. *
  281. * @since 2.1
  282. * @param int $id the resource ID
  283. * @param string $type the resource post type, or `customer`
  284. * @param bool $force true to permanently delete resource, false to move to trash (not supported for `customer`)
  285. * @return array|WP_Error
  286. */
  287. protected function delete( $id, $type, $force = false ) {
  288. if ( 'shop_order' === $type || 'shop_coupon' === $type ) {
  289. $resource_name = str_replace( 'shop_', '', $type );
  290. } else {
  291. $resource_name = $type;
  292. }
  293. if ( 'customer' === $type ) {
  294. $result = wp_delete_user( $id );
  295. if ( $result ) {
  296. return array( 'message' => __( 'Permanently deleted customer', 'woocommerce' ) );
  297. } else {
  298. return new WP_Error( 'woocommerce_api_cannot_delete_customer', __( 'The customer cannot be deleted', 'woocommerce' ), array( 'status' => 500 ) );
  299. }
  300. } else {
  301. // delete order/coupon/product/webhook
  302. $result = ( $force ) ? wp_delete_post( $id, true ) : wp_trash_post( $id );
  303. if ( ! $result ) {
  304. return new WP_Error( "woocommerce_api_cannot_delete_{$resource_name}", sprintf( __( 'This %s cannot be deleted', 'woocommerce' ), $resource_name ), array( 'status' => 500 ) );
  305. }
  306. if ( $force ) {
  307. return array( 'message' => sprintf( __( 'Permanently deleted %s', 'woocommerce' ), $resource_name ) );
  308. } else {
  309. $this->server->send_status( '202' );
  310. return array( 'message' => sprintf( __( 'Deleted %s', 'woocommerce' ), $resource_name ) );
  311. }
  312. }
  313. }
  314. /**
  315. * Checks if the given post is readable by the current user
  316. *
  317. * @since 2.1
  318. * @see WC_API_Resource::check_permission()
  319. * @param WP_Post|int $post
  320. * @return bool
  321. */
  322. protected function is_readable( $post ) {
  323. return $this->check_permission( $post, 'read' );
  324. }
  325. /**
  326. * Checks if the given post is editable by the current user
  327. *
  328. * @since 2.1
  329. * @see WC_API_Resource::check_permission()
  330. * @param WP_Post|int $post
  331. * @return bool
  332. */
  333. protected function is_editable( $post ) {
  334. return $this->check_permission( $post, 'edit' );
  335. }
  336. /**
  337. * Checks if the given post is deletable by the current user
  338. *
  339. * @since 2.1
  340. * @see WC_API_Resource::check_permission()
  341. * @param WP_Post|int $post
  342. * @return bool
  343. */
  344. protected function is_deletable( $post ) {
  345. return $this->check_permission( $post, 'delete' );
  346. }
  347. /**
  348. * Checks the permissions for the current user given a post and context
  349. *
  350. * @since 2.1
  351. * @param WP_Post|int $post
  352. * @param string $context the type of permission to check, either `read`, `write`, or `delete`
  353. * @return bool true if the current user has the permissions to perform the context on the post
  354. */
  355. private function check_permission( $post, $context ) {
  356. $permission = false;
  357. if ( ! is_a( $post, 'WP_Post' ) ) {
  358. $post = get_post( $post );
  359. }
  360. if ( is_null( $post ) ) {
  361. return $permission;
  362. }
  363. $post_type = get_post_type_object( $post->post_type );
  364. if ( 'read' === $context ) {
  365. $permission = 'revision' !== $post->post_type && current_user_can( $post_type->cap->read_private_posts, $post->ID );
  366. } elseif ( 'edit' === $context ) {
  367. $permission = current_user_can( $post_type->cap->edit_post, $post->ID );
  368. } elseif ( 'delete' === $context ) {
  369. $permission = current_user_can( $post_type->cap->delete_post, $post->ID );
  370. }
  371. return apply_filters( 'woocommerce_api_check_permission', $permission, $context, $post, $post_type );
  372. }
  373. }