diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 03860a915c..28187154e7 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -337,6 +337,10 @@ function create_initial_rest_routes() { $site_health = WP_Site_Health::get_instance(); $controller = new WP_REST_Site_Health_Controller( $site_health ); $controller->register_routes(); + + // URL Details. + $controller = new WP_REST_URL_Details_Controller(); + $controller->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php new file mode 100644 index 0000000000..d89a420e8e --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php @@ -0,0 +1,630 @@ +namespace = 'wp-block-editor/v1'; + $this->rest_base = 'url-details'; + } + + /** + * Registers the necessary REST API routes. + * + * @since 5.9.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'parse_url_details' ), + 'args' => array( + 'url' => array( + 'required' => true, + 'description' => __( 'The URL to process.' ), + 'validate_callback' => 'wp_http_validate_url', + 'sanitize_callback' => 'esc_url_raw', + 'type' => 'string', + 'format' => 'uri', + ), + ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'schema' => array( $this, 'get_public_item_schema' ), + ), + ) + ); + } + + /** + * Retrieves the item's schema, conforming to JSON Schema. + * + * @since 5.9.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'url-details', + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'description' => __( 'The contents of the element from the URL.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'icon' => array( + 'description' => __( 'The favicon image link of the <link rel="icon"> element from the URL.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'description' => array( + 'description' => __( 'The content of the <meta name="description"> element from the URL.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'image' => array( + 'description' => __( 'The OG image link of the <meta property="og:image"> or <meta property="og:image:url"> element from the URL.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the contents of the <title> tag from the HTML response. + * + * @since 5.9.0 + * + * @param WP_REST_REQUEST $request Full details about the request. + * @return WP_REST_Response|WP_Error The parsed details as a response object, or an error. + */ + public function parse_url_details( $request ) { + $url = untrailingslashit( $request['url'] ); + + if ( empty( $url ) ) { + return new WP_Error( 'rest_invalid_url', __( 'Invalid URL' ), array( 'status' => 404 ) ); + } + + // Transient per URL. + $cache_key = $this->build_cache_key_for_url( $url ); + + // Attempt to retrieve cached response. + $cached_response = $this->get_cache( $cache_key ); + + if ( ! empty( $cached_response ) ) { + $remote_url_response = $cached_response; + } else { + $remote_url_response = $this->get_remote_url( $url ); + + // Exit if we don't have a valid body or it's empty. + if ( is_wp_error( $remote_url_response ) || empty( $remote_url_response ) ) { + return $remote_url_response; + } + + // Cache the valid response. + $this->set_cache( $cache_key, $remote_url_response ); + } + + $html_head = $this->get_document_head( $remote_url_response ); + $meta_elements = $this->get_meta_with_content_elements( $html_head ); + + $data = $this->add_additional_fields_to_object( + array( + 'title' => $this->get_title( $html_head ), + 'icon' => $this->get_icon( $html_head, $url ), + 'description' => $this->get_description( $meta_elements ), + 'image' => $this->get_image( $meta_elements, $url ), + ), + $request + ); + + // Wrap the data in a response object. + $response = rest_ensure_response( $data ); + + /** + * Filters the URL data for the response. + * + * @param WP_REST_Response $response The response object. + * @param string $url The requested URL. + * @param WP_REST_Request $request Request object. + * @param array $remote_url_response HTTP response body from the remote URL. + */ + return apply_filters( 'rest_prepare_url_details', $response, $url, $request, $remote_url_response ); + } + + /** + * Checks whether a given request has permission to read remote urls. + * + * @since 5.9.0 + * + * @return WP_Error|bool True if the request has access, or WP_Error object. + */ + public function permissions_check() { + if ( current_user_can( 'edit_posts' ) ) { + return true; + } + + foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) { + if ( current_user_can( $post_type->cap->edit_posts ) ) { + return true; + } + } + + return new WP_Error( + 'rest_cannot_view_url_details', + __( 'Sorry, you are not allowed to process remote urls.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Retrieves the document title from a remote URL. + * + * @since 5.9.0 + * + * @param string $url The website url whose HTML we want to access. + * @return string|WP_Error The HTTP response from the remote URL, or an error. + */ + private function get_remote_url( $url ) { + + /* + * Provide a modified UA string to workaround web properties which block WordPress "Pingbacks". + * Why? The UA string used for pingback requests contains `WordPress/` which is very similar + * to that used as the default UA string by the WP HTTP API. Therefore requests from this + * REST endpoint are being unintentionally blocked as they are misidentified as pingback requests. + * By slightly modifying the UA string, but still retaining the "WordPress" identification (via "WP") + * we are able to work around this issue. + * Example UA string: `WP-URLDetails/5.9-alpha-51389 (+http://localhost:8888)`. + */ + $modified_user_agent = 'WP-URLDetails/' . get_bloginfo( 'version' ) . ' (+' . get_bloginfo( 'url' ) . ')'; + + $args = array( + 'limit_response_size' => 150 * KB_IN_BYTES, + 'user-agent' => $modified_user_agent, + ); + + /** + * Filters the HTTP request args for URL data retrieval. + * + * Can be used to adjust response size limit and other WP_Http::request args. + * + * @param array $args Arguments used for the HTTP request + * @param string $url The attempted URL. + */ + $args = apply_filters( 'rest_url_details_http_request_args', $args, $url ); + + $response = wp_safe_remote_get( $url, $args ); + + if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) { + // Not saving the error response to cache since the error might be temporary. + return new WP_Error( 'no_response', __( 'URL not found. Response returned a non-200 status code for this URL.' ), array( 'status' => WP_Http::NOT_FOUND ) ); + } + + $remote_body = wp_remote_retrieve_body( $response ); + + if ( empty( $remote_body ) ) { + return new WP_Error( 'no_content', __( 'Unable to retrieve body from response at this URL.' ), array( 'status' => WP_Http::NOT_FOUND ) ); + } + + return $remote_body; + } + + /** + * Parses the `<title>` contents from the provided HTML. + * + * @since 5.9.0 + * + * @param string $html The HTML from the remote website at URL. + * @return string The title tag contents on success, or an empty string. + */ + private function get_title( $html ) { + $pattern = '#<title[^>]*>(.*?)<\s*/\s*title>#is'; + preg_match( $pattern, $html, $match_title ); + + $title = ! empty( $match_title[1] ) && is_string( $match_title[1] ) ? trim( $match_title[1] ) : ''; + + if ( empty( $title ) ) { + return ''; + } + + return $this->prepare_metadata_for_output( $title ); + } + + /** + * Parses the site icon from the provided HTML. + * + * @since 5.9.0 + * + * @param string $html The HTML from the remote website at URL. + * @param string $url The target website URL. + * @return string The icon URI on success, or an empty string. + */ + private function get_icon( $html, $url ) { + // Grab the icon's link element. + $pattern = '#<link\s[^>]*rel=(?:[\"\']??)\s*(?:icon|shortcut icon|icon shortcut)\s*(?:[\"\']??)[^>]*\/?>#isU'; + preg_match( $pattern, $html, $element ); + $element = ! empty( $element[0] ) && is_string( $element[0] ) ? trim( $element[0] ) : ''; + if ( empty( $element ) ) { + return ''; + } + + // Get the icon's href value. + $pattern = '#href=([\"\']??)([^\" >]*?)\\1[^>]*#isU'; + preg_match( $pattern, $element, $icon ); + $icon = ! empty( $icon[2] ) && is_string( $icon[2] ) ? trim( $icon[2] ) : ''; + if ( empty( $icon ) ) { + return ''; + } + + // If the icon is a data URL, return it. + $parsed_icon = parse_url( $icon ); + if ( isset( $parsed_icon['scheme'] ) && 'data' === $parsed_icon['scheme'] ) { + return $icon; + } + + // Attempt to convert relative URLs to absolute. + if ( ! is_string( $url ) || '' === $url ) { + return $icon; + } + $parsed_url = parse_url( $url ); + if ( isset( $parsed_url['scheme'] ) && isset( $parsed_url['host'] ) ) { + $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/'; + $icon = WP_Http::make_absolute_url( $icon, $root_url ); + } + + return $icon; + } + + /** + * Parses the meta description from the provided HTML. + * + * @since 5.9.0 + * + * @param array $meta_elements { + * A multi-dimensional indexed array on success, or empty array. + * + * @type string[] 0 Meta elements with a content attribute. + * @type string[] 1 Content attribute's opening quotation mark. + * @type string[] 2 Content attribute's value for each meta element. + * } + * @return string The meta description contents on success, or an empty string. + */ + private function get_description( $meta_elements ) { + // Bail out if there are no meta elements. + if ( empty( $meta_elements[0] ) ) { + return ''; + } + + $description = $this->get_metadata_from_meta_element( $meta_elements, 'name', '(?:description|og:description)' ); + + // Bail out if description not found. + if ( '' === $description ) { + return ''; + } + + return $this->prepare_metadata_for_output( $description ); + } + + /** + * Parses the Open Graph Image from the provided HTML. + * + * See: https://ogp.me/. + * + * @since 5.9.0 + * + * @param array $meta_elements { + * A multi-dimensional indexed array on success, or empty array. + * + * @type string[] 0 Meta elements with a content attribute. + * @type string[] 1 Content attribute's opening quotation mark. + * @type string[] 2 Content attribute's value for each meta element. + * } + * @param string $url The target website URL. + * @return string The OG image on success, or empty string. + */ + private function get_image( $meta_elements, $url ) { + $image = $this->get_metadata_from_meta_element( $meta_elements, 'property', '(?:og:image|og:image:url)' ); + + // Bail out if image not found. + if ( '' === $image ) { + return ''; + } + + // Attempt to convert relative URLs to absolute. + $parsed_url = parse_url( $url ); + if ( isset( $parsed_url['scheme'] ) && isset( $parsed_url['host'] ) ) { + $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/'; + $image = WP_Http::make_absolute_url( $image, $root_url ); + } + + return $image; + } + + /** + * Prepares the metadata by: + * - stripping all HTML tags and tag entities. + * - converting non-tag entities into characters. + * + * @since 5.9.0 + * + * @param string $metadata The metadata content to prepare. + * @return string The prepared metadata. + */ + private function prepare_metadata_for_output( $metadata ) { + $metadata = html_entity_decode( $metadata, ENT_QUOTES, get_bloginfo( 'charset' ) ); + $metadata = wp_strip_all_tags( $metadata ); + return $metadata; + } + + /** + * Utility function to build cache key for a given URL. + * + * @since 5.9.0 + * + * @param string $url The URL for which to build a cache key. + * @return string The cache key. + */ + private function build_cache_key_for_url( $url ) { + return 'g_url_details_response_' . md5( $url ); + } + + /** + * Utility function to retrieve a value from the cache at a given key. + * + * @since 5.9.0 + * + * @param string $key The cache key. + * @return mixed The value from the cache. + */ + private function get_cache( $key ) { + return get_transient( $key ); + } + + /** + * Utility function to cache a given data set at a given cache key. + * + * @since 5.9.0 + * + * @param string $key The cache key under which to store the value. + * @param string $data The data to be stored at the given cache key. + * @return bool True when transient set. False if fails. + */ + private function set_cache( $key, $data = '' ) { + $ttl = HOUR_IN_SECONDS; + + /** + * Filters the cache expiration. + * + * Can be used to adjust the time until expiration in seconds for the cache + * of the data retrieved for the given URL. + * + * @param int $ttl the time until cache expiration in seconds. + */ + $cache_expiration = apply_filters( 'rest_url_details_cache_expiration', $ttl ); + + return set_transient( $key, $data, $cache_expiration ); + } + + /** + * Retrieves the `<head>` section. + * + * @since 5.9.0 + * + * @param string $html The string of HTML to parse. + * @return string The `<head>..</head>` section on success, or original HTML. + */ + private function get_document_head( $html ) { + $head_html = $html; + + // Find the opening `<head>` tag. + $head_start = strpos( $html, '<head' ); + if ( false === $head_start ) { + // Didn't find it. Return the original HTML. + return $html; + } + + // Find the closing `</head>` tag. + $head_end = strpos( $head_html, '</head>' ); + if ( false === $head_end ) { + // Didn't find it. Find the opening `<body>` tag. + $head_end = strpos( $head_html, '<body' ); + + // Didn't find it. Return the original HTML. + if ( false === $head_end ) { + return $html; + } + } + + // Extract the HTML from opening tag to the closing tag. Then add the closing tag. + $head_html = substr( $head_html, $head_start, $head_end ); + $head_html .= '</head>'; + + return $head_html; + } + + /** + * Gets all the `<meta>` elements that have a `content` attribute. + * + * @since 5.9.0 + * + * @param string $html The string of HTML to be parsed. + * @return array { + * A multi-dimensional indexed array on success, or empty array. + * + * @type string[] 0 Meta elements with a content attribute. + * @type string[] 1 Content attribute's opening quotation mark. + * @type string[] 2 Content attribute's value for each meta element. + * } + */ + private function get_meta_with_content_elements( $html ) { + /* + * Parse all meta elements with a content attribute. + * + * Why first search for the content attribute rather than directly searching for name=description element? + * tl;dr The content attribute's value will be truncated when it contains a > symbol. + * + * The content attribute's value (i.e. the description to get) can have HTML in it and be well-formed as + * it's a string to the browser. Imagine what happens when attempting to match for the name=description + * first. Hmm, if a > or /> symbol is in the content attribute's value, then it terminates the match + * as the element's closing symbol. But wait, it's in the content attribute and is not the end of the + * element. This is a limitation of using regex. It can't determine "wait a minute this is inside of quotation". + * If this happens, what gets matched is not the entire element or all of the content. + * + * Why not search for the name=description and then content="(.*)"? + * The attribute order could be opposite. Plus, additional attributes may exist including being between + * the name and content attributes. + * + * Why not lookahead? + * Lookahead is not constrained to stay within the element. The first <meta it finds may not include + * the name or content, but rather could be from a different element downstream. + */ + $pattern = '#<meta\s' . + + /* + * Alows for additional attributes before the content attribute. + * Searches for anything other than > symbol. + */ + '[^>]*' . + + /* + * Find the content attribute. When found, capture its value (.*). + * + * Allows for (a) single or double quotes and (b) whitespace in the value. + * + * Why capture the opening quotation mark, i.e. (["\']), and then backreference, + * i.e \1, for the closing quotation mark? + * To ensure the closing quotation mark matches the opening one. Why? Attribute values + * can contain quotation marks, such as an apostrophe in the content. + */ + 'content=(["\']??)(.*)\1' . + + /* + * Allows for additional attributes after the content attribute. + * Searches for anything other than > symbol. + */ + '[^>]*' . + + /* + * \/?> searches for the closing > symbol, which can be in either /> or > format. + * # ends the pattern. + */ + '\/?>#' . + + /* + * These are the options: + * - i : case insensitive + * - s : allows newline characters for the . match (needed for multiline elements) + * - U means non-greedy matching + */ + 'isU'; + + preg_match_all( $pattern, $html, $elements ); + + return $elements; + } + + /** + * Gets the metadata from a target meta element. + * + * @since 5.9.0 + * + * @param array $meta_elements { + * A multi-dimensional indexed array on success, or empty array. + * + * @type string[] 0 Meta elements with a content attribute. + * @type string[] 1 Content attribute's opening quotation mark. + * @type string[] 2 Content attribute's value for each meta element. + * } + * @param string $attr Attribute that identifies the element with the target metadata. + * @param string $attr_value The attribute's value that identifies the element with the target metadata. + * @return string The metadata on success, or an empty string. + */ + private function get_metadata_from_meta_element( $meta_elements, $attr, $attr_value ) { + // Bail out if there are no meta elements. + if ( empty( $meta_elements[0] ) ) { + return ''; + } + + $metadata = ''; + $pattern = '#' . + /* + * Target this attribute and value to find the metadata element. + * + * Allows for (a) no, single, double quotes and (b) whitespace in the value. + * + * Why capture the opening quotation mark, i.e. (["\']), and then backreference, + * i.e \1, for the closing quotation mark? + * To ensure the closing quotation mark matches the opening one. Why? Attribute values + * can contain quotation marks, such as an apostrophe in the content. + */ + $attr . '=([\"\']??)\s*' . $attr_value . '\s*\1' . + + /* + * These are the options: + * - i : case insensitive + * - s : allows newline characters for the . match (needed for multiline elements) + * - U means non-greedy matching + */ + '#isU'; + + // Find the metdata element. + foreach ( $meta_elements[0] as $index => $element ) { + preg_match( $pattern, $element, $match ); + + // This is not the metadata element. Skip it. + if ( empty( $match ) ) { + continue; + } + + /* + * Found the metadata element. + * Get the metadata from its matching content array. + */ + if ( isset( $meta_elements[2][ $index ] ) && is_string( $meta_elements[2][ $index ] ) ) { + $metadata = trim( $meta_elements[2][ $index ] ); + } + + break; + } + + return $metadata; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 99e77f3868..c9b82dc39e 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -276,6 +276,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-sidebars-controller require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-widget-types-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-widgets-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-templates-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-url-details-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; diff --git a/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php b/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php new file mode 100644 index 0000000000..db4d229da3 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php @@ -0,0 +1,1192 @@ +<?php +/** + * WP_REST_URL_Details_Controller tests. + * + * @package WordPress + * @subpackage REST_API + * @since 5.9.0 + */ + +/** + * Tests for WP_REST_URL_Details_Controller. + * + * @since 5.9.0 + * + * @covers WP_REST_URL_Details_Controller + * + * @group url-details + * @group restapi + */ +class Tests_REST_WpRestUrlDetailsController extends WP_Test_REST_Controller_Testcase { + + /** + * Admin user ID. + * + * @since 5.9.0 + * + * @var int + */ + protected static $admin_id; + + /** + * Subscriber user ID. + * + * @since 5.5.0 + * + * @var int + */ + protected static $subscriber_id; + + /** + * The REST API route for the block renderer. + * + * @since 5.9.0 + * + * @var string + */ + const REQUEST_ROUTE = '/wp-block-editor/v1/url-details'; + + /** + * URL placeholder. + * + * @since 5.9.0 + * + * @var string + */ + const URL_PLACEHOLDER = 'https://placeholder-site.com'; + + /** + * Array of request args. + * + * @var array + */ + protected $request_args = array(); + + /** + * Set up class test fixtures. + * + * @since 5.9.0 + * + * @param WP_UnitTest_Factory $factory WordPress unit test factory. + */ + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$subscriber_id = $factory->user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$subscriber_id ); + } + + public function set_up() { + parent::set_up(); + + add_filter( 'pre_http_request', array( $this, 'mock_success_request_to_remote_url' ), 10, 3 ); + + // Disables usage of cache during major of tests. + add_filter( 'pre_transient_' . $this->get_transient_name(), '__return_null' ); + } + + public function tear_down() { + $this->request_args = array(); + parent::tear_down(); + } + + /** + * @covers WP_REST_URL_Details_Controller::get_routes + * + * @ticket 54358 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( static::REQUEST_ROUTE, $routes ); + } + + /** + * @covers WP_REST_URL_Details_Controller::parse_url_details + * + * @ticket 54358 + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + /* + * Note the data in the subset comes from the fixture HTML returned by + * the filter `pre_http_request` (see this class's `set_up` method). + */ + $this->assertSame( + array( + 'title' => 'Example Website — - with encoded content.', + 'icon' => 'https://placeholder-site.com/favicon.ico?querystringaddedfortesting', + 'description' => 'Example description text here. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore.', + 'image' => 'https://placeholder-site.com/images/home/screen-themes.png?3', + ), + $data + ); + } + + /** + * @covers WP_REST_URL_Details_Controller::permissions_check + * + * @ticket 54358 + */ + public function test_get_items_fails_for_unauthenticated_user() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( WP_Http::UNAUTHORIZED, $response->get_status(), 'Response status is not ' . WP_Http::UNAUTHORIZED ); + + $this->assertSame( 'rest_cannot_view_url_details', $data['code'], 'Response "code" is not "rest_cannot_view_url_details"' ); + + $expected = 'you are not allowed to process remote urls'; + $this->assertStringContainsString( $expected, strtolower( $data['message'] ), 'Response "message" does not contain "' . $expected . '"' ); + } + + /** + * @covers WP_REST_URL_Details_Controller::permissions_check + * + * @ticket 54358 + */ + public function test_get_items_fails_for_user_with_insufficient_permissions() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( WP_Http::FORBIDDEN, $response->get_status(), 'Response status is not ' . WP_Http::FORBIDDEN ); + + $this->assertSame( 'rest_cannot_view_url_details', $data['code'], 'Response "code" is not "rest_cannot_view_url_details"' ); + + $expected = 'you are not allowed to process remote urls'; + $this->assertStringContainsString( $expected, strtolower( $data['message'] ), 'Response "message" does not contain "' . $expected . '"' ); + } + + /** + * @dataProvider data_get_items_fails_for_invalid_url + * + * @covers WP_REST_URL_Details_Controller::parse_url_details + * + * @ticket 54358 + * + * @param mixed $invalid_url Given invalid URL to test. + */ + public function test_get_items_fails_for_invalid_url( $invalid_url ) { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => $invalid_url, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status(), 'Response status is not ' . WP_Http::BAD_REQUEST ); + + $this->assertSame( 'rest_invalid_param', $data['code'], 'Response "code" is not "rest_invalid_param"' ); + + $expected = 'invalid parameter(s): url'; + $this->assertStringContainsString( $expected, strtolower( $data['message'] ), 'Response "message" does not contain "' . $expected . '"' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_items_fails_for_invalid_url() { + return array( + 'empty string' => array( '' ), + 'numeric' => array( 1234456 ), + 'invalid scheme' => array( 'invalid.proto://wordpress.org' ), + ); + } + + /** + * @covers WP_REST_URL_Details_Controller::parse_url_details + * + * @ticket 54358 + */ + public function test_get_items_fails_for_url_which_returns_a_non_200_status_code() { + // Force HTTP request to remote site to fail. + remove_filter( 'pre_http_request', array( $this, 'mock_success_request_to_remote_url' ), 10 ); + add_filter( 'pre_http_request', array( $this, 'mock_failed_request_to_remote_url' ), 10, 3 ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, // note: `pre_http_request` causes request to 404. + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 404, $response->get_status(), 'Response status is not 404' ); + + $this->assertSame( 'no_response', $data['code'], 'Response "code" is not "no_response"' ); + + $this->assertStringContainsString( 'not found', strtolower( $data['message'] ), 'Response "message" does not contain "not found"' ); + } + + /** + * @covers WP_REST_URL_Details_Controller::parse_url_details + * + * @ticket 54358 + */ + public function test_get_items_fails_for_url_which_returns_empty_body_for_success() { + // Force HTTP request to remote site to return an empty body in response. + remove_filter( 'pre_http_request', array( $this, 'mock_success_request_to_remote_url' ) ); + add_filter( 'pre_http_request', array( $this, 'mock_request_to_remote_url_with_empty_body_response' ), 10, 3 ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, // note: `pre_http_request` causes request to 404. + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 404, $response->get_status(), 'Response status is not 404' ); + + $this->assertSame( 'no_content', $data['code'], 'Response "code" is not "no_content"' ); + + $expected = strtolower( 'Unable to retrieve body from response at this URL' ); + $this->assertStringContainsString( $expected, strtolower( $data['message'] ), 'Response "message" does not contain "' . $expected . '"' ); + + } + + /** + * @covers WP_REST_URL_Details_Controller::parse_url_details + * + * @ticket 54358 + */ + public function test_can_filter_http_request_args_via_filter() { + wp_set_current_user( self::$admin_id ); + + add_filter( + 'rest_url_details_http_request_args', + static function( $args, $url ) { + return array_merge( + $args, + array( + 'timeout' => 27, // modify default timeout. + 'body' => $url, // add new and allow to assert on $url arg passed. + ) + ); + }, + 10, + 2 + ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, + ) + ); + + rest_get_server()->dispatch( $request ); + + // Check the args were filtered as expected. + $this->assertArrayHasKey( 'timeout', $this->request_args, 'Request args do not contain a "timeout" key' ); + $this->assertArrayHasKey( 'limit_response_size', $this->request_args, 'Request args do not contain a "limit_response_size" key' ); + $this->assertArrayHasKey( 'body', $this->request_args, 'Request args do not contain a "body" key' ); + $this->assertSame( 27, $this->request_args['timeout'], 'Request args "timeout" is not 27' ); + $this->assertSame( 153600, $this->request_args['limit_response_size'], 'Request args "limit_response_size" is not 153600' ); + $this->assertSame( static::URL_PLACEHOLDER, $this->request_args['body'], 'Request args "body" is not "' . static::URL_PLACEHOLDER . '"' ); + } + + /** + * @covers WP_REST_URL_Details_Controller::parse_url_details + * + * @ticket 54358 + */ + public function test_will_return_from_cache_if_populated() { + $transient_name = $this->get_transient_name(); + remove_filter( "pre_transient_{$transient_name}", '__return_null' ); + + // Force cache to return a known value as the remote URL http response body. + add_filter( + "pre_transient_{$transient_name}", + static function() { + return '<html><head><title>This value from cache.'; + } + ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + // Data should be that from cache not from mocked network response. + $this->assertStringContainsString( 'This value from cache', $data['title'] ); + } + + /** + * @covers WP_REST_URL_Details_Controller::parse_url_details + * + * @ticket 54358 + */ + public function test_allows_filtering_data_retrieved_for_a_given_url() { + add_filter( + 'rest_prepare_url_details', + static function( $response ) { + + $data = $response->get_data(); + + $response->set_data( + array_merge( + $data, + array( + 'og_title' => 'This was manually added to the data via filter', + ) + ) + ); + + return $response; + + } + ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + /* + * Instead of the default data retrieved we expect to see the modified + * data we provided via the filter. + */ + $expected = 'Example Website — - with encoded content.'; + $this->assertSame( $expected, $data['title'], 'Response "title" is not "' . $expected . '"' ); + $expected = 'This was manually added to the data via filter'; + $this->assertSame( $expected, $data['og_title'], 'Response "og_title" is not "' . $expected . '"' ); + } + + /** + * @covers WP_REST_URL_Details_Controller::parse_url_details + * + * @ticket 54358 + */ + public function test_allows_filtering_response() { + /* + * Filter the response to known set of values changing only + * based on whether the response came from the cache or not. + */ + add_filter( + 'rest_prepare_url_details', + static function( $response, $url ) { + return new WP_REST_Response( + array( + 'status' => 418, + 'response' => "Response for URL $url altered via rest_prepare_url_details filter", + 'body_response' => array(), + ) + ); + }, + 10, + 3 + ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE ); + $request->set_query_params( + array( + 'url' => static::URL_PLACEHOLDER, + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertSame( 418, $data['status'], 'Response "status" is not 418' ); + + $expected = 'Response for URL https://placeholder-site.com altered via rest_prepare_url_details filter'; + $this->assertSame( $expected, $data['response'], 'Response "response" is not "' . $expected . '"' ); + } + + /** + * @covers WP_REST_URL_Details_Controller::get_item_schema + * + * @ticket 54358 + */ + public function test_get_item_schema() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'OPTIONS', static::REQUEST_ROUTE ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $endpoint = $data['endpoints'][0]; + + $this->assertArrayHasKey( 'url', $endpoint['args'], 'Endpoint "args" does not contain a "url" key' ); + $this->assertSame( + array( + 'description' => 'The URL to process.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + $endpoint['args']['url'], + 'Response endpoint "[args][url]" does not contain expected schema' + ); + } + + /** + * @dataProvider data_get_title + * + * @covers WP_REST_URL_Details_Controller::get_title + * + * @ticket 54358 + * + * @param string $html Given HTML string. + * @param string $expected Expected found title. + */ + public function test_get_title( $html, $expected ) { + $controller = new WP_REST_URL_Details_Controller(); + $method = $this->get_reflective_method( 'get_title' ); + + $actual = $method->invoke( + $controller, + $this->wrap_html_in_doc( $html ) + ); + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_title() { + return array( + + // Happy path for default. + 'default' => array( + 'Testing <title>', + 'Testing', + ), + 'with attributes' => array( + 'Testing <title>', + 'Testing', + ), + 'with text whitespace' => array( + ' Testing <title> ', + 'Testing', + ), + 'with whitespace in opening tag' => array( + 'Testing <title>: with whitespace in opening tag', + 'Testing : with whitespace in opening tag', + ), + 'when whitepace in closing tag' => array( + 'Testing <title>: with whitespace in closing tag</ title>', + 'Testing : with whitespace in closing tag', + ), + 'with other elements' => array( + '<meta name="viewport" content="width=device-width"> + <title>Testing <title> + ', + 'Testing', + ), + 'multiline' => array( + ' + Testing <title> + ', + 'Testing', + ), + + // Unhappy paths. + 'when opening tag is malformed' => array( + '< title>Testing <title>: when opening tag is invalid', + '', + ), + ); + } + + /** + * @dataProvider data_get_icon + * + * @covers WP_REST_URL_Details_Controller::get_icon + * + * @ticket 54358 + * + * @param string $html Given HTML string. + * @param string $expected Expected found icon. + * @param string $target_url Optional. Target URL. Default 'https://wordpress.org'. + */ + public function test_get_icon( $html, $expected, $target_url = 'https://wordpress.org' ) { + $controller = new WP_REST_URL_Details_Controller(); + $method = $this->get_reflective_method( 'get_icon' ); + + $actual = $method->invoke( + $controller, + $this->wrap_html_in_doc( $html ), + $target_url + ); + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_icon() { + return array( + + // Happy path for default. + 'default' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'default with no closing whitespace' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'default without self-closing' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'default with href first' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'default with type last' => array( + '', + 'https://wordpress.org/favicon.png', + ), + 'default with type first' => array( + '', + 'https://wordpress.org/favicon.png', + ), + 'default with single quotes' => array( + '', + 'https://wordpress.org/favicon.png', + ), + + // Happy paths. + 'with query string' => array( + '', + 'https://wordpress.org/favicon.ico?somequerystring=foo&another=bar', + ), + 'with another link' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'with multiple links' => array( + ' + + ', + 'https://wordpress.org/favicon.ico', + ), + 'relative url' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'relative url no slash' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'relative url with path' => array( + '', + 'https://wordpress.org/favicon.ico', + 'https://wordpress.org/my/path/here/', + ), + 'rel reverse order' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'rel icon only' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'rel icon only with whitespace' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'multiline attributes' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'multiline attributes in reverse order' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'multiline attributes with type' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'multiline with type first' => array( + '', + 'https://wordpress.org/favicon.ico', + ), + 'with data URL x-icon type' => array( + '', + 'data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII=', + ), + 'with data URL png type' => array( + '', + 'data:image/png;base64,iVBORw0KGgo=', + ), + + // Unhappy paths. + 'empty rel' => array( + '', + '', + ), + 'empty href' => array( + '', + '', + ), + 'no rel' => array( + '', + '', + ), + 'link to external stylesheet' => array( + '', + '', + 'https://example.com', + ), + 'multiline with no href' => array( + '', + '', + ), + 'multiline with no rel' => array( + '', + '', + ), + ); + } + + /** + * @dataProvider data_get_description + * + * @covers WP_REST_URL_Details_Controller::get_description + * + * @ticket 54358 + * + * @param string $html Given HTML string. + * @param string $expected Expected found icon. + */ + public function test_get_description( $html, $expected ) { + $controller = new WP_REST_URL_Details_Controller(); + + // Parse the meta elements from the given HTML. + $method = $this->get_reflective_method( 'get_meta_with_content_elements' ); + $meta_elements = $method->invoke( + $controller, + $this->wrap_html_in_doc( $html ) + ); + + $method = $this->get_reflective_method( 'get_description' ); + $actual = $method->invoke( $controller, $meta_elements ); + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_description() { + return array( + + // Happy paths. + 'default' => array( + '', + 'This is a description.', + ), + 'with whitespace' => array( + '', + 'This is a description.', + ), + 'with self-closing' => array( + '', + 'This is a description.', + ), + 'with self-closing and whitespace' => array( + '', + 'This is a description.', + ), + 'with content first' => array( + '', + 'Content is first', + ), + 'with single quotes' => array( + '', + 'with single quotes', + ), + 'with another element' => array( + '', + 'This is a description.', + ), + 'with multiple elements' => array( + ' + + + ', + 'This is a description.', + ), + 'with other attributes' => array( + '', + 'description with other attributes', + ), + 'with open graph' => array( + ' + ', + 'This is a OG description.', + ), + + // Happy paths with multiline attributes. + 'with multiline attributes' => array( + '', + 'with multiline attributes', + ), + 'with multiline attributes in reverse order' => array( + '', + 'with multiline attributes in reverse order', + ), + 'with multiline attributes and another element' => array( + ' + ', + 'with multiline attributes', + ), + 'with multiline and other attributes' => array( + '', + 'description with multiline and other attributes', + ), + + // Happy paths with HTML tags or entities in the description. + 'with HTML tags' => array( + '', + 'Description: has HTML tags', + ), + 'with content first and HTML tags' => array( + '', + 'Description: has HTML tags', + ), + 'with HTML tags and other attributes' => array( + ' array( + '', + '', + ), + 'with empty name' => array( + '', + '', + ), + 'without a name attribute' => array( + '', + '', + ), + 'without a content attribute' => array( + '', + '', + ), + ); + } + + /** + * @dataProvider data_get_image + * + * @covers WP_REST_URL_Details_Controller::get_image + * + * @ticket 54358 + * + * @param string $html Given HTML string. + * @param string $expected Expected found image. + * @param string $target_url Optional. Target URL. Default 'https://wordpress.org'. + */ + public function test_get_image( $html, $expected, $target_url = 'https://wordpress.org' ) { + $controller = new WP_REST_URL_Details_Controller(); + + // Parse the meta elements from the given HTML. + $method = $this->get_reflective_method( 'get_meta_with_content_elements' ); + $meta_elements = $method->invoke( + $controller, + $this->wrap_html_in_doc( $html ) + ); + + $method = $this->get_reflective_method( 'get_image' ); + $actual = $method->invoke( $controller, $meta_elements, $target_url ); + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_image() { + return array( + + // Happy paths. + 'default' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with whitespace' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with self-closing' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with self-closing and whitespace' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with single quotes' => array( + "", + 'https://wordpress.org/images/myimage.jpg', + ), + 'without quotes' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with url modifier' => array( + ' + ', + 'https://wordpress.org/images/url-modifier.jpg', + ), + 'with query string' => array( + '', + 'https://wordpress.org/images/withquerystring.jpg?foo=bar&bar=foo', + ), + + // Happy paths with changing attributes order or adding attributes. + 'with content first' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with other attributes' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with other og meta' => array( + ' + + + ', + 'https://wordpress.org/images/myimage.jpg', + ), + + // Happy paths with relative url. + 'with relative url' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with relative url without starting slash' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with relative url and path' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + 'https://wordpress.org/my/path/here/', + ), + + // Happy paths with multiline attributes. + 'with multiline attributes' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with multiline attributes in reverse order' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with multiline attributes and other elements' => array( + ' + + + ', + 'https://wordpress.org/images/myimage.jpg', + ), + 'with multiline and other attributes' => array( + '', + 'https://wordpress.org/images/myimage.jpg', + ), + + // Happy paths with HTML tags in the content. + 'with other og meta' => array( + ' + + + ', + 'https://wordpress.org/images/myimage.jpg', + ), + + // Unhappy paths. + 'with empty content' => array( + '', + '', + ), + 'without a property attribute' => array( + '', + '', + ), + 'without a content attribute empty property' => array( + '', + '', + ), + ); + } + + public function test_context_param() { + $this->markTestSkipped( 'Controller does not use context_param.' ); + } + + public function test_get_item() { + $this->markTestSkipped( 'Controller does not have get_item route.' ); + } + + public function test_create_item() { + $this->markTestSkipped( 'Controller does not have create_item route.' ); + } + + public function test_update_item() { + $this->markTestSkipped( 'Controller does not have update_item route.' ); + } + + public function test_delete_item() { + $this->markTestSkipped( 'Controller does not have delete_item route.' ); + } + + public function test_prepare_item() { + $this->markTestSkipped( 'Controller does not have prepare_item route.' ); + } + + /** + * Mocks the HTTP response for the `wp_safe_remote_get()` which + * would otherwise make a call to a real website. + * + * @return array faux/mocked response. + */ + public function mock_success_request_to_remote_url( $response, $args ) { + return $this->mock_request_to_remote_url( 'success', $args ); + } + + public function mock_failed_request_to_remote_url( $response, $args ) { + return $this->mock_request_to_remote_url( 'failure', $args ); + } + + public function mock_request_to_remote_url_with_empty_body_response( $response, $args ) { + return $this->mock_request_to_remote_url( 'empty_body', $args ); + } + + private function mock_request_to_remote_url( $result_type, $args ) { + $this->request_args = $args; + + $types = array( + 'success', + 'failure', + 'empty_body', + ); + + // Default to success. + if ( ! in_array( $result_type, $types, true ) ) { + $result_type = $types[0]; + } + + // Both should return 200 for the HTTP response. + $should_200 = 'success' === $result_type || 'empty_body' === $result_type; + + return array( + 'headers' => array(), + 'cookies' => array(), + 'filename' => null, + 'response' => array( 'code' => ( $should_200 ? 200 : 404 ) ), + 'status_code' => $should_200 ? 200 : 404, + 'success' => $should_200 ? 1 : 0, + 'body' => 'success' === $result_type ? $this->get_example_website() : '', + ); + } + + private function get_example_website() { + return ' + + + + + Example Website — - with encoded content. + + + + + + + + + + + + + + + + + +

Example Website

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ + '; + } + + private function wrap_html_in_doc( $html, $with_body = false ) { + $doc = ' + + + ' . $html . "\n" . ''; + + if ( $with_body ) { + $doc .= ' + +

Example Website

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ + '; + } + + return $doc; + } + + /** + * Gets the transient name. + * + * @return string + */ + private function get_transient_name() { + return 'g_url_details_response_' . md5( static::URL_PLACEHOLDER ); + } + + /** + * Get reflective access to a private/protected method on + * the WP_REST_URL_Details_Controller class. + * + * @param string $method_name Method name for which to gain access. + * @return ReflectionMethod + * @throws ReflectionException Throws an exception if method does not exist. + */ + protected function get_reflective_method( $method_name ) { + $class = new ReflectionClass( WP_REST_URL_Details_Controller::class ); + $method = $class->getMethod( $method_name ); + $method->setAccessible( true ); + return $method; + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 7852fd34af..58089b1b69 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -15,7 +15,8 @@ mockedApiResponse.Schema = { "namespaces": [ "oembed/1.0", "wp/v2", - "wp-site-health/v1" + "wp-site-health/v1", + "wp-block-editor/v1" ], "authentication": [], "routes": { @@ -7538,6 +7539,64 @@ mockedApiResponse.Schema = { } ] } + }, + "/wp-block-editor/v1": { + "namespace": "wp-block-editor/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-block-editor/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-block-editor/v1" + } + ] + } + }, + "/wp-block-editor/v1/url-details": { + "namespace": "wp-block-editor/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "url": { + "description": "The URL to process.", + "type": "string", + "format": "uri", + "required": true + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-block-editor/v1/url-details" + } + ] + } } }, "site_logo": false