diff --git a/lib/client-assets.php b/lib/client-assets.php index 7f1e3b48938bb..b9b2f10037262 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -231,7 +231,7 @@ function gutenberg_register_scripts_and_styles() { gutenberg_override_script( 'wp-api-fetch', gutenberg_url( 'build/api-fetch/index.js' ), - array( 'wp-polyfill', 'wp-hooks', 'wp-i18n' ), + array( 'wp-polyfill', 'wp-hooks', 'wp-i18n', 'wp-url' ), filemtime( gutenberg_dir_path() . 'build/api-fetch/index.js' ), true ); diff --git a/lib/rest-api.php b/lib/rest-api.php index 7506f45484ee7..35f4af5c52e77 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -290,158 +290,10 @@ function gutenberg_register_post_prepare_functions( $post_type ) { add_filter( "rest_prepare_{$post_type}", 'gutenberg_add_permalink_template_to_posts', 10, 3 ); add_filter( "rest_prepare_{$post_type}", 'gutenberg_add_block_format_to_post_content', 10, 3 ); add_filter( "rest_prepare_{$post_type}", 'gutenberg_add_target_schema_to_links', 10, 3 ); - add_filter( "rest_{$post_type}_collection_params", 'gutenberg_filter_post_collection_parameters', 10, 2 ); - add_filter( "rest_{$post_type}_query", 'gutenberg_filter_post_query_arguments', 10, 2 ); return $post_type; } add_filter( 'registered_post_type', 'gutenberg_register_post_prepare_functions' ); -/** - * Whenever a taxonomy is registered, ensure we're hooked into its WP REST API response. - * - * @param string $taxonomy The newly registered taxonomy. - */ -function gutenberg_register_taxonomy_prepare_functions( $taxonomy ) { - add_filter( "rest_{$taxonomy}_collection_params", 'gutenberg_filter_term_collection_parameters', 10, 2 ); - add_filter( "rest_{$taxonomy}_query", 'gutenberg_filter_term_query_arguments', 10, 2 ); -} -add_filter( 'registered_taxonomy', 'gutenberg_register_taxonomy_prepare_functions' ); - -/** - * Handle any necessary checks early. - * - * @param WP_HTTP_Response $response Result to send to the client. Usually a WP_REST_Response. - * @param WP_REST_Server $handler ResponseHandler instance (usually WP_REST_Server). - * @param WP_REST_Request $request Request used to generate the response. - */ -function gutenberg_handle_early_callback_checks( $response, $handler, $request ) { - if ( 0 === strpos( $request->get_route(), '/wp/v2/' ) ) { - $can_unbounded_query = false; - $types = get_post_types( array( 'show_in_rest' => true ), 'objects' ); - foreach ( $types as $type ) { - if ( current_user_can( $type->cap->edit_posts ) ) { - $can_unbounded_query = true; - } - } - if ( $request['per_page'] < 0 ) { - if ( ! $can_unbounded_query ) { - return new WP_Error( 'rest_forbidden_per_page', __( 'Sorry, you are not allowed to make unbounded queries.', 'gutenberg' ), array( 'status' => rest_authorization_required_code() ) ); - } - } - } - return $response; -} -add_filter( 'rest_request_before_callbacks', 'gutenberg_handle_early_callback_checks', 10, 3 ); - -/** - * Include additional query parameters on the posts query endpoint. - * - * @see https://core.trac.wordpress.org/ticket/43998 - * - * @param array $query_params JSON Schema-formatted collection parameters. - * @param WP_Post_Type $post_type Post type object being accessed. - * @return array - */ -function gutenberg_filter_post_collection_parameters( $query_params, $post_type ) { - if ( - isset( $query_params['per_page'] ) && - ( $post_type->hierarchical || 'wp_block' === $post_type->name ) - ) { - // Change from '1' to '-1', which means unlimited. - $query_params['per_page']['minimum'] = -1; - // Default sanitize callback is 'absint', which won't work in our case. - $query_params['per_page']['sanitize_callback'] = 'rest_sanitize_request_arg'; - } - return $query_params; -} - -/** - * Filter post collection query parameters to include specific behavior. - * - * @see https://core.trac.wordpress.org/ticket/43998 - * - * @param array $prepared_args Array of arguments for WP_Query. - * @param WP_REST_Request $request The current request. - * @return array - */ -function gutenberg_filter_post_query_arguments( $prepared_args, $request ) { - if ( - is_post_type_hierarchical( $prepared_args['post_type'] ) || - 'wp_block' === $prepared_args['post_type'] - ) { - // Avoid triggering 'rest_post_invalid_page_number' error - // which will need to be addressed in https://core.trac.wordpress.org/ticket/43998. - if ( -1 === $prepared_args['posts_per_page'] ) { - $prepared_args['posts_per_page'] = 100000; - } - } - return $prepared_args; -} - -/** - * Include additional query parameters on the terms query endpoint. - * - * @see https://core.trac.wordpress.org/ticket/43998 - * - * @param array $query_params JSON Schema-formatted collection parameters. - * @param object $taxonomy Taxonomy being accessed. - * @return array - */ -function gutenberg_filter_term_collection_parameters( $query_params, $taxonomy ) { - if ( $taxonomy->show_in_rest - && ( false === $taxonomy->rest_controller_class - || 'WP_REST_Terms_Controller' === $taxonomy->rest_controller_class ) - && isset( $query_params['per_page'] ) ) { - // Change from '1' to '-1', which means unlimited. - $query_params['per_page']['minimum'] = -1; - // Default sanitize callback is 'absint', which won't work in our case. - $query_params['per_page']['sanitize_callback'] = 'rest_sanitize_request_arg'; - } - return $query_params; -} - -/** - * Filter term collection query parameters to include specific behavior. - * - * @see https://core.trac.wordpress.org/ticket/43998 - * - * @param array $prepared_args Array of arguments for WP_Term_Query. - * @param WP_REST_Request $request The current request. - * @return array - */ -function gutenberg_filter_term_query_arguments( $prepared_args, $request ) { - // Can't check the actual taxonomy here because it's not - // passed through in $prepared_args (or the filter generally). - if ( 0 === strpos( $request->get_route(), '/wp/v2/' ) ) { - if ( -1 === $prepared_args['number'] ) { - // This should be unset( $prepared_args['number'] ) - // but WP_REST_Terms Controller needs to be updated to support - // unbounded queries. - // Will be addressed in https://core.trac.wordpress.org/ticket/43998. - $prepared_args['number'] = 100000; - } - } - return $prepared_args; -} - -/** - * Include additional query parameters on the user query endpoint. - * - * @see https://core.trac.wordpress.org/ticket/43998 - * - * @param array $query_params JSON Schema-formatted collection parameters. - * @return array - */ -function gutenberg_filter_user_collection_parameters( $query_params ) { - if ( isset( $query_params['per_page'] ) ) { - // Change from '1' to '-1', which means unlimited. - $query_params['per_page']['minimum'] = -1; - // Default sanitize callback is 'absint', which won't work in our case. - $query_params['per_page']['sanitize_callback'] = 'rest_sanitize_request_arg'; - } - return $query_params; -} -add_filter( 'rest_user_collection_params', 'gutenberg_filter_user_collection_parameters' ); /** * Silence PHP Warnings and Errors in JSON requests diff --git a/package-lock.json b/package-lock.json index 1867236d23dc4..179915339a81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2007,7 +2007,8 @@ "requires": { "@babel/runtime": "^7.0.0", "@wordpress/hooks": "file:packages/hooks", - "@wordpress/i18n": "file:packages/i18n" + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/url": "file:packages/url" } }, "@wordpress/autop": { diff --git a/packages/api-fetch/CHANGELOG.md b/packages/api-fetch/CHANGELOG.md index a49cefcd0d75c..7d53f6aa2222f 100644 --- a/packages/api-fetch/CHANGELOG.md +++ b/packages/api-fetch/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.0 (Unreleased) + +- Support `per_page=-1` paginated requests. + ## 2.0.0 (2018-09-05) ### Breaking Change diff --git a/packages/api-fetch/package.json b/packages/api-fetch/package.json index ed66f99b0444e..bf5ed7b10f8f0 100644 --- a/packages/api-fetch/package.json +++ b/packages/api-fetch/package.json @@ -23,7 +23,8 @@ "dependencies": { "@babel/runtime": "^7.0.0", "@wordpress/hooks": "file:../hooks", - "@wordpress/i18n": "file:../i18n" + "@wordpress/i18n": "file:../i18n", + "@wordpress/url": "file:../url" }, "publishConfig": { "access": "public" diff --git a/packages/api-fetch/src/index.js b/packages/api-fetch/src/index.js index a2ebeeff37a9a..b028fef611e4b 100644 --- a/packages/api-fetch/src/index.js +++ b/packages/api-fetch/src/index.js @@ -9,6 +9,7 @@ import { __ } from '@wordpress/i18n'; import createNonceMiddleware from './middlewares/nonce'; import createRootURLMiddleware from './middlewares/root-url'; import createPreloadingMiddleware from './middlewares/preloading'; +import fetchAllMiddleware from './middlewares/fetch-all-middleware'; import namespaceEndpointMiddleware from './middlewares/namespace-endpoint'; import httpV1Middleware from './middlewares/http-v1'; @@ -105,16 +106,19 @@ function apiFetch( options ) { const steps = [ raw, + fetchAllMiddleware, httpV1Middleware, namespaceEndpointMiddleware, ...middlewares, - ]; - const next = ( nextOptions ) => { - const nextMiddleware = steps.pop(); + ].reverse(); + + const runMiddleware = ( index ) => ( nextOptions ) => { + const nextMiddleware = steps[ index ]; + const next = runMiddleware( index + 1 ); return nextMiddleware( nextOptions, next ); }; - return next( options ); + return runMiddleware( 0 )( options ); } apiFetch.use = registerMiddleware; @@ -122,5 +126,6 @@ apiFetch.use = registerMiddleware; apiFetch.createNonceMiddleware = createNonceMiddleware; apiFetch.createPreloadingMiddleware = createPreloadingMiddleware; apiFetch.createRootURLMiddleware = createRootURLMiddleware; +apiFetch.fetchAllMiddleware = fetchAllMiddleware; export default apiFetch; diff --git a/packages/api-fetch/src/middlewares/fetch-all-middleware.js b/packages/api-fetch/src/middlewares/fetch-all-middleware.js new file mode 100644 index 0000000000000..5bc17b5413d32 --- /dev/null +++ b/packages/api-fetch/src/middlewares/fetch-all-middleware.js @@ -0,0 +1,93 @@ +/** + * WordPress dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +// Apply query arguments to both URL and Path, whichever is present. +const modifyQuery = ( { path, url, ...options }, queryArgs ) => ( { + ...options, + url: url && addQueryArgs( url, queryArgs ), + path: path && addQueryArgs( path, queryArgs ), +} ); + +// Duplicates parsing functionality from apiFetch. +const parseResponse = ( response ) => response.json ? + response.json() : + Promise.reject( response ); + +const parseLinkHeader = ( linkHeader ) => { + if ( ! linkHeader ) { + return {}; + } + const match = linkHeader.match( /<([^>]+)>; rel="next"/ ); + return match ? { + next: match[ 1 ], + } : {}; +}; + +const getNextPageUrl = ( response ) => { + const { next } = parseLinkHeader( response.headers.get( 'link' ) ); + return next; +}; + +const requestContainsUnboundedQuery = ( options ) => { + const pathIsUnbounded = options.path && options.path.indexOf( 'per_page=-1' ) !== -1; + const urlIsUnbounded = options.url && options.url.indexOf( 'per_page=-1' ) !== -1; + return pathIsUnbounded || urlIsUnbounded; +}; + +// The REST API enforces an upper limit on the per_page option. To handle large +// collections, apiFetch consumers can pass `per_page=-1`; this middleware will +// then recursively assemble a full response array from all available pages. +const fetchAllMiddleware = async ( options, next ) => { + if ( options.parse === false ) { + // If a consumer has opted out of parsing, do not apply middleware. + return next( options ); + } + if ( ! requestContainsUnboundedQuery( options ) ) { + // If neither url nor path is requesting all items, do not apply middleware. + return next( options ); + } + + // Retrieve requested page of results. + const response = await next( { + ...modifyQuery( options, { + per_page: 100, + } ), + // Ensure headers are returned for page 1. + parse: false, + } ); + + const results = await parseResponse( response ); + + if ( ! Array.isArray( results ) ) { + // We have no reliable way of merging non-array results. + return results; + } + + let nextPage = getNextPageUrl( response ); + + if ( ! nextPage ) { + // There are no further pages to request. + return results; + } + + // Iteratively fetch all remaining pages until no "next" header is found. + let mergedResults = [].concat( results ); + while ( nextPage ) { + const nextResponse = await next( { + ...options, + // Ensure the URL for the next page is used instead of any provided path. + path: undefined, + url: nextPage, + // Ensure we still get headers so we can identify the next page. + parse: false, + } ); + const nextResults = await parseResponse( nextResponse ); + mergedResults = mergedResults.concat( nextResults ); + nextPage = getNextPageUrl( nextResponse ); + } + return mergedResults; +}; + +export default fetchAllMiddleware; diff --git a/packages/api-fetch/src/middlewares/test/fetch-all-middleware.js b/packages/api-fetch/src/middlewares/test/fetch-all-middleware.js new file mode 100644 index 0000000000000..c002790ead45c --- /dev/null +++ b/packages/api-fetch/src/middlewares/test/fetch-all-middleware.js @@ -0,0 +1,51 @@ +/** + * Internal dependencies + */ +import fetchAllMiddleware from '../fetch-all-middleware'; + +describe( 'Fetch All Middleware', async () => { + it( 'should defer with the same options to the next middleware', async () => { + expect.hasAssertions(); + const originalOptions = { path: '/posts' }; + const next = ( options ) => { + expect( options ).toBe( originalOptions ); + return Promise.resolve( 'ok' ); + }; + + await fetchAllMiddleware( originalOptions, next ); + } ); + + it( 'should paginate the request', async () => { + expect.hasAssertions(); + const originalOptions = { url: '/posts?per_page=-1' }; + let counter = 1; + const next = ( options ) => { + if ( counter === 1 ) { + expect( options.url ).toBe( '/posts?per_page=100' ); + } else { + expect( options.url ).toBe( '/posts?per_page=100&page=2' ); + } + const response = Promise.resolve( { + status: 200, + headers: { + get() { + return options.url === '/posts?per_page=100' ? + '; rel="next"' : + ''; + }, + }, + json() { + return Promise.resolve( [ 'item' ] ); + }, + } ); + + counter++; + + return response; + }; + + const result = await fetchAllMiddleware( originalOptions, next ); + + expect( result ).toEqual( [ 'item', 'item' ] ); + } ); +} ); diff --git a/packages/edit-post/src/hooks/components/media-upload/index.js b/packages/edit-post/src/hooks/components/media-upload/index.js index 72e223ce66bd5..f950d34d628ee 100644 --- a/packages/edit-post/src/hooks/components/media-upload/index.js +++ b/packages/edit-post/src/hooks/components/media-upload/index.js @@ -67,8 +67,8 @@ const getAttachmentsCollection = ( ids ) => { return wp.media.query( { order: 'ASC', orderby: 'post__in', - per_page: -1, post__in: ids, + per_page: 100, query: true, type: 'image', } ); diff --git a/phpunit/class-gutenberg-rest-api-test.php b/phpunit/class-gutenberg-rest-api-test.php index 6dc323d6179bd..293dc7fed3526 100644 --- a/phpunit/class-gutenberg-rest-api-test.php +++ b/phpunit/class-gutenberg-rest-api-test.php @@ -381,17 +381,7 @@ public function test_get_items_unbounded_per_page() { $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); $request->set_param( 'per_page', '-1' ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 200, $response->get_status() ); - } - - public function test_get_items_unbounded_per_page_unauthorized() { - wp_set_current_user( $this->subscriber ); - $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); - $request->set_param( 'per_page', '-1' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 403, $response->get_status() ); - $data = $response->get_data(); - $this->assertEquals( 'rest_forbidden_per_page', $data['code'] ); + $this->assertEquals( 400, $response->get_status() ); } public function test_get_categories_unbounded_per_page() { @@ -400,18 +390,7 @@ public function test_get_categories_unbounded_per_page() { $request = new WP_REST_Request( 'GET', '/wp/v2/categories' ); $request->set_param( 'per_page', '-1' ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 200, $response->get_status() ); - } - - public function test_get_categories_unbounded_per_page_unauthorized() { - wp_set_current_user( $this->subscriber ); - $this->factory->category->create(); - $request = new WP_REST_Request( 'GET', '/wp/v2/categories' ); - $request->set_param( 'per_page', '-1' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 403, $response->get_status() ); - $data = $response->get_data(); - $this->assertEquals( 'rest_forbidden_per_page', $data['code'] ); + $this->assertEquals( 400, $response->get_status() ); } public function test_get_pages_unbounded_per_page() { @@ -420,18 +399,7 @@ public function test_get_pages_unbounded_per_page() { $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); $request->set_param( 'per_page', '-1' ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 200, $response->get_status() ); - } - - public function test_get_pages_unbounded_per_page_unauthorized() { - wp_set_current_user( $this->subscriber ); - $this->factory->post->create( array( 'post_type' => 'page' ) ); - $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); - $request->set_param( 'per_page', '-1' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 403, $response->get_status() ); - $data = $response->get_data(); - $this->assertEquals( 'rest_forbidden_per_page', $data['code'] ); + $this->assertEquals( 400, $response->get_status() ); } public function test_get_post_links_predecessor_version() {