diff --git a/inc/RestCommand.php b/inc/RestCommand.php index d130904..534d852 100644 --- a/inc/RestCommand.php +++ b/inc/RestCommand.php @@ -2,22 +2,41 @@ namespace WP_REST_CLI; -use Spyc; +use Mustangostang\Spyc; use WP_CLI; use WP_CLI\Utils; class RestCommand { - private $scope = 'internal'; + /** @var string */ + private $scope = 'internal'; + + /** @var string */ private $api_url = ''; - private $auth = array(); + + /** @var array */ + private $auth = array(); + + /** @var string */ private $name; + + /** @var string */ private $route; + + /** @var string */ private $resource_identifier; + + /** @var array */ private $schema; - private $default_context = ''; + + /** @var int */ private $output_nesting_level = 0; + /** + * @param string $name + * @param string $route + * @param array $schema + */ public function __construct( $name, $route, $schema ) { $this->name = $name; $parsed_args = preg_match_all( '#\([^\)]+\)#', $route, $matches ); @@ -30,6 +49,7 @@ public function __construct( $name, $route, $schema ) { * Set the scope of the REST requests * * @param string $scope + * @return void */ public function set_scope( $scope ) { $this->scope = $scope; @@ -39,6 +59,7 @@ public function set_scope( $scope ) { * Set the API url for the REST requests * * @param string $api_url + * @return void */ public function set_api_url( $api_url ) { $this->api_url = $api_url; @@ -47,7 +68,8 @@ public function set_api_url( $api_url ) { /** * Set the authentication for the API requests * - * @param array $auth + * @param array $auth + * @return void */ public function set_auth( $auth ) { $this->auth = $auth; @@ -57,11 +79,19 @@ public function set_auth( $auth ) { * Create a new item. * * @subcommand create + * + * @param array $args + * @param array $assoc_args + * @return void */ public function create_item( $args, $assoc_args ) { list( $status, $body ) = $this->do_request( 'POST', $this->get_base_route(), $assoc_args ); - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( $body['id'] ); + if ( ! is_array( $body ) || empty( $body['id'] ) ) { + WP_CLI::error( "Could not create {$this->name}." ); + } + /** @var array{id: scalar} $body */ + if ( Utils\get_flag_value( self::get_typed_assoc_args( $assoc_args ), 'porcelain' ) ) { + WP_CLI::line( (string) $body['id'] ); } else { WP_CLI::success( "Created {$this->name} {$body['id']}." ); } @@ -71,12 +101,23 @@ public function create_item( $args, $assoc_args ) { * Generate some items. * * @subcommand generate + * + * @param array $args + * @param array $assoc_args + * @return void */ public function generate_items( $args, $assoc_args ) { - $count = $assoc_args['count']; + $count = 0; + if ( isset( $assoc_args['count'] ) ) { + $count = is_numeric( $assoc_args['count'] ) ? (int) $assoc_args['count'] : 0; + } unset( $assoc_args['count'] ); - $format = $assoc_args['format']; + + $format = 'ids'; + if ( isset( $assoc_args['format'] ) ) { + $format = is_scalar( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'ids'; + } unset( $assoc_args['format'] ); $notify = false; @@ -89,9 +130,11 @@ public function generate_items( $args, $assoc_args ) { list( $status, $body ) = $this->do_request( 'POST', $this->get_base_route(), $assoc_args ); if ( 'progress' === $format ) { + /** @var \cli\progress\Bar $notify */ $notify->tick(); } elseif ( 'ids' === $format ) { - echo $body['id']; + $id = is_array( $body ) && isset( $body['id'] ) ? $body['id'] : ''; + echo is_scalar( $id ) ? (string) $id : ''; if ( $i < $count - 1 ) { echo ' '; } @@ -99,6 +142,7 @@ public function generate_items( $args, $assoc_args ) { } if ( 'progress' === $format ) { + /** @var \cli\progress\Bar $notify */ $notify->finish(); } } @@ -107,14 +151,30 @@ public function generate_items( $args, $assoc_args ) { * Delete an existing item. * * @subcommand delete + * + * @param array $args + * @param array $assoc_args + * @return void */ public function delete_item( $args, $assoc_args ) { list( $status, $body ) = $this->do_request( 'DELETE', $this->get_filled_route( $args ), $assoc_args ); - $id = isset( $body['previous'] ) ? $body['previous']['id'] : $body['id']; - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { + if ( ! is_array( $body ) ) { + $body = array(); + } + + $id = ''; + if ( isset( $body['previous'] ) && is_array( $body['previous'] ) && isset( $body['previous']['id'] ) ) { + $id = $body['previous']['id']; + } elseif ( isset( $body['id'] ) ) { + $id = $body['id']; + } + + $id = is_scalar( $id ) ? (string) $id : ''; + + if ( Utils\get_flag_value( self::get_typed_assoc_args( $assoc_args ), 'porcelain' ) ) { WP_CLI::line( $id ); } elseif ( empty( $assoc_args['force'] ) ) { - WP_CLI::success( "Trashed {$this->name} {$id}." ); + WP_CLI::success( "Trashed {$this->name} {$id}." ); } else { WP_CLI::success( "Deleted {$this->name} {$id}." ); } @@ -124,12 +184,29 @@ public function delete_item( $args, $assoc_args ) { * Get a single item. * * @subcommand get + * + * @param array $args + * @param array $assoc_args + * @return void */ public function get_item( $args, $assoc_args ) { list( $status, $body, $headers ) = $this->do_request( 'GET', $this->get_filled_route( $args ), $assoc_args ); + if ( ! is_array( $body ) ) { + $body = array(); + } + if ( ! empty( $assoc_args['fields'] ) ) { - $body = self::limit_item_to_fields( $body, $assoc_args['fields'] ); + $fields = $assoc_args['fields']; + if ( is_string( $fields ) ) { + $fields = explode( ',', $fields ); + } + if ( is_array( $fields ) ) { + $fields = array_filter( $fields, 'is_string' ); + } else { + $fields = array(); + } + $body = self::limit_item_to_fields( $body, $fields ); } if ( 'headers' === $assoc_args['format'] ) { @@ -155,6 +232,10 @@ public function get_item( $args, $assoc_args ) { * List all items. * * @subcommand list + * + * @param array $args + * @param array $assoc_args + * @return void */ public function list_items( $args, $assoc_args ) { if ( ! empty( $assoc_args['format'] ) && 'count' === $assoc_args['format'] ) { @@ -163,6 +244,9 @@ public function list_items( $args, $assoc_args ) { $method = 'GET'; } list( $status, $body, $headers ) = $this->do_request( $method, $this->get_base_route(), $assoc_args ); + if ( ! is_array( $body ) ) { + $body = array(); + } if ( ! empty( $assoc_args['format'] ) && 'ids' === $assoc_args['format'] ) { $items = array_column( $body, 'id' ); } else { @@ -170,13 +254,26 @@ public function list_items( $args, $assoc_args ) { } if ( ! empty( $assoc_args['fields'] ) ) { + $fields = $assoc_args['fields']; + if ( is_string( $fields ) ) { + $fields = explode( ',', $fields ); + } + if ( is_array( $fields ) ) { + $fields = array_filter( $fields, 'is_string' ); + } else { + $fields = array(); + } foreach ( $items as $key => $item ) { - $items[ $key ] = self::limit_item_to_fields( $item, $assoc_args['fields'] ); + if ( is_array( $item ) ) { + /** @var array $item */ + $items[ $key ] = self::limit_item_to_fields( $item, $fields ); + } } } if ( ! empty( $assoc_args['format'] ) && 'count' === $assoc_args['format'] ) { - echo (int) $headers['X-WP-Total']; + $total = isset( $headers['X-WP-Total'] ) ? $headers['X-WP-Total'] : 0; + echo is_numeric( $total ) ? (int) $total : 0; } elseif ( 'headers' === $assoc_args['format'] ) { echo json_encode( $headers ); } elseif ( 'body' === $assoc_args['format'] ) { @@ -209,6 +306,10 @@ public function list_items( $args, $assoc_args ) { * : Limit comparison to specific fields. * * @subcommand diff + * + * @param array $args + * @param array $assoc_args + * @return void */ public function diff_items( $args, $assoc_args ) { @@ -217,12 +318,19 @@ public function diff_items( $args, $assoc_args ) { WP_CLI::error( "Alias '{$alias}' not found." ); } $resource = isset( $args[1] ) ? $args[1] : null; - $fields = Utils\get_flag_value( $assoc_args, 'fields', null ); + $fields = Utils\get_flag_value( self::get_typed_assoc_args( $assoc_args ), 'fields', '' ); + if ( ! is_string( $fields ) ) { + $fields = ''; + } list( $from_status, $from_body, $from_headers ) = $this->do_request( 'GET', $this->get_base_route(), array() ); - $php_bin = WP_CLI::get_php_binary(); - $script_path = $GLOBALS['argv'][0]; + $php_bin = WP_CLI::get_php_binary(); + $argv = isset( $GLOBALS['argv'] ) && is_array( $GLOBALS['argv'] ) ? $GLOBALS['argv'] : array(); + $script_path = ''; + if ( isset( $argv[0] ) && is_string( $argv[0] ) ) { + $script_path = $argv[0]; + } $other_args = implode( ' ', array_map( 'escapeshellarg', array( $alias, 'rest', $this->name, 'list' ) ) ); $other_assoc_args = Utils\assoc_args_to_str( array( 'format' => 'envelope' ) ); $full_command = "{$php_bin} {$script_path} {$other_args} {$other_assoc_args}"; @@ -237,9 +345,16 @@ public function diff_items( $args, $assoc_args ) { ); $result = $process->run(); $response = json_decode( $result->stdout, true ); - $to_headers = $response['headers']; - $to_body = $response['body']; - $to_api_url = $response['api_url']; + if ( ! is_array( $response ) || ! isset( $response['headers'] ) || ! isset( $response['body'] ) || ! isset( $response['api_url'] ) || ! is_string( $response['api_url'] ) ) { + WP_CLI::error( 'Invalid response from alias.' ); + } + /** @var array{headers: mixed, body: mixed, api_url: string} $response */ + $to_headers = $response['headers']; + $to_body = $response['body']; + $to_api_url = $response['api_url']; + + $from_body = is_array( $from_body ) ? $from_body : array(); + $to_body = is_array( $to_body ) ? $to_body : array(); if ( ! is_null( $resource ) ) { $field = is_numeric( $resource ) ? 'id' : 'slug'; @@ -260,8 +375,12 @@ public function diff_items( $args, $assoc_args ) { $to_item = array(); if ( ! empty( $from_body ) ) { $from_item = array_shift( $from_body ); + $from_item = is_array( $from_item ) ? $from_item : array(); if ( ! empty( $to_body ) && ! empty( $from_item['slug'] ) ) { foreach ( $to_body as $i => $item ) { + if ( ! is_array( $item ) ) { + continue; + } if ( ! empty( $item['slug'] ) && $item['slug'] === $from_item['slug'] ) { $to_item = $item; unset( $to_body[ $i ] ); @@ -271,13 +390,15 @@ public function diff_items( $args, $assoc_args ) { } } elseif ( ! empty( $to_body ) ) { $to_item = array_shift( $to_body ); + $to_item = is_array( $to_item ) ? $to_item : array(); } if ( ! empty( $to_item ) ) { - foreach ( array( 'to_item', 'from_item' ) as $item ) { - if ( isset( $item['_links'] ) ) { - unset( $item['_links'] ); - } + if ( isset( $to_item['_links'] ) ) { + unset( $to_item['_links'] ); + } + if ( isset( $from_item['_links'] ) ) { + unset( $from_item['_links'] ); } $display_items[] = array( 'from' => self::limit_item_to_fields( $from_item, $fields ), @@ -305,11 +426,19 @@ public function diff_items( $args, $assoc_args ) { * Update an existing item. * * @subcommand update + * + * @param array $args + * @param array $assoc_args + * @return void */ public function update_item( $args, $assoc_args ) { list( $status, $body ) = $this->do_request( 'POST', $this->get_filled_route( $args ), $assoc_args ); - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( $body['id'] ); + if ( ! is_array( $body ) || empty( $body['id'] ) ) { + WP_CLI::error( "Could not update {$this->name}." ); + } + /** @var array{id: scalar} $body */ + if ( Utils\get_flag_value( self::get_typed_assoc_args( $assoc_args ), 'porcelain' ) ) { + WP_CLI::line( (string) $body['id'] ); } else { WP_CLI::success( "Updated {$this->name} {$body['id']}." ); } @@ -319,30 +448,46 @@ public function update_item( $args, $assoc_args ) { * Open an existing item in the editor * * @subcommand edit + * + * @param array $args + * @param array $assoc_args + * @return void */ public function edit_item( $args, $assoc_args ) { $assoc_args['context'] = 'edit'; list( $status, $options_body ) = $this->do_request( 'OPTIONS', $this->get_filled_route( $args ), $assoc_args ); - if ( empty( $options_body['schema'] ) ) { + if ( ! is_array( $options_body ) || empty( $options_body['schema'] ) || ! is_array( $options_body['schema'] ) ) { WP_CLI::error( 'Cannot edit - no schema found for resource.' ); } - $schema = $options_body['schema']; + /** @var array{schema: array{properties: array, title: string}} $options_body */ + $schema = $options_body['schema']; + if ( empty( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) { + WP_CLI::error( 'Cannot edit - no properties found in schema.' ); + } + if ( empty( $schema['title'] ) || ! is_string( $schema['title'] ) ) { + WP_CLI::error( 'Cannot edit - no valid title in schema.' ); + } list( $status, $resource_fields ) = $this->do_request( 'GET', $this->get_filled_route( $args ), $assoc_args ); - $editable_fields = array(); + if ( ! is_array( $resource_fields ) ) { + WP_CLI::error( 'Cannot edit - no resource fields found.' ); + } + /** @var array $resource_fields */ + $editable_fields = array(); foreach ( $resource_fields as $key => $value ) { - if ( ! isset( $schema['properties'][ $key ] ) || ! empty( $schema['properties'][ $key ]['readonly'] ) ) { + if ( ! isset( $schema['properties'][ $key ] ) || ! is_array( $schema['properties'][ $key ] ) || ! empty( $schema['properties'][ $key ]['readonly'] ) ) { continue; } $properties = $schema['properties'][ $key ]; - if ( isset( $properties['properties'] ) ) { + if ( isset( $properties['properties'] ) && is_array( $properties['properties'] ) ) { $parent_key = $key; $properties = $properties['properties']; - foreach ( $value as $key => $value ) { - if ( isset( $properties[ $key ] ) && empty( $properties[ $key ]['readonly'] ) ) { - if ( ! isset( $editable_fields[ $parent_key ] ) ) { - $editable_fields[ $parent_key ] = array(); + if ( is_array( $value ) ) { + foreach ( $value as $sub_key => $sub_value ) { + if ( isset( $properties[ $sub_key ] ) && is_array( $properties[ $sub_key ] ) && empty( $properties[ $sub_key ]['readonly'] ) ) { + $sub_array = isset( $editable_fields[ $parent_key ] ) && is_array( $editable_fields[ $parent_key ] ) ? $editable_fields[ $parent_key ] : array(); + $sub_array[ $sub_key ] = $sub_value; + $editable_fields[ $parent_key ] = $sub_array; } - $editable_fields[ $parent_key ][ $key ] = $value; } } continue; @@ -355,7 +500,7 @@ public function edit_item( $args, $assoc_args ) { WP_CLI::error( 'Cannot edit - no editable fields found on schema.' ); } $ret = Utils\launch_editor_for_input( Spyc::YAMLDump( $editable_fields ), sprintf( 'Editing %s %s', $schema['title'], $args[0] ) ); - if ( false === $ret ) { + if ( ! is_string( $ret ) ) { WP_CLI::warning( 'No edits made.' ); } else { list( $status, $body ) = $this->do_request( 'POST', $this->get_filled_route( $args ), Spyc::YAMLLoadString( $ret ) ); @@ -366,8 +511,11 @@ public function edit_item( $args, $assoc_args ) { /** * Do a REST Request * - * @param string $method + * @param string $method + * @param string $route + * @param array $assoc_args * + * @return array{0: int, 1: mixed, 2: array} */ private function do_request( $method, $route, $assoc_args ) { if ( 'internal' === $this->scope ) { @@ -383,13 +531,18 @@ private function do_request( $method, $route, $assoc_args ) { $request->set_param( $key, $value ); } } + $original_queries = array(); if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { - $original_queries = is_array( $GLOBALS['wpdb']->queries ) ? array_keys( $GLOBALS['wpdb']->queries ) : array(); + /** @var \wpdb $wpdb */ + $wpdb = $GLOBALS['wpdb']; + $original_queries = is_array( $wpdb->queries ) ? array_keys( $wpdb->queries ) : array(); } $response = rest_do_request( $request ); if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + /** @var \wpdb $wpdb */ + $wpdb = $GLOBALS['wpdb']; $performed_queries = array(); - foreach ( (array) $GLOBALS['wpdb']->queries as $key => $query ) { + foreach ( (array) $wpdb->queries as $key => $query ) { if ( in_array( $key, $original_queries, true ) ) { continue; } @@ -440,17 +593,25 @@ function ( $a, $b ) { } elseif ( 'http' === $this->scope ) { $headers = array(); if ( ! empty( $this->auth ) && 'basic' === $this->auth['type'] ) { - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode - $headers['Authorization'] = 'Basic ' . base64_encode( $this->auth['username'] . ':' . $this->auth['password'] ); + $username = isset( $this->auth['username'] ) ? $this->auth['username'] : ''; + $password = isset( $this->auth['password'] ) ? $this->auth['password'] : ''; + if ( is_scalar( $username ) && is_scalar( $password ) ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $headers['Authorization'] = 'Basic ' . base64_encode( (string) $username . ':' . (string) $password ); + } } if ( 'OPTIONS' === $method ) { $method = 'GET'; $assoc_args['_method'] = 'OPTIONS'; } + /** @var \WpOrg\Requests\Response $response */ $response = Utils\http_request( $method, rtrim( $this->api_url, '/' ) . $route, $assoc_args, $headers ); $body = json_decode( $response->body, true ); + if ( ! is_array( $body ) ) { + $body = array(); + } if ( $response->status_code >= 400 ) { - if ( ! empty( $body['message'] ) ) { + if ( ! empty( $body['message'] ) && is_string( $body['message'] ) ) { WP_CLI::error( $body['message'] . ' ' . json_encode( array( 'status' => $response->status_code ) ) ); } else { switch ( $response->status_code ) { @@ -463,26 +624,30 @@ function ( $a, $b ) { } } } + assert( is_int( $response->status_code ) ); return array( $response->status_code, json_decode( $response->body, true ), $response->headers->getAll() ); } WP_CLI::error( 'Invalid scope for REST command.' ); + return array( 0, '', array() ); } /** * Get Formatter object based on supplied parameters. * - * @param array $assoc_args Parameters passed to command. Determines formatting. + * @param array $assoc_args Parameters passed to command. Determines formatting. * @return \WP_CLI\Formatter */ - protected function get_formatter( &$assoc_args ) { + protected function get_formatter( $assoc_args ) { if ( ! empty( $assoc_args['fields'] ) ) { if ( is_string( $assoc_args['fields'] ) ) { $fields = explode( ',', $assoc_args['fields'] ); + } elseif ( is_array( $assoc_args['fields'] ) ) { + $fields = array_filter( $assoc_args['fields'], 'is_string' ); } else { - $fields = $assoc_args['fields']; + $fields = array(); } - } elseif ( ! empty( $assoc_args['context'] ) ) { - $fields = $this->get_context_fields( $assoc_args['context'] ); + } elseif ( ! empty( $assoc_args['context'] ) && is_scalar( $assoc_args['context'] ) ) { + $fields = $this->get_context_fields( (string) $assoc_args['context'] ); } else { $fields = $this->get_context_fields( 'view' ); } @@ -493,22 +658,35 @@ protected function get_formatter( &$assoc_args ) { * Get a list of fields present in a given context * * @param string $context - * @return array + * @return array */ private function get_context_fields( $context ) { $fields = array(); - foreach ( $this->schema['properties'] as $key => $args ) { - if ( empty( $args['context'] ) || in_array( $context, $args['context'], true ) ) { - $fields[] = $key; + if ( ! empty( $this->schema['properties'] ) && is_array( $this->schema['properties'] ) ) { + foreach ( $this->schema['properties'] as $key => $args ) { + if ( ! is_array( $args ) ) { + continue; + } + $context_array = isset( $args['context'] ) ? $args['context'] : array(); + if ( ! is_array( $context_array ) ) { + $context_array = array(); + } + if ( empty( $context_array ) || in_array( $context, $context_array, true ) ) { + $fields[] = (string) $key; + } } } - foreach ( $this->get_additional_fields( $this->schema['title'] ) as $field_name => $field ) { + $title = isset( $this->schema['title'] ) ? $this->schema['title'] : ''; + if ( ! is_scalar( $title ) ) { + $title = ''; + } + foreach ( $this->get_additional_fields( (string) $title ) as $field_name => $field ) { // For back-compat, include any field with an empty schema // because it won't be present in $this->get_item_schema(). // @see \WP_REST_Controller::get_fields_for_response - if ( is_null( $field['schema'] ) ) { - $fields[] = $field_name; + if ( is_array( $field ) && isset( $field['schema'] ) && is_null( $field['schema'] ) ) { + $fields[] = (string) $field_name; } } return $fields; @@ -521,7 +699,7 @@ private function get_context_fields( $context ) { * @param string $object_type * * @see \WP_REST_Controller::get_additional_fields - * @return array + * @return array> */ private function get_additional_fields( $object_type ) { global $wp_rest_additional_fields; @@ -545,6 +723,9 @@ private function get_base_route() { /** * Fill the route based on provided $args + * + * @param array $args + * @return string */ private function get_filled_route( $args ) { return rtrim( $this->get_base_route(), '/' ) . '/' . $args[0]; @@ -553,7 +734,9 @@ private function get_filled_route( $args ) { /** * Visually depict the difference between "dictated" and "current" * - * @param array + * @param string $slug + * @param array $difference + * @return void */ private function show_difference( $slug, $difference ) { $this->output_nesting_level = 0; @@ -564,47 +747,58 @@ private function show_difference( $slug, $difference ) { /** * Recursively output the difference between "dictated" and "current" + * + * @param mixed $dictated + * @param mixed $current + * @return void */ private function recursively_show_difference( $dictated, $current = null ) { ++$this->output_nesting_level; - if ( $this->is_assoc_array( $dictated ) ) { + if ( is_array( $dictated ) && $this->is_assoc_array( $dictated ) ) { foreach ( $dictated as $key => $value ) { + $key_str = (string) $key; - if ( $this->is_assoc_array( $value ) || is_array( $value ) ) { + if ( is_array( $value ) ) { + + $new_current = null; + if ( is_array( $current ) && isset( $current[ $key ] ) ) { + $new_current = $current[ $key ]; + } - $new_current = isset( $current[ $key ] ) ? $current[ $key ] : null; if ( $new_current ) { - $this->nested_line( $key . ': ' ); + $this->nested_line( $key_str . ': ' ); } else { - $this->add_line( $key . ': ' ); + $this->add_line( $key_str . ': ' ); } $this->recursively_show_difference( $value, $new_current ); - } elseif ( is_string( $value ) ) { - - $pre = $key . ': '; + } elseif ( is_scalar( $value ) ) { - if ( isset( $current[ $key ] ) && $current[ $key ] !== $value ) { - - $this->remove_line( $pre . $current[ $key ] ); - $this->add_line( $pre . $value ); - - } elseif ( ! isset( $current[ $key ] ) ) { - - $this->add_line( $pre . $value ); + $pre = $key_str . ': '; + $value_str = (string) $value; + if ( is_array( $current ) && isset( $current[ $key ] ) ) { + $current_val = $current[ $key ]; + if ( $current_val !== $value ) { + $current_val_str = is_scalar( $current_val ) ? (string) $current_val : ''; + $this->remove_line( $pre . $current_val_str ); + $this->add_line( $pre . $value_str ); + } + } else { + $this->add_line( $pre . $value_str ); } } } } elseif ( is_array( $dictated ) ) { foreach ( $dictated as $value ) { - if ( ! $current || ! in_array( $value, $current, true ) ) { - $this->add_line( '- ' . $value ); + $value_str = is_scalar( $value ) ? (string) $value : ''; + if ( ! is_array( $current ) || ! in_array( $value, $current, true ) ) { + $this->add_line( '- ' . $value_str ); } } } @@ -615,7 +809,8 @@ private function recursively_show_difference( $dictated, $current = null ) { /** * Output a line to be added * - * @param string + * @param string $line + * @return void */ private function add_line( $line ) { $this->nested_line( $line, 'add' ); @@ -624,7 +819,8 @@ private function add_line( $line ) { /** * Output a line to be removed * - * @param string + * @param string $line + * @return void */ private function remove_line( $line ) { $this->nested_line( $line, 'remove' ); @@ -632,6 +828,10 @@ private function remove_line( $line ) { /** * Output a line that's appropriately nested + * + * @param string $line + * @param string|bool $change + * @return void */ private function nested_line( $line, $change = false ) { @@ -657,7 +857,7 @@ private function nested_line( $line, $change = false ) { /** * Whether or not this is an associative array * - * @param array $arr + * @param array $arr * @return bool */ private function is_assoc_array( $arr ) { @@ -676,9 +876,9 @@ private function is_assoc_array( $arr ) { /** * Reduce an item to specific fields. * - * @param array $item - * @param array $fields - * @return array + * @param array $item + * @param array|string $fields + * @return array */ private static function limit_item_to_fields( $item, $fields ) { if ( empty( $fields ) ) { @@ -694,4 +894,20 @@ private static function limit_item_to_fields( $item, $fields ) { } return $item; } + + /** + * Get typed assoc args for WP-CLI utilities. + * + * @param array $assoc_args + * @return array + */ + private static function get_typed_assoc_args( array $assoc_args ) { + $typed = array(); + foreach ( $assoc_args as $key => $value ) { + if ( is_string( $key ) && ( is_string( $value ) || is_bool( $value ) ) ) { + $typed[ $key ] = $value; + } + } + return $typed; + } } diff --git a/inc/Runner.php b/inc/Runner.php index 8597156..8e96485 100644 --- a/inc/Runner.php +++ b/inc/Runner.php @@ -11,6 +11,8 @@ class Runner { /** * When --http=domain.com is passed as global arg, register REST for it + * + * @return void */ public static function load_remote_commands() { @@ -23,10 +25,12 @@ public static function load_remote_commands() { if ( ! $api_url ) { WP_CLI::error( "Couldn't auto-discover WP REST API endpoint from {$http}." ); } + assert( is_string( $api_url ) ); $api_index = self::get_api_index( $api_url ); if ( ! $api_index ) { WP_CLI::error( "Couldn't find index data from {$api_url}." ); } + assert( is_array( $api_index ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url $bits = parse_url( $http ); $auth = array(); @@ -35,22 +39,40 @@ public static function load_remote_commands() { $auth['username'] = $bits['user']; $auth['password'] = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; } - foreach ( $api_index['routes'] as $route => $route_data ) { - if ( empty( $route_data['schema']['title'] ) ) { - WP_CLI::debug( "No schema title found for {$route}, skipping REST command registration.", 'rest' ); + if ( ! isset( $api_index['routes'] ) || ! is_array( $api_index['routes'] ) ) { + WP_CLI::error( "No routes found in API index from {$api_url}." ); + } + /** @var array> $routes */ + $routes = $api_index['routes']; + foreach ( $routes as $route => $route_data ) { + if ( ! is_array( $route_data ) ) { + continue; + } + if ( empty( $route_data['schema'] ) || ! is_array( $route_data['schema'] ) ) { continue; } - $name = $route_data['schema']['title']; - $rest_command = new RESTCommand( $name, $route, $route_data['schema'] ); + if ( empty( $route_data['schema']['title'] ) || ! is_string( $route_data['schema']['title'] ) ) { + WP_CLI::debug( "No valid schema title found for {$route}, skipping REST command registration.", 'rest' ); + continue; + } + $name = $route_data['schema']['title']; + /** @var array $schema */ + $schema = $route_data['schema']; + $rest_command = new RestCommand( $name, $route, $schema ); $rest_command->set_scope( 'http' ); $rest_command->set_api_url( $api_url ); $rest_command->set_auth( $auth ); - self::register_route_commands( $rest_command, $route, $route_data, array( 'when' => 'before_wp_load' ) ); + self::register_route_commands( $rest_command, (string) $route, $route_data, array( 'when' => 'before_wp_load' ) ); } } + /** + * Run after WordPress is loaded. + * + * @return void + */ public static function after_wp_load() { - if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { + if ( wp_installing() ) { return; } if ( ! class_exists( 'WP_REST_Server' ) ) { @@ -74,14 +96,27 @@ public static function after_wp_load() { return; } - foreach ( $response_data['routes'] as $route => $route_data ) { - if ( empty( $route_data['schema']['title'] ) ) { - WP_CLI::debug( "No schema title found for {$route}, skipping REST command registration.", 'rest' ); + if ( ! is_array( $response_data ) || ! isset( $response_data['routes'] ) || ! is_array( $response_data['routes'] ) ) { + return; + } + /** @var array> $routes */ + $routes = $response_data['routes']; + foreach ( $routes as $route => $route_data ) { + if ( ! is_array( $route_data ) ) { + continue; + } + if ( empty( $route_data['schema'] ) || ! is_array( $route_data['schema'] ) ) { + continue; + } + if ( empty( $route_data['schema']['title'] ) || ! is_string( $route_data['schema']['title'] ) ) { + WP_CLI::debug( "No valid schema title found for {$route}, skipping REST command registration.", 'rest' ); continue; } - $name = $route_data['schema']['title']; - $rest_command = new RESTCommand( $name, $route, $route_data['schema'] ); - self::register_route_commands( $rest_command, $route, $route_data ); + $name = $route_data['schema']['title']; + /** @var array $schema */ + $schema = $route_data['schema']; + $rest_command = new RestCommand( $name, $route, $schema ); + self::register_route_commands( $rest_command, (string) $route, $route_data ); } } @@ -95,6 +130,7 @@ private static function auto_discover_api( $url ) { if ( false === stripos( $url, 'http://' ) && false === stripos( $url, 'https://' ) ) { $url = 'http://' . $url; } + /** @var \WpOrg\Requests\Response $response */ $response = Utils\http_request( 'HEAD', $url ); if ( empty( $response->headers['link'] ) ) { return false; @@ -106,6 +142,12 @@ private static function auto_discover_api( $url ) { return $endpoint; } + /** + * Discover WP-API endpoint from link headers + * + * @param string $link_headers + * @return string|false + */ private static function discover_wp_api( $link_headers ) { if ( preg_match( '#<([^>]+)> *; *rel="https://api.w.org/"#', $link_headers, $matches ) ) { return $matches[1]; @@ -117,29 +159,45 @@ private static function discover_wp_api( $link_headers ) { * Get the index data from an API url * * @param string $api_url - * @return array|false + * @return array|false */ private static function get_api_index( $api_url ) { $query_char = false !== strpos( $api_url, '?' ) ? '&' : '?'; $api_url .= $query_char . 'context=help'; - $response = Utils\http_request( 'GET', $api_url ); + /** @var \WpOrg\Requests\Response $response */ + $response = Utils\http_request( 'GET', $api_url ); if ( empty( $response->body ) ) { return false; } - return json_decode( $response->body, true ); + $index = json_decode( $response->body, true ); + /** @var array|false $index */ + return $index; } /** * Register WP-CLI commands for all endpoints on a route * - * @param string - * @param array $endpoints + * @param \WP_REST_CLI\RestCommand $rest_command + * @param string $route + * @param array $route_data + * @param array $command_args + * @return void */ private static function register_route_commands( $rest_command, $route, $route_data, $command_args = array() ) { + if ( empty( $route_data['schema'] ) || ! is_array( $route_data['schema'] ) ) { + return; + } + if ( empty( $route_data['schema']['title'] ) || ! is_string( $route_data['schema']['title'] ) ) { + return; + } + $parent = "rest {$route_data['schema']['title']}"; $supported_commands = array(); + if ( empty( $route_data['endpoints'] ) || ! is_array( $route_data['endpoints'] ) ) { + return; + } foreach ( $route_data['endpoints'] as $endpoint ) { $parsed_args = preg_match_all( '#\([^\)]+\)#', $route, $matches ); @@ -147,35 +205,43 @@ private static function register_route_commands( $rest_command, $route, $route_d $trimmed_route = rtrim( $route ); $is_singular = $resource_id && substr( $trimmed_route, - strlen( $resource_id ) ) === $resource_id; + if ( ! is_array( $endpoint ) ) { + continue; + } + if ( empty( $endpoint['methods'] ) || ! is_array( $endpoint['methods'] ) ) { + continue; + } + $command = ''; // List a collection if ( array( 'GET' ) === $endpoint['methods'] && ! $is_singular ) { - $supported_commands['list'] = ! empty( $endpoint['args'] ) ? $endpoint['args'] : array(); + $supported_commands['list'] = ( isset( $endpoint['args'] ) && is_array( $endpoint['args'] ) ) ? $endpoint['args'] : array(); } // Create a specific resource if ( array( 'POST' ) === $endpoint['methods'] && ! $is_singular ) { - $supported_commands['create'] = ! empty( $endpoint['args'] ) ? $endpoint['args'] : array(); + $supported_commands['create'] = ( isset( $endpoint['args'] ) && is_array( $endpoint['args'] ) ) ? $endpoint['args'] : array(); } // Get a specific resource if ( array( 'GET' ) === $endpoint['methods'] && $is_singular ) { - $supported_commands['get'] = ! empty( $endpoint['args'] ) ? $endpoint['args'] : array(); + $supported_commands['get'] = ( isset( $endpoint['args'] ) && is_array( $endpoint['args'] ) ) ? $endpoint['args'] : array(); } // Update a specific resource if ( in_array( 'POST', $endpoint['methods'], true ) && $is_singular ) { - $supported_commands['update'] = ! empty( $endpoint['args'] ) ? $endpoint['args'] : array(); + $supported_commands['update'] = ( isset( $endpoint['args'] ) && is_array( $endpoint['args'] ) ) ? $endpoint['args'] : array(); } // Delete a specific resource if ( array( 'DELETE' ) === $endpoint['methods'] && $is_singular ) { - $supported_commands['delete'] = ! empty( $endpoint['args'] ) ? $endpoint['args'] : array(); + $supported_commands['delete'] = ( isset( $endpoint['args'] ) && is_array( $endpoint['args'] ) ) ? $endpoint['args'] : array(); } } + /** @var array> $supported_commands */ foreach ( $supported_commands as $command => $endpoint_args ) { $synopsis = array(); @@ -189,6 +255,9 @@ private static function register_route_commands( $rest_command, $route, $route_d } foreach ( $endpoint_args as $name => $args ) { + if ( ! is_array( $args ) ) { + continue; + } $arg_reg = array( 'name' => $name, 'type' => 'assoc', diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..8168aa3 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + level: 9 + paths: + - inc + - wp-rest-cli.php + scanDirectories: + - vendor/wp-cli/wp-cli/php + scanFiles: + - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + - vendor/wp-cli/mustangostang-spyc/src/Spyc.php + treatPhpDocTypesAsCertain: false