/includes/cli/class-wc-cli-rest-command.php

  1. <?php 
  2. /** 
  3. * Main Command for WooCommere CLI. 
  4. * 
  5. * Since a lot of WC operations can be handled via the REST API, we base our CLI 
  6. * off of Restful to generate commands for each WooCommerce REST API endpoint 
  7. * so most of the logic is shared. 
  8. * 
  9. * Forked from wp-cli/restful (by Daniel Bachhuber, released under the MIT license https://opensource.org/licenses/MIT). 
  10. * https://github.com/wp-cli/restful 
  11. * 
  12. * @version 3.0.0 
  13. * @package WooCommerce 
  14. */ 
  15. class WC_CLI_REST_Command { 
  16. /** 
  17. * Endpoints that have a parent ID. 
  18. * Ex: Product reviews, which has a product ID and a review ID. 
  19. */ 
  20. protected $routes_with_parent_id = array( 
  21. 'customer_download',  
  22. 'product_review',  
  23. 'order_note',  
  24. 'shop_order_refund',  
  25. ); 
  26.  
  27. /** 
  28. * Name of command/endpoint object. 
  29. */ 
  30. private $name; 
  31.  
  32. /** 
  33. * Endpoint route. 
  34. */ 
  35. private $route; 
  36.  
  37. /** 
  38. * Main resource ID. 
  39. */ 
  40. private $resource_identifier; 
  41.  
  42. /** 
  43. * Schema for command. 
  44. */ 
  45. private $schema; 
  46.  
  47. /** 
  48. * Nesting level. 
  49. */ 
  50. private $output_nesting_level = 0; 
  51.  
  52. /** 
  53. * List of supported IDs and their description (name => desc). 
  54. */ 
  55. private $supported_ids = array(); 
  56.  
  57. /** 
  58. * Sets up REST Command. 
  59. * 
  60. * @param string $name Name of endpoint object (comes from schema) 
  61. * @param string $route Path to route of this endpoint 
  62. * @param array $schema Schema object 
  63. */ 
  64. public function __construct( $name, $route, $schema ) { 
  65. $this->name = $name; 
  66. $parsed_args = preg_match_all( '#\([^\)]+\)#', $route, $matches ); 
  67. $first_match = $matches[0]; 
  68. $resource_id = ! empty( $matches[0] ) ? array_pop( $matches[0] ) : null; 
  69. $this->route = rtrim( $route ); 
  70. $this->schema = $schema; 
  71.  
  72. $this->resource_identifier = $resource_id; 
  73. if ( in_array( $name, $this->routes_with_parent_id ) ) { 
  74. $is_singular = substr( $this->route, - strlen( $resource_id ) ) === $resource_id; 
  75. if ( ! $is_singular ) { 
  76. $this->resource_identifier = $first_match[0]; 
  77.  
  78. /** 
  79. * Passes supported ID arguments (things like product_id, order_id, etc) that we should look for in addition to id. 
  80. * 
  81. * @param array $supported_ids 
  82. */ 
  83. public function set_supported_ids( $supported_ids = array() ) { 
  84. $this->supported_ids = $supported_ids; 
  85.  
  86. /** 
  87. * Peturns an ID of supported ID arguments (things like product_id, order_id, etc) that we should look for in addition to id. 
  88. * 
  89. * @return array 
  90. */ 
  91. public function get_supported_ids() { 
  92. return $this->supported_ids; 
  93.  
  94. /** 
  95. * Create a new item. 
  96. * 
  97. * @subcommand create 
  98. */ 
  99. public function create_item( $args, $assoc_args ) { 
  100. $assoc_args = self::decode_json( $assoc_args ); 
  101. list( $status, $body ) = $this->do_request( 'POST', $this->get_filled_route( $args ), $assoc_args ); 
  102. if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { 
  103. WP_CLI::line( $body['id'] ); 
  104. } else { 
  105. WP_CLI::success( "Created {$this->name} {$body['id']}." ); 
  106.  
  107. /** 
  108. * Delete an existing item. 
  109. * 
  110. * @subcommand delete 
  111. */ 
  112. public function delete_item( $args, $assoc_args ) { 
  113. list( $status, $body ) = $this->do_request( 'DELETE', $this->get_filled_route( $args ), $assoc_args ); 
  114. if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { 
  115. WP_CLI::line( $body['id'] ); 
  116. } else { 
  117. if ( empty( $assoc_args['force'] ) ) { 
  118. WP_CLI::success( __( 'Trashed', 'woocommerce' ) . " {$this->name} {$body['id']}" ); 
  119. } else { 
  120. WP_CLI::success( __( 'Deleted', 'woocommerce' ) . " {$this->name} {$body['id']}." ); 
  121.  
  122. /** 
  123. * Get a single item. 
  124. * 
  125. * @subcommand get 
  126. */ 
  127. public function get_item( $args, $assoc_args ) { 
  128. $route = $this->get_filled_route( $args ); 
  129. list( $status, $body, $headers ) = $this->do_request( 'GET', $route, $assoc_args ); 
  130.  
  131. if ( ! empty( $assoc_args['fields'] ) ) { 
  132. $body = self::limit_item_to_fields( $body, $assoc_args['fields'] ); 
  133.  
  134. if ( 'headers' === $assoc_args['format'] ) { 
  135. echo json_encode( $headers ); 
  136. } elseif ( 'body' === $assoc_args['format'] ) { 
  137. echo json_encode( $body ); 
  138. } elseif ( 'envelope' === $assoc_args['format'] ) { 
  139. echo json_encode( array( 
  140. 'body' => $body,  
  141. 'headers' => $headers,  
  142. 'status' => $status,  
  143. ) ); 
  144. } else { 
  145. $formatter = $this->get_formatter( $assoc_args ); 
  146. $formatter->display_item( $body ); 
  147.  
  148. /** 
  149. * List all items. 
  150. * 
  151. * @subcommand list 
  152. */ 
  153. public function list_items( $args, $assoc_args ) { 
  154. if ( ! empty( $assoc_args['format'] ) && 'count' === $assoc_args['format'] ) { 
  155. $method = 'HEAD'; 
  156. } else { 
  157. $method = 'GET'; 
  158.  
  159. list( $status, $body, $headers ) = $this->do_request( $method, $this->get_filled_route( $args ), $assoc_args ); 
  160. if ( ! empty( $assoc_args['format'] ) && 'ids' === $assoc_args['format'] ) { 
  161. $items = array_column( $body, 'id' ); 
  162. } else { 
  163. $items = $body; 
  164.  
  165. if ( ! empty( $assoc_args['fields'] ) ) { 
  166. foreach ( $items as $key => $item ) { 
  167. $items[ $key ] = self::limit_item_to_fields( $item, $assoc_args['fields'] ); 
  168.  
  169. if ( ! empty( $assoc_args['format'] ) && 'count' === $assoc_args['format'] ) { 
  170. echo (int) $headers['X-WP-Total']; 
  171. } elseif ( 'headers' === $assoc_args['format'] ) { 
  172. echo json_encode( $headers ); 
  173. } elseif ( 'body' === $assoc_args['format'] ) { 
  174. echo json_encode( $body ); 
  175. } elseif ( 'envelope' === $assoc_args['format'] ) { 
  176. echo json_encode( array( 
  177. 'body' => $body,  
  178. 'headers' => $headers,  
  179. 'status' => $status,  
  180. 'api_url' => $this->api_url,  
  181. ) ); 
  182. } else { 
  183. $formatter = $this->get_formatter( $assoc_args ); 
  184. $formatter->display_items( $items ); 
  185.  
  186. /** 
  187. * Update an existing item. 
  188. * 
  189. * @subcommand update 
  190. */ 
  191. public function update_item( $args, $assoc_args ) { 
  192. $assoc_args = self::decode_json( $assoc_args ); 
  193. list( $status, $body ) = $this->do_request( 'POST', $this->get_filled_route( $args ), $assoc_args ); 
  194. if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { 
  195. WP_CLI::line( $body['id'] ); 
  196. } else { 
  197. WP_CLI::success( __( 'Updated', 'woocommerce' ) . " {$this->name} {$body['id']}." ); 
  198.  
  199. /** 
  200. * Do a REST Request 
  201. * 
  202. * @param string $method 
  203. * 
  204. */ 
  205. private function do_request( $method, $route, $assoc_args ) { 
  206. if ( ! defined( 'REST_REQUEST' ) ) { 
  207. define( 'REST_REQUEST', true ); 
  208. $request = new WP_REST_Request( $method, $route ); 
  209. if ( in_array( $method, array( 'POST', 'PUT' ) ) ) { 
  210. $request->set_body_params( $assoc_args ); 
  211. } else { 
  212. foreach ( $assoc_args as $key => $value ) { 
  213. $request->set_param( $key, $value ); 
  214. if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { 
  215. $original_queries = is_array( $GLOBALS['wpdb']->queries ) ? array_keys( $GLOBALS['wpdb']->queries ) : array(); 
  216. $response = rest_do_request( $request ); 
  217. if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { 
  218. $performed_queries = array(); 
  219. foreach ( (array) $GLOBALS['wpdb']->queries as $key => $query ) { 
  220. if ( in_array( $key, $original_queries ) ) { 
  221. continue; 
  222. $performed_queries[] = $query; 
  223. usort( $performed_queries, function( $a, $b ) { 
  224. if ( $a[1] === $b[1] ) { 
  225. return 0; 
  226. return ( $a[1] > $b[1] ) ? -1 : 1; 
  227. }); 
  228.  
  229. $query_count = count( $performed_queries ); 
  230. $query_total_time = 0; 
  231. foreach ( $performed_queries as $query ) { 
  232. $query_total_time += $query[1]; 
  233. $slow_query_message = ''; 
  234. if ( $performed_queries && 'wc' === WP_CLI::get_config( 'debug' ) ) { 
  235. $slow_query_message .= '. Ordered by slowness, the queries are:' . PHP_EOL; 
  236. foreach ( $performed_queries as $i => $query ) { 
  237. $i++; 
  238. $bits = explode( ', ', $query[2] ); 
  239. $backtrace = implode( ', ', array_slice( $bits, 13 ) ); 
  240. $seconds = round( $query[1], 6 ); 
  241. $slow_query_message .= <<<EOT 
  242. {$i}: 
  243. - {$seconds} seconds 
  244. - {$backtrace} 
  245. - {$query[0]} 
  246. EOT; 
  247. $slow_query_message .= PHP_EOL; 
  248. } elseif ( 'wc' !== WP_CLI::get_config( 'debug' ) ) { 
  249. $slow_query_message = '. Use --debug=wc to see all queries.'; 
  250. $query_total_time = round( $query_total_time, 6 ); 
  251. WP_CLI::debug( "wc command executed {$query_count} queries in {$query_total_time} seconds{$slow_query_message}", 'wc' ); 
  252.  
  253. if ( $error = $response->as_error() ) { 
  254. // For authentication errors (status 401), include a reminder to set the --user flag. 
  255. // WP_CLI::error will only return the first message from WP_Error, so we will pass a string containing both instead. 
  256. if ( 401 === $response->get_status() ) { 
  257. $errors = $error->get_error_messages(); 
  258. $errors[] = __( 'Make sure to include the --user flag with an account that has permissions for this action.', 'woocommerce' ) . ' {"status":401}'; 
  259. $error = implode( "\n", $errors ); 
  260. WP_CLI::error( $error ); 
  261. return array( $response->get_status(), $response->get_data(), $response->get_headers() ); 
  262.  
  263. /** 
  264. * Get Formatter object based on supplied parameters. 
  265. * 
  266. * @param array $assoc_args Parameters passed to command. Determines formatting. 
  267. * @return \WP_CLI\Formatter 
  268. */ 
  269. protected function get_formatter( &$assoc_args ) { 
  270. if ( ! empty( $assoc_args['fields'] ) ) { 
  271. if ( is_string( $assoc_args['fields'] ) ) { 
  272. $fields = explode( ', ', $assoc_args['fields'] ); 
  273. } else { 
  274. $fields = $assoc_args['fields']; 
  275. } else { 
  276. if ( ! empty( $assoc_args['context'] ) ) { 
  277. $fields = $this->get_context_fields( $assoc_args['context'] ); 
  278. } else { 
  279. $fields = $this->get_context_fields( 'view' ); 
  280. return new \WP_CLI\Formatter( $assoc_args, $fields ); 
  281.  
  282. /** 
  283. * Get a list of fields present in a given context 
  284. * 
  285. * @param string $context 
  286. * @return array 
  287. */ 
  288. private function get_context_fields( $context ) { 
  289. $fields = array(); 
  290. foreach ( $this->schema['properties'] as $key => $args ) { 
  291. if ( empty( $args['context'] ) || in_array( $context, $args['context'] ) ) { 
  292. $fields[] = $key; 
  293. return $fields; 
  294.  
  295. /** 
  296. * Get the route for this resource 
  297. * 
  298. * @param array $args 
  299. * @return string 
  300. */ 
  301. private function get_filled_route( $args = array() ) { 
  302. $supported_id_matched = false; 
  303. $route = $this->route; 
  304.  
  305. foreach ( $this->get_supported_ids() as $id_name => $id_desc ) { 
  306. if ( strpos( $route, '<' . $id_name . '>' ) !== false && ! empty( $args ) ) { 
  307. $route = str_replace( '(?P<' . $id_name . '>[\d]+)', $args[0], $route ); 
  308. $supported_id_matched = true; 
  309.  
  310. if ( ! empty( $args ) ) { 
  311. $id_replacement = $supported_id_matched && ! empty( $args[1] ) ? $args[1] : $args[0]; 
  312. $route = str_replace( array( '(?P<id>[\d]+)', '(?P<id>[\w-]+)' ), $id_replacement, $route ); 
  313.  
  314. return rtrim( $route ); 
  315.  
  316. /** 
  317. * Output a line to be added 
  318. * 
  319. * @param string 
  320. */ 
  321. private function add_line( $line ) { 
  322. $this->nested_line( $line, 'add' ); 
  323.  
  324. /** 
  325. * Output a line to be removed 
  326. * 
  327. * @param string 
  328. */ 
  329. private function remove_line( $line ) { 
  330. $this->nested_line( $line, 'remove' ); 
  331.  
  332. /** 
  333. * Output a line that's appropriately nested 
  334. */ 
  335. private function nested_line( $line, $change = false ) { 
  336. if ( 'add' == $change ) { 
  337. $label = '+ '; 
  338. } elseif ( 'remove' == $change ) { 
  339. $label = '- '; 
  340. } else { 
  341. $label = false; 
  342.  
  343. $spaces = ( $this->output_nesting_level * 2 ) + 2; 
  344. if ( $label ) { 
  345. $line = $label . $line; 
  346. $spaces = $spaces - 2; 
  347. WP_CLI::line( str_pad( ' ', $spaces ) . $line ); 
  348.  
  349. /** 
  350. * Whether or not this is an associative array 
  351. * 
  352. * @param array 
  353. * @return bool 
  354. */ 
  355. private function is_assoc_array( $array ) { 
  356. if ( ! is_array( $array ) ) { 
  357. return false; 
  358. return array_keys( $array ) !== range( 0, count( $array ) - 1 ); 
  359.  
  360. /** 
  361. * Reduce an item to specific fields. 
  362. * 
  363. * @param array $item 
  364. * @param array $fields 
  365. * @return array 
  366. */ 
  367. private static function limit_item_to_fields( $item, $fields ) { 
  368. if ( empty( $fields ) ) { 
  369. return $item; 
  370. if ( is_string( $fields ) ) { 
  371. $fields = explode( ', ', $fields ); 
  372. foreach ( $item as $i => $field ) { 
  373. if ( ! in_array( $i, $fields ) ) { 
  374. unset( $item[ $i ] ); 
  375. return $item; 
  376.  
  377. /** 
  378. * JSON can be passed in some more complicated objects, like the payment gateway settings array. 
  379. * This function decodes the json (if present) and tries to get it's value. 
  380. * 
  381. * @param array $arr 
  382. */ 
  383. protected function decode_json( $arr ) { 
  384. foreach ( $arr as $key => $value ) { 
  385. if ( '[' === substr( $value, 0, 1 ) || '{' === substr( $value, 0, 1 ) ) { 
  386. $arr[ $key ] = json_decode( $value, true ); 
  387. } else { 
  388. continue; 
  389. return $arr; 
  390.  
.