Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?php

declare( strict_types=1 );

/**
* Tests the REST-level pagination handling in the Abilities run controller.
*
* A registered ability opts into pagination by setting a truthy `pagination` meta value
* and returning integer `total` and `total_pages` alongside its collection. The run
* controller then emits the standard X-WP-Total / X-WP-TotalPages headers and rejects
* out-of-range page requests with a 400.
*
* @covers WP_REST_Abilities_V1_Run_Controller::execute_ability
*
* @group abilities-api
* @group rest-api
*/
class Tests_REST_API_WpRestAbilitiesV1RunControllerPagination extends WP_UnitTestCase {

/**
* The REST server instance for the current test.
*
* @var WP_REST_Server
*/
protected $server;

/**
* Administrator user ID.
*
* @var int
*/
protected static $admin_id;

/**
* The total number of items the test ability reports.
*
* @var int
*/
const TOTAL_ITEMS = 5;

/**
* The run route for the paginated test ability.
*
* @var string
*/
const RUN_ROUTE = '/wp-abilities/v1/abilities/test/paginated/run';

/**
* Registers a paginated test ability and its category.
*
* @since 7.1.0
*/
public static function set_up_before_class(): void {
parent::set_up_before_class();

self::$admin_id = self::factory()->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<string, mixed> $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'] );
}
}
Loading