class-wc-cli-rest-command.php 13 KB

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