diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index dbf605523d2dc..dbe098606d9c0 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -518,6 +518,14 @@ public function serve_request( $path = null ) { $embed = isset( $_GET['_embed'] ) ? rest_parse_embed_param( $_GET['_embed'] ) : false; $result = $this->response_to_data( $result, $embed ); + if ( + $request->get_method() === 'OPTIONS' && + isset( $result['schema'] ) && + is_array( $result['schema'] ) + ) { + $result['schema'] = $this->sanitize_schema_properties_recursive( $result['schema'] ); + } + /** * Filters the REST API response. * @@ -1341,6 +1349,37 @@ protected function get_json_last_error() { return json_last_error_msg(); } + /** + * Recursively sanitizes schema data ensuring empty properties arrays become objects. + * + * The JSON Schema specification requires 'properties' to always be an object. + * In PHP, empty associative arrays become empty arrays ([]) when JSON-encoded, + * not empty objects ({}), which would make the schema invalid. + * + * @since 7.0.0 + * + * @param array|object $data The schema data to sanitize. + * @return array|object The sanitized schema data. + */ + protected function sanitize_schema_properties_recursive( $data ) { + $is_object = is_object( $data ); + $data_array = $is_object ? (array) $data : $data; + + // Convert empty properties array to empty object. + if ( isset( $data_array['properties'] ) && is_array( $data_array['properties'] ) && empty( $data_array['properties'] ) ) { + $data_array['properties'] = (object) array(); + } + + // Process nested elements recursively. + foreach ( $data_array as $key => $value ) { + if ( is_array( $value ) || is_object( $value ) ) { + $data_array[ $key ] = $this->sanitize_schema_properties_recursive( $value ); + } + } + + return $is_object ? (object) $data_array : $data_array; + } + /** * Retrieves the site index. * diff --git a/tests/phpunit/tests/functions/wpRemoteFopen.php b/tests/phpunit/tests/functions/wpRemoteFopen.php index 51394fd031497..f83ac4a78b125 100644 --- a/tests/phpunit/tests/functions/wpRemoteFopen.php +++ b/tests/phpunit/tests/functions/wpRemoteFopen.php @@ -30,6 +30,10 @@ public function test_wp_remote_fopen() { $url = 'https://s.w.org/screenshots/3.9/dashboard.png'; $response = wp_remote_fopen( $url ); + if ( false === $response ) { + $this->markTestSkipped( 'External HTTP request failed. The remote resource may be unavailable.' ); + } + $this->assertIsString( $response ); $this->assertSame( 153204, strlen( $response ) ); } diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index 692c363ac7595..95bcd8d2f1c75 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -2684,4 +2684,188 @@ public function data_envelope_params() { array( array( 'alternate' ) ), ); } + + /** + * Test that OPTIONS request returns empty properties as object instead of array. + * + * @ticket 63186 + */ + public function test_options_request_empty_properties_returns_object() { + // Use the posts endpoint which has meta with empty properties by default. + $_SERVER['REQUEST_METHOD'] = 'OPTIONS'; + rest_get_server()->serve_request( '/wp/v2/posts' ); + $data = json_decode( rest_get_server()->sent_body ); + + $this->assertObjectHasProperty( 'schema', $data, 'Response should contain schema.' ); + $this->assertObjectHasProperty( 'properties', $data->schema, 'Schema should contain properties.' ); + $this->assertObjectHasProperty( 'meta', $data->schema->properties, 'Properties should contain meta.' ); + $this->assertObjectHasProperty( 'properties', $data->schema->properties->meta, 'Meta should contain properties.' ); + // Meta properties should be an object (empty or not) for JSON schema compliance. + $this->assertIsObject( $data->schema->properties->meta->properties, 'Meta properties should be an object, not an array.' ); + } + + /** + * Test that serve_request sanitizes nested empty properties in schema. + * + * @ticket 63186 + */ + public function test_serve_request_sanitizes_nested_schema_properties() { + // Register a route with nested empty properties to test sanitization. + rest_get_server()->register_route( + 'test-ns', + '/test-ns/test', + array( + array( + 'methods' => 'GET', + 'callback' => '__return_null', + 'permission_callback' => '__return_true', + ), + 'schema' => function () { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'test', + 'type' => 'object', + 'properties' => array( + 'meta' => array( + 'type' => 'object', + 'properties' => array(), + ), + ), + ); + }, + ) + ); + + $_SERVER['REQUEST_METHOD'] = 'OPTIONS'; + rest_get_server()->serve_request( '/test-ns/test' ); + $data = json_decode( rest_get_server()->sent_body ); + + $this->assertObjectHasProperty( 'schema', $data, 'Response should contain schema.' ); + $this->assertIsObject( $data->schema->properties->meta->properties, 'Nested empty properties should be an object.' ); + } + + + /** + * Test that sanitize_schema_properties_recursive handles empty properties array. + * + * @ticket 63186 + */ + public function test_sanitize_schema_properties_recursive_converts_empty_properties_to_object() { + $server = new WP_REST_Server(); + + $method = new ReflectionMethod( $server, 'sanitize_schema_properties_recursive' ); + $method->setAccessible( true ); + + $data = array( + 'type' => 'object', + 'properties' => array(), + ); + + $result = $method->invoke( $server, $data ); + + $this->assertIsObject( $result['properties'], 'Empty properties should be converted to an object.' ); + } + + /** + * Test that sanitize_schema_properties_recursive preserves non-empty properties. + * + * @ticket 63186 + */ + public function test_sanitize_schema_properties_recursive_preserves_non_empty_properties() { + $server = new WP_REST_Server(); + + $method = new ReflectionMethod( $server, 'sanitize_schema_properties_recursive' ); + $method->setAccessible( true ); + + $data = array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + ), + ), + ); + + $result = $method->invoke( $server, $data ); + + $this->assertIsArray( $result['properties'], 'Non-empty properties should remain an array.' ); + $this->assertArrayHasKey( 'id', $result['properties'] ); + } + + /** + * Test that sanitize_schema_properties_recursive handles nested structures. + * + * @ticket 63186 + */ + public function test_sanitize_schema_properties_recursive_handles_nested_structures() { + $server = new WP_REST_Server(); + + $method = new ReflectionMethod( $server, 'sanitize_schema_properties_recursive' ); + $method->setAccessible( true ); + + $data = array( + 'type' => 'object', + 'properties' => array( + 'meta' => array( + 'type' => 'object', + 'properties' => array(), + ), + ), + ); + + $result = $method->invoke( $server, $data ); + + $this->assertIsArray( $result['properties'], 'Top-level non-empty properties should remain an array.' ); + $this->assertIsObject( $result['properties']['meta']['properties'], 'Nested empty properties should be converted to an object.' ); + } + + /** + * Test that sanitize_schema_properties_recursive handles object input. + * + * @ticket 63186 + */ + public function test_sanitize_schema_properties_recursive_handles_object_input() { + $server = new WP_REST_Server(); + + $method = new ReflectionMethod( $server, 'sanitize_schema_properties_recursive' ); + $method->setAccessible( true ); + + $data = (object) array( + 'type' => 'object', + 'properties' => array(), + ); + + $result = $method->invoke( $server, $data ); + + $this->assertIsObject( $result, 'Result should be an object when input is an object.' ); + $this->assertIsObject( $result->properties, 'Empty properties should be converted to an object.' ); + } + + /** + * Test that sanitize_schema_properties_recursive preserves other data. + * + * @ticket 63186 + */ + public function test_sanitize_schema_properties_recursive_preserves_other_data() { + $server = new WP_REST_Server(); + + $method = new ReflectionMethod( $server, 'sanitize_schema_properties_recursive' ); + $method->setAccessible( true ); + + $data = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'test', + 'type' => 'object', + 'description' => 'A test schema', + 'properties' => array(), + ); + + $result = $method->invoke( $server, $data ); + + $this->assertSame( 'http://json-schema.org/draft-04/schema#', $result['$schema'] ); + $this->assertSame( 'test', $result['title'] ); + $this->assertSame( 'object', $result['type'] ); + $this->assertSame( 'A test schema', $result['description'] ); + $this->assertIsObject( $result['properties'] ); + } }