From d8692c2e1897e51b4e6a95c2536a88ef55b3aa4e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 00:13:58 +0100 Subject: [PATCH] Abilities API: Add REST-level pagination to the run controller Abilities can opt into standard collection pagination by setting a truthy `pagination` meta value and returning integer `total` and `total_pages` alongside their collection. The run controller then emits the standard X-WP-Total and X-WP-TotalPages headers and rejects out-of-range page requests with a 400 (rest_ability_invalid_page_number), mirroring the core REST collection endpoints. The response body is returned unchanged, so non-REST callers keep the totals. --- ...ss-wp-rest-abilities-v1-run-controller.php | 55 +++++ ...RestAbilitiesV1RunControllerPagination.php | 226 ++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunControllerPagination.php diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index 04213573b5ff1..8b65303f418b1 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -95,9 +95,64 @@ public function execute_ability( $request ) { return $result; } + if ( $this->is_paginated_ability( $ability ) && is_array( $result ) && isset( $result['total'], $result['total_pages'] ) ) { + return $this->prepare_paginated_response( $result, $input ); + } + return rest_ensure_response( $result ); } + /** + * Determines whether an ability returns a paginated collection. + * + * An ability opts into REST-level pagination by setting a truthy `pagination` meta + * value. Such an ability accepts `page` and `per_page` input parameters and returns, + * alongside its collection, integer `total` and `total_pages` values describing the + * full result set. This controller turns those into the standard pagination response. + * + * @since 7.1.0 + * + * @param WP_Ability $ability The ability. + * @return bool Whether the ability is paginated. + */ + private function is_paginated_ability( $ability ): bool { + return ! empty( $ability->get_meta_item( 'pagination' ) ); + } + + /** + * Builds the response for a paginated ability, adding the standard pagination headers. + * + * Mirrors the collection endpoints: `X-WP-Total` and `X-WP-TotalPages` headers are + * emitted from the ability's reported totals, and a request for a page beyond the + * available range returns a 400 error. The response body is the ability result, + * unchanged, so non-REST callers (and clients ignoring the headers) keep the totals. + * + * @since 7.1.0 + * + * @param array $result The ability result, including `total` and `total_pages`. + * @param mixed $input The input the ability was executed with. + * @return WP_REST_Response|WP_Error The response, or an error for an out-of-range page. + */ + private function prepare_paginated_response( array $result, $input ) { + $total = (int) $result['total']; + $total_pages = (int) $result['total_pages']; + $page = ( is_array( $input ) && isset( $input['page'] ) ) ? (int) $input['page'] : 1; + + if ( $total > 0 && $page > $total_pages ) { + return new WP_Error( + 'rest_ability_invalid_page_number', + __( 'The page number requested is larger than the number of pages available.' ), + array( 'status' => 400 ) + ); + } + + $response = rest_ensure_response( $result ); + $response->header( 'X-WP-Total', $total ); + $response->header( 'X-WP-TotalPages', $total_pages ); + + return $response; + } + /** * Validates if the HTTP method matches the expected method for the ability based on its annotations. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunControllerPagination.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunControllerPagination.php new file mode 100644 index 0000000000000..cae0ee4afea21 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunControllerPagination.php @@ -0,0 +1,226 @@ +user->create( array( 'role' => 'administrator' ) ); + + global $wp_current_filter; + + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability_category( + 'test', + array( + 'label' => 'Test', + 'description' => 'Test abilities.', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability( + 'test/paginated', + array( + 'label' => 'Paginated', + 'description' => 'A paginated test ability.', + 'category' => 'test', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'default' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'default' => 2, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'items' => array( 'type' => 'array' ), + 'total' => array( 'type' => 'integer' ), + 'total_pages' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => static function ( $input = array() ): array { + $per_page = isset( $input['per_page'] ) ? max( 1, (int) $input['per_page'] ) : 2; + $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1; + $total = self::TOTAL_ITEMS; + + $offset = ( $page - 1 ) * $per_page; + $count = max( 0, min( $per_page, $total - $offset ) ); + + return array( + 'items' => array_fill( 0, $count, 'item' ), + 'total' => $total, + 'total_pages' => (int) ceil( $total / $per_page ), + ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'pagination' => true, + ), + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Unregisters the test ability and category. + * + * @since 7.1.0 + */ + public static function tear_down_after_class(): void { + if ( wp_has_ability( 'test/paginated' ) ) { + wp_unregister_ability( 'test/paginated' ); + } + if ( wp_has_ability_category( 'test' ) ) { + wp_unregister_ability_category( 'test' ); + } + + parent::tear_down_after_class(); + } + + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + do_action( 'rest_api_init' ); + + wp_set_current_user( self::$admin_id ); + } + + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Builds a GET run request with the given ability input. + * + * @param array $input The ability input. + * @return WP_REST_Request The request. + */ + private function run_request( array $input ): WP_REST_Request { + $request = new WP_REST_Request( 'GET', self::RUN_ROUTE ); + $request->set_query_params( array( 'input' => $input ) ); + return $request; + } + + public function test_emits_total_headers(): void { + $response = $this->server->dispatch( $this->run_request( array( 'per_page' => 2 ) ) ); + $headers = $response->get_headers(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( self::TOTAL_ITEMS, (int) $headers['X-WP-Total'] ); + $this->assertSame( 3, (int) $headers['X-WP-TotalPages'] ); + } + + public function test_body_is_returned_unchanged(): void { + $response = $this->server->dispatch( $this->run_request( array( 'per_page' => 2 ) ) ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'items', $data ); + $this->assertArrayHasKey( 'total', $data ); + $this->assertSame( self::TOTAL_ITEMS, $data['total'] ); + } + + public function test_out_of_range_page_returns_400(): void { + $response = $this->server->dispatch( + $this->run_request( + array( + 'per_page' => 2, + 'page' => 99, + ) + ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_ability_invalid_page_number', $response->get_data()['code'] ); + } + + public function test_last_page_is_allowed(): void { + $response = $this->server->dispatch( + $this->run_request( + array( + 'per_page' => 2, + 'page' => 3, + ) + ) + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data()['items'] ); + } +}