From 166bebaa2c64208dd6638aa10c6000d9b12d307d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 29 Oct 2026 13:31:36 +0000 Subject: [PATCH 1/3] Abilities API: Encode empty object results as JSON objects. The run endpoint prepared only associative array results for JSON clients, so an empty array returned for an object output schema, such as the `core/settings-get` result when no setting matches, was encoded as [] and failed output validation in JSON clients. Prepare every result, including root results, lists and object results, so empty objects are encoded as {} while arrays stay arrays. Register the run endpoint with the `data_processing` route option set to `false`, so the `_fields` and `_embed` parameters and response links leave the result unchanged and it follows the ability's output schema whether it is an object, an array, or a scalar value. Follow-up to [64311], [64592], [64689]. See #66267. --- ...ss-wp-rest-abilities-v1-run-controller.php | 21 +-- .../wpRestAbilitiesSettingsController.php | 44 +++++ .../wpRestAbilitiesV1RunController.php | 176 ++++++++++++++++-- 3 files changed, 215 insertions(+), 26 deletions(-) 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 ac3f8eb5c630d..f92eafe3f6828 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 @@ -65,6 +65,13 @@ public function register_routes(): void { 'callback' => array( $this, 'execute_ability' ), 'permission_callback' => array( $this, 'check_ability_permissions' ), 'args' => $this->get_run_args(), + + /* + * Serve the ability result as returned, so it follows the ability's output + * schema whether it is an object, an array, or a scalar value: `_fields`, + * `_embed` and response links leave it unchanged. + */ + 'data_processing' => false, ), 'schema' => array( $this, 'get_run_schema' ), ) @@ -96,18 +103,10 @@ public function execute_ability( $request ) { } /* - * Preserve empty objects in nested properties when encoding an associative - * result as JSON. Preparation can convert empty PHP arrays to stdClass. - * - * The REST server's `_fields` and `_embed` processing expects arrays at the - * response root and for numeric collection items. Converting those values - * to objects would cause type errors, so skip root empty arrays and numeric - * collections until that processing supports objects. An empty object result - * therefore still encodes as [], and a list containing one encodes as [[]]. + * Preserve empty objects when encoding the result as JSON. Preparation can + * convert empty PHP arrays to stdClass, also within object results. */ - if ( is_array( $result ) && ! wp_is_numeric_array( $result ) ) { - $result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() ); - } + $result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() ); return rest_ensure_response( $result ); } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 278041158ef90..3139598656c79 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -196,6 +196,50 @@ public function test_fields_filter_from_query_string_limits_settings(): void { $this->assertEqualSets( array( 'title', 'posts_per_page' ), array_keys( $data ), 'Only the settings from the comma-separated list should be returned.' ); } + /** + * Data provider for test_no_matching_setting_is_encoded_as_an_empty_object(). + * + * @return array}> + */ + public static function data_global_params(): array { + return array( + 'no global parameter' => array( array() ), + '_fields' => array( array( '_fields' => 'title' ) ), + '_embed' => array( array( '_embed' => '1' ) ), + ); + } + + /** + * An empty result is encoded as a JSON object, which the output schema requires, + * also when the global `_fields` or `_embed` parameter is given. + * + * @ticket 66267 + * @dataProvider data_global_params + * + * @param array $global_params The global parameters sent with the request. + */ + public function test_no_matching_setting_is_encoded_as_an_empty_object( array $global_params ): void { + $request = $this->run_request( + array( + 'group' => 'reading', + 'fields' => 'title', + ) + ); + foreach ( $global_params as $name => $value ) { + $request->set_param( $name, $value ); + } + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Filters that match no setting should be accepted.' ); + + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + $data = $this->server->response_to_data( $response, isset( $global_params['_embed'] ) ); + + $this->assertSame( '{}', wp_json_encode( $data ), 'The empty result should be encoded as an empty object.' ); + } + /** * @ticket 64605 */ diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index 8d0d0c5079ce8..c7cf6e3bbba26 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -443,6 +443,118 @@ private function register_test_abilities(): void { ) ); + // Ability that returns an object result with an empty object property. + $this->register_test_ability( + 'test/nested-empty-object-in-object-output', + array( + 'label' => 'Nested Empty Object In Object Output', + 'description' => 'Returns an object result with an empty object property.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'settings' => array( 'type' => 'object' ), + ), + ), + 'execute_callback' => static function () { + return (object) array( 'settings' => array() ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns a JsonSerializable result with an empty object property. + $this->register_test_ability( + 'test/nested-empty-object-in-json-serializable-output', + array( + 'label' => 'Nested Empty Object In JsonSerializable Output', + 'description' => 'Returns a JsonSerializable result with an empty object property.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'settings' => array( 'type' => 'object' ), + ), + ), + 'execute_callback' => static function () { + return new class() implements JsonSerializable { + public function jsonSerialize(): array { + return array( 'settings' => array() ); + } + }; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns an empty list. + $this->register_test_ability( + 'test/empty-list-output', + array( + 'label' => 'Empty List Output', + 'description' => 'Returns an empty list.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'execute_callback' => static function () { + return array(); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns a list of strings. + $this->register_test_ability( + 'test/string-list-output', + array( + 'label' => 'String List Output', + 'description' => 'Returns a list of strings.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'execute_callback' => static function () { + return array( 'a', 'b' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns a string. + $this->register_test_ability( + 'test/string-output', + array( + 'label' => 'String Output', + 'description' => 'Returns a string.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'string', + ), + 'execute_callback' => static function () { + return 'value'; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + // Read-only ability for query params testing. $this->register_test_ability( 'test/query-params', @@ -633,39 +745,73 @@ public function test_output_validation(): void { /** * Data provider for test_output_is_prepared_for_json_clients(). * - * @return array + * Each result is requested without global parameters, with `_fields` and `_embed`, + * and with a link that a `rest_post_dispatch` filter adds, alone and embedded. + * + * @return array */ public static function data_output_is_prepared_for_json_clients(): array { - return array( - 'root empty object stays unchanged' => array( 'test/empty-object-output', '[]' ), - 'nested empty object' => array( 'test/nested-empty-object-output', '{"settings":{}}' ), - 'numeric collection stays unchanged' => array( 'test/empty-object-list-output', '[[]]' ), - ); + $results = array( + 'root empty object' => array( 'test/empty-object-output', '{}' ), + 'nested empty object' => array( 'test/nested-empty-object-output', '{"settings":{}}' ), + 'numeric collection' => array( 'test/empty-object-list-output', '[{}]' ), + 'object result' => array( 'test/nested-empty-object-in-object-output', '{"settings":{}}' ), + 'JsonSerializable result' => array( 'test/nested-empty-object-in-json-serializable-output', '{"settings":{}}' ), + 'root empty list' => array( 'test/empty-list-output', '[]' ), + 'list of strings' => array( 'test/string-list-output', '["a","b"]' ), + 'string' => array( 'test/string-output', '"value"' ), + ); + + $data = array(); + foreach ( $results as $label => $result ) { + foreach ( array( 'none', '_fields', '_embed', 'link', 'embedded link' ) as $global_param ) { + $data[ "{$label}, {$global_param}" ] = array( $result[0], $global_param, $result[1] ); + } + } + + return $data; } /** - * Tests that ability results are prepared for JSON clients without breaking - * the global `_fields` and `_embed` parameters. + * Tests that ability results are prepared for JSON clients, and that the global + * `_fields` and `_embed` parameters and response links leave them unchanged. * * @ticket 66267 * @dataProvider data_output_is_prepared_for_json_clients * * @param string $ability_name The ability name. + * @param string $global_param The global parameter sent with the request, 'link' or + * 'embedded link' for a link added by a filter, or 'none'. * @param string $expected The expected encoded result. */ - public function test_output_is_prepared_for_json_clients( string $ability_name, string $expected ): void { - $request = new WP_REST_Request( 'POST', "/wp-abilities/v1/abilities/{$ability_name}/run" ); - $response = $this->server->dispatch( $request ); + public function test_output_is_prepared_for_json_clients( string $ability_name, string $global_param, string $expected ): void { + $request = new WP_REST_Request( 'POST', "/wp-abilities/v1/abilities/{$ability_name}/run" ); + if ( '_fields' === $global_param ) { + // No result has this field, so filtering would leave none of the result. + $request->set_param( '_fields', 'other' ); + } elseif ( '_embed' === $global_param || 'embedded link' === $global_param ) { + $request->set_param( '_embed', '1' ); + } - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( $expected, wp_json_encode( $response->get_data() ) ); + if ( 'link' === $global_param || 'embedded link' === $global_param ) { + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->add_link( 'alternate', rest_url( '/wp-abilities/v1/abilities' ), array( 'embeddable' => true ) ); + return $response; + } + ); + } + + $response = $this->server->dispatch( $request ); - $request->set_param( '_fields', 'settings' ); + $this->assertSame( 200, $response->get_status(), 'The ability should run successfully.' ); add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + $data = $this->server->response_to_data( $response, $request->has_param( '_embed' ) ); - $this->assertSame( $expected, wp_json_encode( $this->server->response_to_data( $response, true ) ) ); + $this->assertSame( $expected, wp_json_encode( $data ), 'The result should be encoded as expected.' ); } /** From 4c8c8c99c83527e1b1808f8f3ef1ffa44019a4c9 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 29 Oct 2026 15:24:39 +0000 Subject: [PATCH 2/3] Tests: Cover served, preloaded and embedded ability run results. See #66267. --- .../wpRestAbilitiesSettingsController.php | 39 ++-- .../wpRestAbilitiesV1RunController.php | 172 ++++++++++++++++-- 2 files changed, 173 insertions(+), 38 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 3139598656c79..2e052dbe5de98 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -15,7 +15,7 @@ class Tests_REST_API_WpRestAbilitiesSettingsController extends WP_UnitTestCase { /** * The REST server instance for the current test. * - * @var WP_REST_Server + * @var Spy_REST_Server */ protected $server; @@ -108,7 +108,7 @@ public function set_up(): void { parent::set_up(); global $wp_rest_server; - $wp_rest_server = new WP_REST_Server(); + $wp_rest_server = new Spy_REST_Server(); $this->server = $wp_rest_server; do_action( 'rest_api_init' ); @@ -206,12 +206,18 @@ public static function data_global_params(): array { 'no global parameter' => array( array() ), '_fields' => array( array( '_fields' => 'title' ) ), '_embed' => array( array( '_embed' => '1' ) ), + '_fields and _embed' => array( + array( + '_fields' => 'title', + '_embed' => '1', + ), + ), ); } /** - * An empty result is encoded as a JSON object, which the output schema requires, - * also when the global `_fields` or `_embed` parameter is given. + * An empty result is served as a JSON object, which the output schema requires, + * also when the global `_fields` and `_embed` parameters are given. * * @ticket 66267 * @dataProvider data_global_params @@ -219,25 +225,20 @@ public static function data_global_params(): array { * @param array $global_params The global parameters sent with the request. */ public function test_no_matching_setting_is_encoded_as_an_empty_object( array $global_params ): void { - $request = $this->run_request( + $_GET = array_merge( array( - 'group' => 'reading', - 'fields' => 'title', - ) + 'input' => array( + 'group' => 'reading', + 'fields' => 'title', + ), + ), + $global_params ); - foreach ( $global_params as $name => $value ) { - $request->set_param( $name, $value ); - } - - $response = $this->server->dispatch( $request ); - - $this->assertSame( 200, $response->get_status(), 'Filters that match no setting should be accepted.' ); - add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); - $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); - $data = $this->server->response_to_data( $response, isset( $global_params['_embed'] ) ); + $this->server->serve_request( self::RUN_ROUTE ); - $this->assertSame( '{}', wp_json_encode( $data ), 'The empty result should be encoded as an empty object.' ); + $this->assertSame( 200, $this->server->status, 'Filters that match no setting should be accepted.' ); + $this->assertSame( '{}', $this->server->sent_body, 'The empty result should be served as an empty object.' ); } /** diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index c7cf6e3bbba26..dbdf1bc838d98 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -745,10 +745,11 @@ public function test_output_validation(): void { /** * Data provider for test_output_is_prepared_for_json_clients(). * - * Each result is requested without global parameters, with `_fields` and `_embed`, - * and with a link that a `rest_post_dispatch` filter adds, alone and embedded. + * Each result is requested without global parameters, with `_fields` and `_embed` + * alone and together, and with a link that a `rest_post_dispatch` filter adds, + * alone and embedded. * - * @return array + * @return array, 2: bool, 3: string}> */ public static function data_output_is_prepared_for_json_clients(): array { $results = array( @@ -762,10 +763,26 @@ public static function data_output_is_prepared_for_json_clients(): array { 'string' => array( 'test/string-output', '"value"' ), ); + // No result has the `other` field, so filtering would leave none of the result. + $requests = array( + 'no global parameters' => array( array(), false ), + '_fields' => array( array( '_fields' => 'other' ), false ), + '_embed' => array( array( '_embed' => '1' ), false ), + '_fields and _embed' => array( + array( + '_fields' => 'other', + '_embed' => '1', + ), + false, + ), + 'response link' => array( array(), true ), + 'response link with _embed' => array( array( '_embed' => '1' ), true ), + ); + $data = array(); - foreach ( $results as $label => $result ) { - foreach ( array( 'none', '_fields', '_embed', 'link', 'embedded link' ) as $global_param ) { - $data[ "{$label}, {$global_param}" ] = array( $result[0], $global_param, $result[1] ); + foreach ( $results as $result_label => $result ) { + foreach ( $requests as $request_label => $request ) { + $data[ "{$result_label}, {$request_label}" ] = array( $result[0], $request[0], $request[1], $result[1] ); } } @@ -779,21 +796,16 @@ public static function data_output_is_prepared_for_json_clients(): array { * @ticket 66267 * @dataProvider data_output_is_prepared_for_json_clients * - * @param string $ability_name The ability name. - * @param string $global_param The global parameter sent with the request, 'link' or - * 'embedded link' for a link added by a filter, or 'none'. - * @param string $expected The expected encoded result. + * @param string $ability_name The ability name. + * @param array $query_params The global parameters sent with the request. + * @param bool $add_link Whether a `rest_post_dispatch` filter adds an embeddable link. + * @param string $expected The expected encoded result. */ - public function test_output_is_prepared_for_json_clients( string $ability_name, string $global_param, string $expected ): void { + public function test_output_is_prepared_for_json_clients( string $ability_name, array $query_params, bool $add_link, string $expected ): void { $request = new WP_REST_Request( 'POST', "/wp-abilities/v1/abilities/{$ability_name}/run" ); - if ( '_fields' === $global_param ) { - // No result has this field, so filtering would leave none of the result. - $request->set_param( '_fields', 'other' ); - } elseif ( '_embed' === $global_param || 'embedded link' === $global_param ) { - $request->set_param( '_embed', '1' ); - } + $request->set_query_params( $query_params ); - if ( 'link' === $global_param || 'embedded link' === $global_param ) { + if ( $add_link ) { add_filter( 'rest_post_dispatch', static function ( $response ) { @@ -809,11 +821,133 @@ static function ( $response ) { add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); - $data = $this->server->response_to_data( $response, $request->has_param( '_embed' ) ); + $embed = $request->has_param( '_embed' ) ? rest_parse_embed_param( $request['_embed'] ) : false; + $data = $this->server->response_to_data( $response, $embed ); $this->assertSame( $expected, wp_json_encode( $data ), 'The result should be encoded as expected.' ); } + /** + * Data provider for test_links_in_the_result_are_not_embedded(). + * + * @return array + */ + public static function data_links_in_the_result_are_not_embedded(): array { + return array( + 'every link' => array( '1' ), + 'the link relation' => array( 'author' ), + ); + } + + /** + * Tests that the global `_embed` parameter leaves the `_links` returned by an ability + * unchanged, without requesting the linked resources. + * + * @ticket 66267 + * @dataProvider data_links_in_the_result_are_not_embedded + * + * @param string $embed The `_embed` parameter sent with the request. + */ + public function test_links_in_the_result_are_not_embedded( string $embed ): void { + $result = array( + 'id' => 1, + '_links' => array( + 'author' => array( + array( + 'href' => rest_url( '/wp-abilities/v1/abilities' ), + 'embeddable' => true, + ), + ), + ), + ); + $this->register_test_ability( + 'test/links-output', + array( + 'label' => 'Links Output', + 'description' => 'Returns an object with its own embeddable link.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'object', + ), + 'execute_callback' => static function () use ( $result ) { + return $result; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/links-output/run' ); + $request->set_query_params( array( '_embed' => $embed ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The ability should run successfully.' ); + + $dispatch = new MockAction(); + add_filter( 'rest_pre_dispatch', array( $dispatch, 'filter' ) ); + $data = $this->server->response_to_data( $response, rest_parse_embed_param( $request['_embed'] ) ); + + $this->assertSame( $result, $data, 'The links returned by the ability should be left unchanged.' ); + $this->assertSame( 0, $dispatch->get_call_count(), 'No linked resource should be requested.' ); + } + + /** + * Data provider for test_preloaded_output_is_prepared_for_json_clients(). + * + * @return array, 1: mixed, 2: string, 3: string}> + */ + public static function data_preloaded_output_is_prepared_for_json_clients(): array { + return array( + 'empty object' => array( array( 'type' => 'object' ), array(), '', '{}' ), + 'empty object, _fields and _embed' => array( array( 'type' => 'object' ), array(), '?_fields=other&_embed=1', '{}' ), + 'string' => array( array( 'type' => 'string' ), 'value', '', '"value"' ), + 'string, _fields and _embed' => array( array( 'type' => 'string' ), 'value', '?_fields=other&_embed=1', '"value"' ), + ); + } + + /** + * Tests that preloading a read-only ability run keeps the result prepared for + * JSON clients, also with the global `_fields` and `_embed` parameters. + * + * @ticket 66267 + * @dataProvider data_preloaded_output_is_prepared_for_json_clients + * + * @param array $output_schema The ability output schema. + * @param mixed $result The ability result. + * @param string $query The query string of the preloaded path. + * @param string $expected The expected encoded preloaded body. + */ + public function test_preloaded_output_is_prepared_for_json_clients( array $output_schema, $result, string $query, string $expected ): void { + $this->register_test_ability( + 'test/readonly-output', + array( + 'label' => 'Read-only Output', + 'description' => 'Returns a value from a read-only ability.', + 'category' => 'general', + 'output_schema' => $output_schema, + 'execute_callback' => static function () use ( $result ) { + return $result; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + $path = '/wp-abilities/v1/abilities/test/readonly-output/run' . $query; + $preload = rest_preload_api_request( array(), $path ); + + $this->assertArrayHasKey( $path, $preload, 'The ability run should be preloaded.' ); + $this->assertSame( $expected, wp_json_encode( $preload[ $path ]['body'] ), 'The preloaded result should be encoded as expected.' ); + } + /** * Test permission check for execution. * From eb63dba2cf634770aad5c52bfe54ce5ef1623ee3 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 30 Oct 2026 11:08:47 +0000 Subject: [PATCH 3/3] Tests: Test each ability run result shape once. Test each output shape once, and keep focused tests for the run route's processing opt-out, the links returned by an ability, the served settings response and preloading, instead of repeating every shape with each combination of the global `_fields` and `_embed` parameters and response links. --- .../wpRestAbilitiesSettingsController.php | 38 +---- .../wpRestAbilitiesV1RunController.php | 145 ++++++------------ 2 files changed, 55 insertions(+), 128 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 2e052dbe5de98..311672a5238db 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -197,42 +197,16 @@ public function test_fields_filter_from_query_string_limits_settings(): void { } /** - * Data provider for test_no_matching_setting_is_encoded_as_an_empty_object(). - * - * @return array}> - */ - public static function data_global_params(): array { - return array( - 'no global parameter' => array( array() ), - '_fields' => array( array( '_fields' => 'title' ) ), - '_embed' => array( array( '_embed' => '1' ) ), - '_fields and _embed' => array( - array( - '_fields' => 'title', - '_embed' => '1', - ), - ), - ); - } - - /** - * An empty result is served as a JSON object, which the output schema requires, - * also when the global `_fields` and `_embed` parameters are given. + * An empty result is served as a JSON object, which the output schema requires. * * @ticket 66267 - * @dataProvider data_global_params - * - * @param array $global_params The global parameters sent with the request. */ - public function test_no_matching_setting_is_encoded_as_an_empty_object( array $global_params ): void { - $_GET = array_merge( - array( - 'input' => array( - 'group' => 'reading', - 'fields' => 'title', - ), + public function test_no_matching_setting_is_encoded_as_an_empty_object(): void { + $_GET = array( + 'input' => array( + 'group' => 'reading', + 'fields' => 'title', ), - $global_params ); $this->server->serve_request( self::RUN_ROUTE ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index dbdf1bc838d98..3f89d742b9da2 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -745,14 +745,10 @@ public function test_output_validation(): void { /** * Data provider for test_output_is_prepared_for_json_clients(). * - * Each result is requested without global parameters, with `_fields` and `_embed` - * alone and together, and with a link that a `rest_post_dispatch` filter adds, - * alone and embedded. - * - * @return array, 2: bool, 3: string}> + * @return array */ public static function data_output_is_prepared_for_json_clients(): array { - $results = array( + return array( 'root empty object' => array( 'test/empty-object-output', '{}' ), 'nested empty object' => array( 'test/nested-empty-object-output', '{"settings":{}}' ), 'numeric collection' => array( 'test/empty-object-list-output', '[{}]' ), @@ -762,81 +758,59 @@ public static function data_output_is_prepared_for_json_clients(): array { 'list of strings' => array( 'test/string-list-output', '["a","b"]' ), 'string' => array( 'test/string-output', '"value"' ), ); - - // No result has the `other` field, so filtering would leave none of the result. - $requests = array( - 'no global parameters' => array( array(), false ), - '_fields' => array( array( '_fields' => 'other' ), false ), - '_embed' => array( array( '_embed' => '1' ), false ), - '_fields and _embed' => array( - array( - '_fields' => 'other', - '_embed' => '1', - ), - false, - ), - 'response link' => array( array(), true ), - 'response link with _embed' => array( array( '_embed' => '1' ), true ), - ); - - $data = array(); - foreach ( $results as $result_label => $result ) { - foreach ( $requests as $request_label => $request ) { - $data[ "{$result_label}, {$request_label}" ] = array( $result[0], $request[0], $request[1], $result[1] ); - } - } - - return $data; } /** - * Tests that ability results are prepared for JSON clients, and that the global - * `_fields` and `_embed` parameters and response links leave them unchanged. + * Tests that ability results are prepared for JSON clients. * * @ticket 66267 * @dataProvider data_output_is_prepared_for_json_clients * - * @param string $ability_name The ability name. - * @param array $query_params The global parameters sent with the request. - * @param bool $add_link Whether a `rest_post_dispatch` filter adds an embeddable link. - * @param string $expected The expected encoded result. + * @param string $ability_name The ability name. + * @param string $expected The expected encoded result. */ - public function test_output_is_prepared_for_json_clients( string $ability_name, array $query_params, bool $add_link, string $expected ): void { - $request = new WP_REST_Request( 'POST', "/wp-abilities/v1/abilities/{$ability_name}/run" ); - $request->set_query_params( $query_params ); - - if ( $add_link ) { - add_filter( - 'rest_post_dispatch', - static function ( $response ) { - $response->add_link( 'alternate', rest_url( '/wp-abilities/v1/abilities' ), array( 'embeddable' => true ) ); - return $response; - } - ); - } - + public function test_output_is_prepared_for_json_clients( string $ability_name, string $expected ): void { + $request = new WP_REST_Request( 'POST', "/wp-abilities/v1/abilities/{$ability_name}/run" ); $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'The ability should run successfully.' ); - - add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); - $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); - $embed = $request->has_param( '_embed' ) ? rest_parse_embed_param( $request['_embed'] ) : false; - $data = $this->server->response_to_data( $response, $embed ); - - $this->assertSame( $expected, wp_json_encode( $data ), 'The result should be encoded as expected.' ); + $this->assertSame( $expected, wp_json_encode( $response->get_data() ), 'The result should be encoded as expected.' ); } /** - * Data provider for test_links_in_the_result_are_not_embedded(). + * Tests that the global `_fields` and `_embed` parameters and response links leave + * ability results unchanged. * - * @return array + * @ticket 66267 */ - public static function data_links_in_the_result_are_not_embedded(): array { - return array( - 'every link' => array( '1' ), - 'the link relation' => array( 'author' ), + public function test_global_parameters_and_response_links_leave_output_unchanged(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/nested-empty-object-output/run' ); + + // The result has no `other` field, so filtering would leave none of the result. + $request->set_query_params( + array( + '_fields' => 'other', + '_embed' => '1', + ) ); + + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->add_link( 'alternate', rest_url( '/wp-abilities/v1/abilities' ), array( 'embeddable' => true ) ); + return $response; + } + ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The ability should run successfully.' ); + + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + $data = $this->server->response_to_data( $response, rest_parse_embed_param( $request['_embed'] ) ); + + $this->assertSame( '{"settings":{}}', wp_json_encode( $data ), 'The result should be left unchanged.' ); } /** @@ -844,11 +818,8 @@ public static function data_links_in_the_result_are_not_embedded(): array { * unchanged, without requesting the linked resources. * * @ticket 66267 - * @dataProvider data_links_in_the_result_are_not_embedded - * - * @param string $embed The `_embed` parameter sent with the request. */ - public function test_links_in_the_result_are_not_embedded( string $embed ): void { + public function test_links_in_the_result_are_not_embedded(): void { $result = array( 'id' => 1, '_links' => array( @@ -880,7 +851,7 @@ public function test_links_in_the_result_are_not_embedded( string $embed ): void ); $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/links-output/run' ); - $request->set_query_params( array( '_embed' => $embed ) ); + $request->set_query_params( array( '_embed' => '1' ) ); $response = $this->server->dispatch( $request ); @@ -894,42 +865,24 @@ public function test_links_in_the_result_are_not_embedded( string $embed ): void $this->assertSame( 0, $dispatch->get_call_count(), 'No linked resource should be requested.' ); } - /** - * Data provider for test_preloaded_output_is_prepared_for_json_clients(). - * - * @return array, 1: mixed, 2: string, 3: string}> - */ - public static function data_preloaded_output_is_prepared_for_json_clients(): array { - return array( - 'empty object' => array( array( 'type' => 'object' ), array(), '', '{}' ), - 'empty object, _fields and _embed' => array( array( 'type' => 'object' ), array(), '?_fields=other&_embed=1', '{}' ), - 'string' => array( array( 'type' => 'string' ), 'value', '', '"value"' ), - 'string, _fields and _embed' => array( array( 'type' => 'string' ), 'value', '?_fields=other&_embed=1', '"value"' ), - ); - } - /** * Tests that preloading a read-only ability run keeps the result prepared for * JSON clients, also with the global `_fields` and `_embed` parameters. * * @ticket 66267 - * @dataProvider data_preloaded_output_is_prepared_for_json_clients - * - * @param array $output_schema The ability output schema. - * @param mixed $result The ability result. - * @param string $query The query string of the preloaded path. - * @param string $expected The expected encoded preloaded body. */ - public function test_preloaded_output_is_prepared_for_json_clients( array $output_schema, $result, string $query, string $expected ): void { + public function test_preloaded_output_is_prepared_for_json_clients(): void { $this->register_test_ability( 'test/readonly-output', array( 'label' => 'Read-only Output', - 'description' => 'Returns a value from a read-only ability.', + 'description' => 'Returns an empty object from a read-only ability.', 'category' => 'general', - 'output_schema' => $output_schema, - 'execute_callback' => static function () use ( $result ) { - return $result; + 'output_schema' => array( + 'type' => 'object', + ), + 'execute_callback' => static function () { + return array(); }, 'permission_callback' => '__return_true', 'meta' => array( @@ -941,11 +894,11 @@ public function test_preloaded_output_is_prepared_for_json_clients( array $outpu ) ); - $path = '/wp-abilities/v1/abilities/test/readonly-output/run' . $query; + $path = '/wp-abilities/v1/abilities/test/readonly-output/run?_fields=other&_embed=1'; $preload = rest_preload_api_request( array(), $path ); $this->assertArrayHasKey( $path, $preload, 'The ability run should be preloaded.' ); - $this->assertSame( $expected, wp_json_encode( $preload[ $path ]['body'] ), 'The preloaded result should be encoded as expected.' ); + $this->assertSame( '{}', wp_json_encode( $preload[ $path ]['body'] ), 'The preloaded result should be encoded as an empty object.' ); } /**