diff --git a/lib/compat/wordpress-6.0/class-gutenberg-rest-pattern-directory-controller-6-0.php b/lib/compat/wordpress-6.0/class-gutenberg-rest-pattern-directory-controller-6-0.php new file mode 100644 index 0000000000000..331e6015edb0c --- /dev/null +++ b/lib/compat/wordpress-6.0/class-gutenberg-rest-pattern-directory-controller-6-0.php @@ -0,0 +1,48 @@ += 6.0. + * + * @param array $query_args Query arguments to generate a transient key from. + * @return string Transient key. + */ + protected function get_transient_key( $query_args ) { + if ( method_exists( get_parent_class( $this ), __FUNCTION__ ) ) { + return parent::get_transient_key( $query_args ); + } + + if ( isset( $query_args['slug'] ) ) { + // This is an additional precaution because the "sort" function expects an array. + $query_args['slug'] = wp_parse_list( $query_args['slug'] ); + + // Empty arrays should not affect the transient key. + if ( empty( $query_args['slug'] ) ) { + unset( $query_args['slug'] ); + } else { + // Sort the array so that the transient key doesn't depend on the order of slugs. + sort( $query_args['slug'] ); + } + } + + return 'wp_remote_block_patterns_' . md5( serialize( $query_args ) ); + } +} diff --git a/lib/compat/wordpress-6.0/rest-api.php b/lib/compat/wordpress-6.0/rest-api.php index 0cf08533e33b7..0fa1852b18813 100644 --- a/lib/compat/wordpress-6.0/rest-api.php +++ b/lib/compat/wordpress-6.0/rest-api.php @@ -14,16 +14,6 @@ function gutenberg_register_global_styles_endpoints() { } add_action( 'rest_api_init', 'gutenberg_register_global_styles_endpoints' ); - -/** - * Registers the block pattern directory. - */ -function gutenberg_register_rest_pattern_directory() { - $pattern_directory_controller = new Gutenberg_REST_Pattern_Directory_Controller(); - $pattern_directory_controller->register_routes(); -} -add_action( 'rest_api_init', 'gutenberg_register_rest_pattern_directory' ); - /** * Registers the Edit Site's Export REST API routes. * diff --git a/lib/compat/wordpress-6.0/class-gutenberg-rest-pattern-directory-controller.php b/lib/compat/wordpress-6.2/class-gutenberg-rest-pattern-directory-controller-6-2.php similarity index 55% rename from lib/compat/wordpress-6.0/class-gutenberg-rest-pattern-directory-controller.php rename to lib/compat/wordpress-6.2/class-gutenberg-rest-pattern-directory-controller-6-2.php index e92b25f675abb..cc44c37b4d7c9 100644 --- a/lib/compat/wordpress-6.0/class-gutenberg-rest-pattern-directory-controller.php +++ b/lib/compat/wordpress-6.2/class-gutenberg-rest-pattern-directory-controller-6-2.php @@ -1,23 +1,24 @@ get_user_locale(), - 'wp-version' => $wp_version, // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable -- it's defined in `version.php` above. - 'gutenberg-version' => $gutenberg_data['Version'], + $valid_query_args = array( 'offset', 'order', 'orderby', 'page', 'per_page', 'search', 'slug' ); + $query_args = array_merge( + array_intersect_key( $request->get_params(), array_flip( $valid_query_args ) ), + array( + 'locale' => get_user_locale(), + 'wp-version' => $wp_version, // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable -- it's defined in `version.php` above. + 'gutenberg-version' => $gutenberg_data['Version'], + ) ); - $category_id = $request['category']; - $keyword_id = $request['keyword']; - $search_term = $request['search']; - $slug = $request['slug']; - - if ( $category_id ) { - $query_args['pattern-categories'] = $category_id; - } - - if ( $keyword_id ) { - $query_args['pattern-keywords'] = $keyword_id; - } - - if ( $search_term ) { - $query_args['search'] = $search_term; - } + $query_args['pattern-categories'] = isset( $request['category'] ) ? $request['category'] : false; + $query_args['pattern-keywords'] = isset( $request['keyword'] ) ? $request['keyword'] : false; - if ( $slug ) { - $query_args['slug'] = $slug; - } + $query_args = array_filter( $query_args ); $transient_key = $this->get_transient_key( $query_args ); - /** + /* * Use network-wide transient to improve performance. The locale is the only site * configuration that affects the response, and it's included in the transient key. */ @@ -71,7 +60,7 @@ public function get_items( $request ) { $api_url = set_url_scheme( $api_url, 'https' ); } - /** + /* * Default to a short TTL, to mitigate cache stampedes on high-traffic sites. * This assumes that most errors will be short-lived, e.g., packet loss that causes the * first request to fail, but a follow-up one will succeed. The value should be high @@ -90,7 +79,7 @@ public function get_items( $request ) { $raw_patterns = new WP_Error( 'pattern_api_failed', sprintf( - /* translators: %s: Support forums URL. */ + /* translators: %s: Support forums URL. */ __( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server’s configuration. If you continue to have problems, please try the support forums.', 'gutenberg' ), __( 'https://wordpress.org/support/forums/', 'gutenberg' ) ), @@ -125,40 +114,4 @@ public function get_items( $request ) { return new WP_REST_Response( $response ); } - - /** - * Include a hash of the query args, so that different requests are stored in - * separate caches. - * - * MD5 is chosen for its speed, low-collision rate, universal availability, and to stay - * under the character limit for `_site_transient_timeout_{...}` keys. - * - * @link https://stackoverflow.com/questions/3665247/fastest-hash-for-non-cryptographic-uses - * - * @since 6.0.0 - * @todo This should be removed when the minimum required WordPress version is >= 6.0. - * - * @param array $query_args Query arguments to generate a transient key from. - * @return string Transient key. - */ - protected function get_transient_key( $query_args ) { - if ( method_exists( get_parent_class( $this ), __FUNCTION__ ) ) { - return parent::get_transient_key( $query_args ); - } - - if ( isset( $query_args['slug'] ) ) { - // This is an additional precaution because the "sort" function expects an array. - $query_args['slug'] = wp_parse_list( $query_args['slug'] ); - - // Empty arrays should not affect the transient key. - if ( empty( $query_args['slug'] ) ) { - unset( $query_args['slug'] ); - } else { - // Sort the array so that the transient key doesn't depend on the order of slugs. - sort( $query_args['slug'] ); - } - } - - return 'wp_remote_block_patterns_' . md5( serialize( $query_args ) ); - } } diff --git a/lib/compat/wordpress-6.2/rest-api.php b/lib/compat/wordpress-6.2/rest-api.php index 35a7a6178fd4d..023a79e3db95f 100644 --- a/lib/compat/wordpress-6.2/rest-api.php +++ b/lib/compat/wordpress-6.2/rest-api.php @@ -13,3 +13,73 @@ function gutenberg_register_rest_block_pattern_categories() { $block_patterns->register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_rest_block_pattern_categories' ); + +/** + * Registers the block pattern directory. + */ +function gutenberg_register_rest_pattern_directory() { + $pattern_directory_controller = new Gutenberg_REST_Pattern_Directory_Controller_6_2(); + $pattern_directory_controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_rest_pattern_directory' ); + +/** + * Add extra collection params to pattern directory requests. + * + * @param array $query_params JSON Schema-formatted collection parameters. + * @return array Updated parameters. + */ +function gutenberg_pattern_directory_collection_params_6_2( $query_params ) { + $query_params['page'] = array( + 'description' => __( 'Current page of the collection.', 'gutenberg' ), + 'type' => 'integer', + 'default' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + 'minimum' => 1, + ); + + $query_params['per_page'] = array( + 'description' => __( 'Maximum number of items to be returned in result set.', 'gutenberg' ), + 'type' => 'integer', + 'default' => 100, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $query_params['offset'] = array( + 'description' => __( 'Offset the result set by a specific number of items.', 'gutenberg' ), + 'type' => 'integer', + ); + + $query_params['order'] = array( + 'description' => __( 'Order sort attribute ascending or descending.', 'gutenberg' ), + 'type' => 'string', + 'default' => 'desc', + 'enum' => array( 'asc', 'desc' ), + ); + + $query_params['orderby'] = array( + 'description' => __( 'Sort collection by post attribute.', 'gutenberg' ), + 'type' => 'string', + 'default' => 'date', + 'enum' => array( + 'author', + 'date', + 'id', + 'include', + 'modified', + 'parent', + 'relevance', + 'slug', + 'include_slugs', + 'title', + 'favorite_count', + ), + ); + + return $query_params; +} +add_filter( 'rest_pattern_directory_collection_params', 'gutenberg_pattern_directory_collection_params_6_2' ); diff --git a/lib/load.php b/lib/load.php index c8c03b4c51df2..70dbf94453525 100644 --- a/lib/load.php +++ b/lib/load.php @@ -37,7 +37,7 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.0 compat. require_once __DIR__ . '/compat/wordpress-6.0/class-gutenberg-rest-global-styles-controller.php'; - require_once __DIR__ . '/compat/wordpress-6.0/class-gutenberg-rest-pattern-directory-controller.php'; + require_once __DIR__ . '/compat/wordpress-6.0/class-gutenberg-rest-pattern-directory-controller-6-0.php'; require_once __DIR__ . '/compat/wordpress-6.0/class-gutenberg-rest-edit-site-export-controller.php'; if ( ! class_exists( 'WP_REST_Block_Pattern_Categories_Controller' ) ) { require_once __DIR__ . '/compat/wordpress-6.0/class-wp-rest-block-pattern-categories-controller.php'; @@ -51,6 +51,7 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.2 compat. require_once __DIR__ . '/compat/wordpress-6.2/class-gutenberg-rest-block-pattern-categories-controller.php'; + require_once __DIR__ . '/compat/wordpress-6.2/class-gutenberg-rest-pattern-directory-controller-6-2.php'; require_once __DIR__ . '/compat/wordpress-6.2/rest-api.php'; require_once __DIR__ . '/compat/wordpress-6.2/block-patterns.php'; diff --git a/phpunit/class-wp-rest-pattern-directory-controller-test.php b/phpunit/class-wp-rest-pattern-directory-controller-test.php new file mode 100644 index 0000000000000..bf3bb8c4bf188 --- /dev/null +++ b/phpunit/class-wp-rest-pattern-directory-controller-test.php @@ -0,0 +1,234 @@ +user->create( + array( + 'role' => 'contributor', + ) + ); + + self::$http_request_urls = array(); + + static::$controller = new WP_REST_Pattern_Directory_Controller(); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$contributor_id ); + } + + /** + * Clear the captured request URLs after each test. + */ + public function tear_down() { + self::$http_request_urls = array(); + parent::tear_down(); + } + + /** + * Tests if the provided query args are passed through to the wp.org API. + * + * @dataProvider data_get_items_query_args + * + * @covers WP_REST_Pattern_Directory_Controller::get_items + * + * @since 6.2.0 + * + * @param string $param Query parameter name (ex, page). + * @param mixed $value Query value to test. + * @param bool $is_error Whether this value should error or not. + * @param mixed $expected Expected value (or expected error code). + */ + public function test_get_items_query_args( $param, $value, $is_error, $expected ) { + wp_set_current_user( self::$contributor_id ); + self::capture_http_urls(); + + $request = new WP_REST_Request( 'GET', '/wp/v2/pattern-directory/patterns' ); + if ( $value ) { + $request->set_query_params( array( $param => $value ) ); + } + + $response = rest_do_request( $request ); + $data = $response->get_data(); + if ( $is_error ) { + $this->assertSame( $expected, $data['code'] ); + $this->assertStringContainsString( $param, $data['message'] ); + } else { + $this->assertCount( 1, self::$http_request_urls ); + $this->assertStringContainsString( $param . '=' . $expected, self::$http_request_urls[0] ); + } + } + + /** + * @since 6.2.0 + */ + public function data_get_items_query_args() { + return array( + 'per_page default' => array( 'per_page', false, false, 100 ), + 'per_page custom-1' => array( 'per_page', 5, false, 5 ), + 'per_page custom-2' => array( 'per_page', 50, false, 50 ), + 'per_page invalid-1' => array( 'per_page', 200, true, 'rest_invalid_param' ), + 'per_page invalid-2' => array( 'per_page', 'abc', true, 'rest_invalid_param' ), + + 'page default' => array( 'page', false, false, 1 ), + 'page custom' => array( 'page', 5, false, 5 ), + 'page invalid' => array( 'page', 'abc', true, 'rest_invalid_param' ), + + 'offset custom' => array( 'offset', 5, false, 5 ), + 'offset invalid-1' => array( 'offset', 'abc', true, 'rest_invalid_param' ), + + 'order default' => array( 'order', false, false, 'desc' ), + 'order custom' => array( 'order', 'asc', false, 'asc' ), + 'order invalid-1' => array( 'order', 10, true, 'rest_invalid_param' ), + 'order invalid-2' => array( 'order', 'fake', true, 'rest_invalid_param' ), + + 'orderby default' => array( 'orderby', false, false, 'date' ), + 'orderby custom-1' => array( 'orderby', 'title', false, 'title' ), + 'orderby custom-2' => array( 'orderby', 'date', false, 'date' ), + 'orderby custom-3' => array( 'orderby', 'favorite_count', false, 'favorite_count' ), + 'orderby invalid-1' => array( 'orderby', 10, true, 'rest_invalid_param' ), + 'orderby invalid-2' => array( 'orderby', 'fake', true, 'rest_invalid_param' ), + ); + } + + /** + * Attach a filter to capture requested wp.org URL. + * + * @since 6.2.0 + */ + private static function capture_http_urls() { + add_filter( + 'pre_http_request', + function ( $preempt, $args, $url ) { + if ( 'api.wordpress.org' !== wp_parse_url( $url, PHP_URL_HOST ) ) { + return $preempt; + } + + self::$http_request_urls[] = $url; + + // Return a response to prevent external API request. + $response = array( + 'headers' => array(), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => '[]', + 'cookies' => array(), + 'filename' => null, + ); + + return $response; + }, + 10, + 3 + ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_register_routes() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item() { + // Controller does not implement get_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not implement create_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Controller does not implement update_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not implement delete_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // The controller's schema is hardcoded, so tests would not be meaningful. + } + +}