diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f521dedce..f1e4b7256 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -167,7 +167,7 @@ jobs: # - Uploads code coverage report to Codecov.io (if coverage is enabled). # - Uploads HTML coverage report as an artifact (if coverage is enabled). phpunit: - name: Test PHP ${{ matrix.php }} WP ${{ matrix.wp }}${{ matrix.coverage && ' with coverage' || '' }} + name: Test PHP ${{ matrix.php }} WP ${{ matrix.wp }}${{ matrix.coverage && ' with coverage' || '' }}${{ matrix.rest_backend && ' against the REST API' || '' }} runs-on: ubuntu-24.04 if: ${{ github.repository == 'WordPress/ai' || github.event_name == 'pull_request' }} strategy: @@ -176,10 +176,16 @@ jobs: php: ['8.4', '8.3', '8.2', '8.1', '8.0', '7.4'] wp: ['7.0', latest, trunk] coverage: [false] + rest_backend: [false] include: - php: '8.4' wp: latest coverage: true + # The core read abilities ship a second execute implementation that calls the + # REST API. One run covers it, since it shares the suite with the default one. + - php: '8.3' + wp: latest + rest_backend: true env: WP_ENV_PHP_VERSION: ${{ matrix.php }} WP_ENV_CORE: ${{ matrix.wp == 'trunk' && 'WordPress/WordPress' || format( 'https://wordpress.org/wordpress-{0}.zip', matrix.wp ) }} @@ -244,7 +250,7 @@ jobs: - name: Run PHPUnit tests${{ matrix.coverage && ' with coverage report' || '' }} id: phpunit run: | - npm run test:php + npm run ${{ matrix.rest_backend && 'test:php:rest' || 'test:php' }} - name: Upload code coverage report continue-on-error: true diff --git a/includes/Abilities/Content/Content.php b/includes/Abilities/Content/Content.php index 4f027f8e5..527bfeba2 100644 --- a/includes/Abilities/Content/Content.php +++ b/includes/Abilities/Content/Content.php @@ -14,6 +14,7 @@ use WP_Error; use WP_Post; use WP_Query; +use WordPress\AI\Abilities\Rest\Rest_Backend; // Exit if accessed directly. defined( 'ABSPATH' ) || exit; @@ -522,6 +523,9 @@ public function execute_read_content( $input = array() ) { $fields = $this->normalize_fields( $input ); $requires_edit = $this->has_explicit_edit_fields( $input ); + // Plugin: the alternative implementation reads the same posts through the REST API. + $rest = Rest_Backend::is_enabled() ? new Content_Rest() : null; + // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { $post = get_post( $this->input_int( $input['id'] ) ); @@ -533,7 +537,9 @@ public function execute_read_content( $input = array() ) { return $this->not_found_error(); } - return $this->to_output_post( $this->format_post( $post, $fields ) ); + return null !== $rest + ? $rest->get_post( $post, $fields ) + : $this->to_output_post( $this->format_post( $post, $fields ) ); } // Single-post mode (by slug) and query mode. @@ -549,7 +555,9 @@ public function execute_read_content( $input = array() ) { return $this->not_found_error(); } - return $this->to_output_post( $this->format_post( $post, $fields ) ); + return null !== $rest + ? $rest->get_post( $post, $fields ) + : $this->to_output_post( $this->format_post( $post, $fields ) ); } /* @@ -646,6 +654,11 @@ public function execute_read_content( $input = array() ) { $query_args['post_parent'] = $parent; } + // Plugin: the alternative implementation runs the same query through the REST API. + if ( null !== $rest ) { + return $rest->query_posts( $post_type, $query_args, $fields ); + } + $query = new WP_Query( $query_args ); $total = $this->get_query_total( $query, $query_args, $page ); $total_pages = $total > 0 ? (int) ceil( $total / $per_page ) : 0; diff --git a/includes/Abilities/Content/Content_Rest.php b/includes/Abilities/Content/Content_Rest.php new file mode 100644 index 000000000..5de65432d --- /dev/null +++ b/includes/Abilities/Content/Content_Rest.php @@ -0,0 +1,539 @@ +> + */ + private const SUB_OBJECT_FIELDS = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. + 'title' => array( + 'title_raw' => 'raw', + 'title_rendered' => 'rendered', + ), + 'excerpt' => array( + 'excerpt_raw' => 'raw', + 'excerpt_rendered' => 'rendered', + 'excerpt_protected' => 'protected', + ), + 'content' => array( + 'content_raw' => 'raw', + 'content_rendered' => 'rendered', + 'content_protected' => 'protected', + ), + ); + + /** + * Ability fields that map to a plain REST field, keyed by ability field name. + * + * @since x.x.x + * @var array + */ + private const PLAIN_FIELDS = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. + 'id' => 'id', + 'post_type' => 'type', + 'status' => 'status', + 'date' => 'date', + 'date_gmt' => 'date_gmt', + 'modified' => 'modified', + 'modified_gmt' => 'modified_gmt', + 'slug' => 'slug', + 'link' => 'link', + 'author' => 'author', + 'parent' => 'parent', + ); + + /** + * Reads a single post through the REST API. + * + * @since x.x.x + * + * @param \WP_Post $post The post to read. + * @param list $fields The requested field names. + * @return array|\stdClass|\WP_Error The formatted post data, or a WP_Error on failure. + */ + public function get_post( WP_Post $post, array $fields ) { + $post_type_object = get_post_type_object( $post->post_type ); + if ( ! $post_type_object instanceof WP_Post_Type ) { + return $this->not_found_error(); + } + + $restore_post_type = $this->prepare_post_type( $post_type_object ); + $restore_context = $this->capture_post_context(); + + try { + $response = Rest_Backend::get( + $this->route( $post_type_object ) . '/' . (int) $post->ID, + array( + 'context' => current_user_can( 'edit_post', $post->ID ) ? 'edit' : 'view', + '_fields' => $this->rest_fields( $fields ), + ) + ); + } finally { + $restore_context(); + $restore_post_type(); + } + + if ( is_wp_error( $response ) ) { + return $response; + } + + $data = Rest_Backend::data( $response ); + if ( is_wp_error( $data ) ) { + return $data; + } + + return $this->format_post( $data, $fields ); + } + + /** + * Reads a set of posts through the REST API. + * + * Takes the `WP_Query` arguments the ability prepared and maps them to the collection + * parameters of the posts endpoint. + * + * @since x.x.x + * + * @param string $post_type The post type to query. + * @param array $query_args The prepared `WP_Query` arguments. + * @param list $fields The requested field names. + * @return array{posts: list|\stdClass>, total: int, total_pages: int}|\WP_Error The query data, or a WP_Error on failure. + */ + public function query_posts( string $post_type, array $query_args, array $fields ) { + $post_type_object = get_post_type_object( $post_type ); + if ( ! $post_type_object instanceof WP_Post_Type ) { + return $this->not_found_error(); + } + + $params = array( + 'context' => 'editable' === ( $query_args['perm'] ?? '' ) ? 'edit' : 'view', + 'status' => $query_args['post_status'], + 'per_page' => $query_args['posts_per_page'], + 'page' => $query_args['paged'], + '_fields' => $this->rest_fields( $fields ), + ); + + // The REST parameters for author and parent are lists, unlike the query arguments. + if ( isset( $query_args['post__in'] ) ) { + $params['include'] = $query_args['post__in']; + } + if ( isset( $query_args['author'] ) ) { + $params['author'] = array( $query_args['author'] ); + } + if ( isset( $query_args['post_parent'] ) ) { + $params['parent'] = array( $query_args['post_parent'] ); + } + + /* + * The endpoint has no parameters for the read permission and the cache priming the + * ability decides on, so they are carried over to the query the endpoint builds, + * for this request only. + */ + $carry_query_args = static function ( array $args ) use ( $query_args ): array { + $args['perm'] = $query_args['perm']; + $args['update_post_meta_cache'] = $query_args['update_post_meta_cache']; + $args['update_post_term_cache'] = $query_args['update_post_term_cache']; + + return $args; + }; + add_filter( "rest_{$post_type}_query", $carry_query_args ); + + $restore_post_type = $this->prepare_post_type( $post_type_object ); + $restore_context = $this->capture_post_context(); + + try { + $response = Rest_Backend::get( $this->route( $post_type_object ), $params ); + } finally { + remove_filter( "rest_{$post_type}_query", $carry_query_args ); + $restore_context(); + $restore_post_type(); + } + + if ( is_wp_error( $response ) ) { + // The endpoint reports the same out-of-range page the ability reports, under + // its own error code. + if ( 'rest_post_invalid_page_number' === $response->get_error_code() ) { + return new WP_Error( + 'content_invalid_page_number', + __( 'The page number requested is larger than the number of pages available.', 'ai' ), + array( 'status' => 400 ) + ); + } + + return $response; + } + + $data = Rest_Backend::data( $response ); + if ( is_wp_error( $data ) ) { + return $data; + } + + $posts = array(); + foreach ( $data as $item ) { + // A row the mapping cannot read is reported, not skipped: skipping it would + // return a page that is short of rows while the totals still count them. + if ( ! is_array( $item ) ) { + return Rest_Backend::unexpected_response_error(); + } + + $posts[] = $this->format_post( $item, $fields ); + } + + return array( + 'posts' => $posts, + 'total' => Rest_Backend::pagination_header( $response, 'X-WP-Total' ), + 'total_pages' => Rest_Backend::pagination_header( $response, 'X-WP-TotalPages' ), + ); + } + + /** + * Maps a REST post response to the ability output shape. + * + * A field the post type does not support is absent from the REST response, so it is + * absent here too. An empty projection is returned as an object so it serializes as `{}`. + * + * @since x.x.x + * + * @param array $data The REST response data for one post. + * @param list $fields The requested field names. + * @return array|\stdClass The formatted post data. + */ + private function format_post( array $data, array $fields ) { + $requested = array_flip( $fields ); + $result = array(); + + foreach ( self::PLAIN_FIELDS as $field => $rest_field ) { + if ( ! isset( $requested[ $field ] ) || ! array_key_exists( $rest_field, $data ) ) { + continue; + } + + $result[ $field ] = $data[ $rest_field ]; + } + + foreach ( array( 'id', 'parent' ) as $field ) { + if ( ! isset( $result[ $field ] ) ) { + continue; + } + + $result[ $field ] = (int) $result[ $field ]; + } + foreach ( array( 'post_type', 'status', 'slug', 'link' ) as $field ) { + if ( ! isset( $result[ $field ] ) ) { + continue; + } + + $result[ $field ] = (string) $result[ $field ]; + } + + // REST reports dates without a timezone offset; the ability reports full ISO 8601. + foreach ( array( 'date', 'modified' ) as $field ) { + if ( array_key_exists( $field, $result ) ) { + $result[ $field ] = $this->to_iso_8601( $result[ $field ], wp_timezone() ); + } + if ( ! array_key_exists( $field . '_gmt', $result ) ) { + continue; + } + + $gmt = $this->to_iso_8601( $result[ $field . '_gmt' ], new \DateTimeZone( 'UTC' ) ); + + /* + * REST reports no GMT date when the stored column is null rather than the + * zero date. The ability derives it from the local date in that case, so + * read the stored dates back for the fallback. + */ + $result[ $field . '_gmt' ] = '' === $gmt ? $this->gmt_from_stored_date( $data, $field ) : $gmt; + } + + // REST reports the author ID alone; the ability reports the ID with the name. + if ( array_key_exists( 'author', $result ) ) { + $author = get_userdata( (int) $result['author'] ); + $result['author'] = array( + 'id' => (int) $result['author'], + 'name' => $author ? $author->display_name : '', + ); + } + + foreach ( self::SUB_OBJECT_FIELDS as $rest_field => $mapped_fields ) { + if ( ! isset( $data[ $rest_field ] ) || ! is_array( $data[ $rest_field ] ) ) { + continue; + } + + foreach ( $mapped_fields as $field => $key ) { + if ( ! isset( $requested[ $field ] ) || ! array_key_exists( $key, $data[ $rest_field ] ) ) { + continue; + } + + $value = $data[ $rest_field ][ $key ]; + $result[ $field ] = 'protected' === $key ? (bool) $value : (string) $value; + } + } + + return array() === $result ? (object) array() : $this->in_output_order( $result ); + } + + /** + * Sorts the mapped fields into the order the ability documents them in. + * + * @since x.x.x + * + * @param array $result The mapped post data. + * @return array The post data in output order. + */ + private function in_output_order( array $result ): array { + $order = array( + 'id', + 'post_type', + 'status', + 'date', + 'date_gmt', + 'modified', + 'modified_gmt', + 'slug', + 'link', + 'title_raw', + 'title_rendered', + 'excerpt_raw', + 'excerpt_rendered', + 'excerpt_protected', + 'content_raw', + 'content_rendered', + 'content_protected', + 'author', + 'parent', + ); + + $ordered = array(); + foreach ( $order as $field ) { + if ( ! array_key_exists( $field, $result ) ) { + continue; + } + + $ordered[ $field ] = $result[ $field ]; + } + + return $ordered; + } + + /** + * Derives a GMT date from the post's stored local date. + * + * @since x.x.x + * + * @param array $data The REST response data for one post. + * @param string $field Either `date` or `modified`. + * @return string The ISO 8601 date, or an empty string when it cannot be resolved. + */ + private function gmt_from_stored_date( array $data, string $field ): string { + $post = isset( $data['id'] ) ? get_post( (int) $data['id'] ) : null; + if ( ! $post instanceof WP_Post ) { + return ''; + } + + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + if ( ! is_string( $local ) || '' === $local || '0000-00-00 00:00:00' === $local ) { + return ''; + } + + $timestamp = strtotime( get_gmt_from_date( $local ) . ' UTC' ); + + return false === $timestamp ? '' : gmdate( 'c', $timestamp ); + } + + /** + * Remembers the global post context so it can be put back after the request. + * + * The posts endpoint sets the global post while it renders each item and leaves it + * there. The ability restores whatever context it found, so filters that run after it + * still see the post they were looking at. + * + * @since x.x.x + * + * @return callable(): void A callback that restores the previous global post context. + */ + private function capture_post_context(): callable { + $previous_post = $GLOBALS['post'] ?? null; + + return static function () use ( $previous_post ): void { + if ( $previous_post instanceof WP_Post ) { + $GLOBALS['post'] = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context. + setup_postdata( $previous_post ); + + return; + } + + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + }; + } + + /** + * Formats a REST date as ISO 8601 with a timezone offset. + * + * @since x.x.x + * + * @param mixed $value The REST date value. + * @param \DateTimeZone $timezone The timezone the value is expressed in. + * @return string The ISO 8601 date, or an empty string when it cannot be resolved. + */ + private function to_iso_8601( $value, \DateTimeZone $timezone ): string { + if ( ! is_string( $value ) || '' === $value ) { + return ''; + } + + $datetime = date_create_immutable( $value, $timezone ); + + return $datetime ? $datetime->format( 'c' ) : ''; + } + + /** + * Maps the requested ability fields to the REST fields that carry them. + * + * @since x.x.x + * + * @param list $fields The requested field names. + * @return list The REST field names to request. + */ + private function rest_fields( array $fields ): array { + $rest_fields = array(); + + foreach ( $fields as $field ) { + if ( isset( self::PLAIN_FIELDS[ $field ] ) ) { + $rest_fields[] = self::PLAIN_FIELDS[ $field ]; + continue; + } + + foreach ( self::SUB_OBJECT_FIELDS as $rest_field => $mapped_fields ) { + if ( ! isset( $mapped_fields[ $field ] ) ) { + continue; + } + + $rest_fields[] = $rest_field; + } + } + + // `id` keeps the response shaped as a map even when nothing else is requested. + $rest_fields[] = 'id'; + + return array_values( array_unique( $rest_fields ) ); + } + + /** + * Returns the REST route for a post type's posts endpoint. + * + * @since x.x.x + * + * @param \WP_Post_Type $post_type_object The post type object. + * @return string The route, for example `/wp/v2/posts`. + */ + private function route( WP_Post_Type $post_type_object ): string { + $namespace = ! empty( $post_type_object->rest_namespace ) && is_string( $post_type_object->rest_namespace ) + ? $post_type_object->rest_namespace + : 'wp/v2'; + $base = ! empty( $post_type_object->rest_base ) && is_string( $post_type_object->rest_base ) + ? $post_type_object->rest_base + : $post_type_object->name; + + return '/' . $namespace . '/' . $base; + } + + /** + * Makes sure a post type can be read through the REST API. + * + * A post type can be exposed to abilities with `show_in_abilities` without being exposed + * to REST, in which case it has no route and the posts controller refuses to serve it. + * Turn the flag on for the length of the request, and drop the built REST server so the + * next request builds a fresh one. Rebuilding runs `rest_api_init`, where WordPress + * registers a route for every post type exposed to REST, including this one. + * + * @since x.x.x + * + * @param \WP_Post_Type $post_type_object The post type object. + * @return callable(): void A callback that restores the flag and the previous server. + */ + private function prepare_post_type( WP_Post_Type $post_type_object ): callable { + if ( ! empty( $post_type_object->show_in_rest ) ) { + return static function (): void {}; + } + + $previous_flag = $post_type_object->show_in_rest; + $previous_server = $GLOBALS['wp_rest_server'] ?? null; + + $post_type_object->show_in_rest = true; + unset( $GLOBALS['wp_rest_server'] ); + + return static function () use ( $post_type_object, $previous_flag, $previous_server ): void { + $post_type_object->show_in_rest = $previous_flag; + + /* + * The server used for the request was built while the post type was exposed, so + * its routes include one the restored post type must not have. Drop it either + * way: when there was a previous server, put it back, and when there was none, + * leave the global unset so the next caller builds a fresh one. + */ + if ( null === $previous_server ) { + unset( $GLOBALS['wp_rest_server'] ); + return; + } + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Restores the WordPress REST server that was replaced above. + $GLOBALS['wp_rest_server'] = $previous_server; + }; + } + + /** + * Builds the uniform not-found error. + * + * @since x.x.x + * + * @return \WP_Error The not-found error. + */ + private function not_found_error(): WP_Error { + return new WP_Error( + 'content_not_found', + __( 'The requested content was not found.', 'ai' ), + array( 'status' => 404 ) + ); + } +} diff --git a/includes/Abilities/Rest/Rest_Backend.php b/includes/Abilities/Rest/Rest_Backend.php new file mode 100644 index 000000000..2fb132c6c --- /dev/null +++ b/includes/Abilities/Rest/Rest_Backend.php @@ -0,0 +1,156 @@ + $params Request parameters. + * @return \WP_REST_Response|\WP_Error The response, or the error the endpoint returned. + */ + public static function get( string $route, array $params = array() ) { + $request = new WP_REST_Request( 'GET', $route ); + $request->set_query_params( $params ); + + $response = rest_do_request( $request ); + + if ( ! $response->is_error() ) { + return $response; + } + + // An errored response always carries an error, so the fallback is never reached. + return $response->as_error() ?? new WP_Error( 'rest_request_failed', __( 'The REST request failed.', 'ai' ) ); + } + + /** + * Reads a pagination header from a REST response. + * + * @since x.x.x + * + * @param \WP_REST_Response $response The REST response. + * @param string $header The header name, for example `X-WP-Total`. + * @return int The header value as an integer, or 0 when the header is missing. + */ + public static function pagination_header( WP_REST_Response $response, string $header ): int { + $headers = $response->get_headers(); + + return isset( $headers[ $header ] ) && is_scalar( $headers[ $header ] ) ? (int) $headers[ $header ] : 0; + } + + /** + * Returns the response data as an array. + * + * A successful response that does not carry a list or a map is a response the mapping + * cannot read. Reporting it as an empty array would make it look like a valid empty + * result, so it is reported as an error instead. + * + * @since x.x.x + * + * @param \WP_REST_Response $response The REST response. + * @return array|\WP_Error The response data, or an error when it is not a list or map. + */ + public static function data( WP_REST_Response $response ) { + $data = $response->get_data(); + + return is_array( $data ) ? $data : self::unexpected_response_error(); + } + + /** + * Builds the error for a response the mapping cannot read. + * + * @since x.x.x + * + * @return \WP_Error The unexpected-response error. + */ + public static function unexpected_response_error(): WP_Error { + return new WP_Error( + 'rest_unexpected_response', + __( 'The REST API returned a response in an unexpected shape.', 'ai' ), + array( 'status' => 500 ) + ); + } +} diff --git a/includes/Abilities/Settings/Settings.php b/includes/Abilities/Settings/Settings.php index 127f1a1b6..00f31b355 100644 --- a/includes/Abilities/Settings/Settings.php +++ b/includes/Abilities/Settings/Settings.php @@ -11,6 +11,8 @@ namespace WordPress\AI\Abilities\Settings; +use WordPress\AI\Abilities\Rest\Rest_Backend; + // Exit if accessed directly. defined( 'ABSPATH' ) || exit; @@ -162,10 +164,13 @@ private function register_get_settings(): void { * * @since 1.1.0 * + * Plugin: the return type is not declared, so the alternative implementation can pass on + * a REST error. Core's version always returns an array. + * * @param mixed $input Optional. The ability input. Default empty array. - * @return array Map of exposed setting name to current value. + * @return array|\WP_Error Map of exposed setting name to current value, or a WP_Error. */ - public function execute_get_settings( $input = array() ): array { + public function execute_get_settings( $input = array() ) { $input = is_array( $input ) ? $input : array(); $settings = $this->exposed_settings; @@ -178,6 +183,17 @@ public function execute_get_settings( $input = array() ): array { $group = isset( $input['group'] ) && is_string( $input['group'] ) ? $input['group'] : ''; $fields = isset( $input['fields'] ) && is_array( $input['fields'] ) ? $input['fields'] : array(); + /* + * Plugin: the alternative implementation reads the same values through + * `GET /wp/v2/settings`. It reports nothing for settings the REST API does not + * expose, which fall back to the stored option below. An error from the endpoint + * is passed on instead, so a refused request cannot be answered from the options. + */ + $rest_values = Rest_Backend::is_enabled() ? ( new Settings_Rest() )->get_values( $settings ) : null; + if ( is_wp_error( $rest_values ) ) { + return $rest_values; + } + $result = array(); foreach ( $settings as $exposed_name => $setting ) { if ( '' !== $group && $setting['group'] !== $group ) { @@ -188,7 +204,9 @@ public function execute_get_settings( $input = array() ): array { } $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; - $value = get_option( $setting['option'], $setting['default'] ); + $value = null !== $rest_values && array_key_exists( $exposed_name, $rest_values ) + ? $rest_values[ $exposed_name ] + : get_option( $setting['option'], $setting['default'] ); $result[ $exposed_name ] = $this->cast_value( $value, $type ); } diff --git a/includes/Abilities/Settings/Settings_Rest.php b/includes/Abilities/Settings/Settings_Rest.php new file mode 100644 index 000000000..9da272468 --- /dev/null +++ b/includes/Abilities/Settings/Settings_Rest.php @@ -0,0 +1,109 @@ +}> $settings Exposed settings keyed by exposed name. + * @return array|\WP_Error Values keyed by exposed name, or the error the + * endpoint returned. Settings the REST API does + * not expose are absent from a successful result. + */ + public function get_values( array $settings ) { + $response = Rest_Backend::get( '/wp/v2/settings', array( 'context' => 'edit' ) ); + + /* + * The error is passed on rather than reported as "no values". Reporting no values + * would send every setting to the stored option instead, which answers a request + * the endpoint just refused. + */ + if ( is_wp_error( $response ) ) { + return $response; + } + + $rest_values = Rest_Backend::data( $response ); + if ( is_wp_error( $rest_values ) ) { + return $rest_values; + } + + $rest_names = $this->rest_names(); + + $values = array(); + foreach ( $settings as $exposed_name => $setting ) { + $rest_name = $rest_names[ $setting['option'] ] ?? null; + + if ( null === $rest_name || ! array_key_exists( $rest_name, $rest_values ) ) { + continue; + } + + $values[ $exposed_name ] = $rest_values[ $rest_name ]; + } + + return $values; + } + + /** + * Maps each option name to the name the settings endpoint reports it under. + * + * Mirrors how `WP_REST_Settings_Controller::get_registered_options()` picks the response + * key: the `show_in_rest` name when one is given, and the option name otherwise. + * + * @since x.x.x + * + * @return array REST names keyed by option name. + */ + private function rest_names(): array { + $names = array(); + + foreach ( get_registered_settings() as $option_name => $args ) { + $show = $args['show_in_rest'] ?? false; + if ( empty( $show ) ) { + continue; + } + + $option_name = (string) $option_name; + + $names[ $option_name ] = is_array( $show ) && ! empty( $show['name'] ) && is_string( $show['name'] ) + ? $show['name'] + : $option_name; + } + + return $names; + } +} diff --git a/includes/Abilities/Users/Users.php b/includes/Abilities/Users/Users.php index 9e62eb7bf..00fe7ddad 100644 --- a/includes/Abilities/Users/Users.php +++ b/includes/Abilities/Users/Users.php @@ -14,6 +14,7 @@ use WP_Error; use WP_User; use WP_User_Query; +use WordPress\AI\Abilities\Rest\Rest_Backend; use stdClass; // Exit if accessed directly. @@ -188,6 +189,9 @@ public function execute_get_users( $input = array() ) { $input = $this->to_input_array( $input ); $fields = $this->normalize_fields( $input ); + // Plugin: the alternative implementation reads the same users through the REST API. + $rest = Rest_Backend::is_enabled() ? new Users_Rest() : null; + $lookup_type = $this->get_lookup_type( $input ); if ( self::LOOKUP_COLLECTION !== $lookup_type ) { $user = $this->resolve_readable_user( $input, $lookup_type ); @@ -198,7 +202,7 @@ public function execute_get_users( $input = array() ) { ); } - return $this->format_user( $user, $fields ); + return null !== $rest ? $rest->get_user( $user, $fields ) : $this->format_user( $user, $fields ); } $per_page = $this->normalize_per_page( $input ); @@ -260,6 +264,11 @@ public function execute_get_users( $input = array() ) { $query_args['has_published_posts'] = $has_published_posts; } + // Plugin: the alternative implementation runs the same collection through the REST API. + if ( null !== $rest ) { + return $rest->query_users( $query_args, $fields ); + } + $query = new WP_User_Query( $query_args ); $users = array(); diff --git a/includes/Abilities/Users/Users_Rest.php b/includes/Abilities/Users/Users_Rest.php new file mode 100644 index 000000000..81bf00b04 --- /dev/null +++ b/includes/Abilities/Users/Users_Rest.php @@ -0,0 +1,310 @@ +` instead of running + * `WP_User_Query` and reading the user object directly. + * + * The ability field names match the REST field names one to one, so the mapping is mostly + * about visibility. Two rules differ from REST and are applied here: + * + * - REST returns the sensitive fields (username, email, names, locale, registration date) + * only in the `edit` context, which the ability grants to the current user and to users + * the caller can edit. The context is picked per user from that rule. + * - REST returns `roles` to anyone who can list users. The ability treats roles as + * sensitive, so they are dropped for users the caller cannot edit. + * + * @internal This class should not be used outside the plugin and there is no guarantee of backwards compatibility. + * + * @since x.x.x + */ +final class Users_Rest { + + /** + * The REST route for the users endpoint. + * + * @since x.x.x + * @var string + */ + private const ROUTE = '/wp/v2/users'; + + /** + * Fields REST only returns in the `edit` context. + * + * @since x.x.x + * @var list + */ + private const SENSITIVE_FIELDS = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. + 'username', + 'email', + 'first_name', + 'last_name', + 'nickname', + 'locale', + 'registered_date', + 'roles', + ); + + /** + * Reads a single user through the REST API. + * + * @since x.x.x + * + * @param \WP_User $user The user to read. + * @param string[] $fields The requested field names. + * @return array|\stdClass|\WP_Error The formatted user data, or a WP_Error on failure. + */ + public function get_user( WP_User $user, array $fields ) { + $can_view_sensitive = $this->can_view_sensitive( $user ); + $context = $can_view_sensitive && $this->wants_sensitive( $fields ) ? 'edit' : 'view'; + + $response = Rest_Backend::get( + self::ROUTE . '/' . (int) $user->ID, + array( + 'context' => $context, + '_fields' => $fields, + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $data = Rest_Backend::data( $response ); + if ( is_wp_error( $data ) ) { + return $data; + } + + return $this->format_user( $user, $fields, $data, $can_view_sensitive ); + } + + /** + * Reads a collection of users through the REST API. + * + * Takes the `WP_User_Query` arguments the ability prepared and maps them to the + * collection parameters of the users endpoint. + * + * @since x.x.x + * + * @param array $query_args The prepared `WP_User_Query` arguments. + * @param string[] $fields The requested field names. + * @return array{users: list|\stdClass>, total: int, total_pages: int}|\WP_Error The collection data, or a WP_Error on failure. + */ + public function query_users( array $query_args, array $fields ) { + $per_page = max( 1, (int) ( $query_args['number'] ?? 10 ) ); + $offset = max( 0, (int) ( $query_args['offset'] ?? 0 ) ); + + $params = array( + 'context' => 'view', + 'per_page' => $per_page, + 'page' => (int) floor( $offset / $per_page ) + 1, + '_fields' => $this->collection_fields( $fields ), + ); + + if ( ! empty( $query_args['include'] ) ) { + $params['include'] = $query_args['include']; + } + if ( ! empty( $query_args['role__in'] ) ) { + $params['roles'] = $query_args['role__in']; + } + + /* + * The ordering needs no mapping: both sides order by display name, ascending. + * + * `has_published_posts` does. The endpoint only accepts post types exposed to REST + * there, while the ability counts every publicly viewable post type, so the resolved + * list is carried over to the query the endpoint builds, for this request only. + */ + $carry_query_args = static function ( array $args ) use ( $query_args ): array { + if ( ! empty( $query_args['has_published_posts'] ) ) { + $args['has_published_posts'] = $query_args['has_published_posts']; + } + + return $args; + }; + add_filter( 'rest_user_query', $carry_query_args ); + + try { + $response = Rest_Backend::get( self::ROUTE, $params ); + } finally { + remove_filter( 'rest_user_query', $carry_query_args ); + } + + if ( is_wp_error( $response ) ) { + return $response; + } + + $data = Rest_Backend::data( $response ); + if ( is_wp_error( $data ) ) { + return $data; + } + + $users = array(); + foreach ( $data as $row ) { + // A row the mapping cannot read is reported for the same reason a failed row + // is below: the totals count it, so dropping it hides a user without saying so. + if ( ! is_array( $row ) || ! isset( $row['id'] ) ) { + return Rest_Backend::unexpected_response_error(); + } + + $user = get_userdata( (int) $row['id'] ); + if ( ! $user instanceof WP_User ) { + return Rest_Backend::unexpected_response_error(); + } + + /* + * The collection is read in the `view` context, which withholds the sensitive + * fields for every row. REST only serves them in the `edit` context, which + * drops rows the caller cannot edit and so cannot back this collection. Read + * the rows that may show sensitive fields individually instead. + */ + $formatted = $this->wants_sensitive( $fields ) && $this->can_view_sensitive( $user ) + ? $this->get_user( $user, $fields ) + : $this->format_user( $user, $fields, $row, false ); + + /* + * A row that fails is reported, not dropped. Dropping it would return a page + * that is short of rows while the totals still count them, so the caller + * cannot tell the missing users from users that do not exist. + */ + if ( is_wp_error( $formatted ) ) { + return $formatted; + } + + $users[] = $formatted; + } + + return array( + 'users' => $users, + 'total' => Rest_Backend::pagination_header( $response, 'X-WP-Total' ), + 'total_pages' => Rest_Backend::pagination_header( $response, 'X-WP-TotalPages' ), + ); + } + + /** + * Maps a REST user response to the ability output shape. + * + * @since x.x.x + * + * @param \WP_User $user The user the response describes. + * @param string[] $fields The requested field names. + * @param array $data The REST response data. + * @param bool $can_view_sensitive Whether the caller may see the sensitive fields. + * @return array|\stdClass The formatted user data, as an object when empty. + */ + private function format_user( WP_User $user, array $fields, array $data, bool $can_view_sensitive ) { + $requested = array_flip( $fields ); + $result = array(); + + if ( isset( $requested['id'] ) ) { + $result['id'] = (int) $user->ID; + } + foreach ( array( 'name', 'description', 'url', 'link', 'slug' ) as $field ) { + if ( ! isset( $requested[ $field ], $data[ $field ] ) ) { + continue; + } + + $result[ $field ] = (string) $data[ $field ]; + } + + /* + * The option is read on every call. REST decides once per request whether avatar + * URLs are part of its schema, so a response can still carry them after the option + * was turned off. A size with no resolvable URL is reported as null. + */ + if ( isset( $requested['avatar_urls'] ) && get_option( 'show_avatars' ) && ! empty( $data['avatar_urls'] ) && is_array( $data['avatar_urls'] ) ) { + $result['avatar_urls'] = array_map( + static function ( $url ) { + return is_string( $url ) ? $url : null; + }, + $data['avatar_urls'] + ); + } + + if ( ! $can_view_sensitive ) { + return array() === $result ? (object) $result : $result; + } + + if ( isset( $requested['username'], $data['username'] ) ) { + $result['username'] = (string) $data['username']; + } + if ( isset( $requested['email'] ) && array_key_exists( 'email', $data ) ) { + $result['email'] = is_email( $data['email'] ) ? (string) $data['email'] : null; + } + foreach ( array( 'first_name', 'last_name', 'nickname', 'locale', 'registered_date' ) as $field ) { + if ( ! isset( $requested[ $field ], $data[ $field ] ) ) { + continue; + } + + $result[ $field ] = (string) $data[ $field ]; + } + if ( isset( $requested['roles'] ) && ! empty( $data['roles'] ) && is_array( $data['roles'] ) ) { + $result['roles'] = array_values( array_unique( array_filter( $data['roles'], 'is_string' ) ) ); + } + + return array() === $result ? (object) $result : $result; + } + + /** + * Checks whether the caller may see a user's sensitive fields. + * + * @since x.x.x + * + * @param \WP_User $user The user object. + * @return bool True when the caller is the user or can edit them. + */ + private function can_view_sensitive( WP_User $user ): bool { + return get_current_user_id() === (int) $user->ID || current_user_can( 'edit_user', $user->ID ); + } + + /** + * Checks whether any requested field is only served in the `edit` context. + * + * @since x.x.x + * + * @param string[] $fields The requested field names. + * @return bool True when a sensitive field was requested. + */ + private function wants_sensitive( array $fields ): bool { + return array() !== array_intersect( self::SENSITIVE_FIELDS, $fields ); + } + + /** + * Returns the fields to request for a collection row. + * + * The sensitive fields are never served in the `view` context, so they are dropped from + * the collection request. `id` is kept so each row can be resolved back to its user. + * + * @since x.x.x + * + * @param string[] $fields The requested field names. + * @return string[] The field names to request. + */ + private function collection_fields( array $fields ): array { + $collection_fields = array_values( array_diff( $fields, self::SENSITIVE_FIELDS ) ); + + if ( ! in_array( 'id', $collection_fields, true ) ) { + array_unshift( $collection_fields, 'id' ); + } + + return $collection_fields; + } +} diff --git a/package.json b/package.json index 50e52366d..1fbfe9f88 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "start": "concurrently -k -n legacy,routes \"npm run start:legacy\" \"npm run start:routes\"", "test:e2e:debug": "wp-scripts test-playwright --config tests/e2e/playwright.config.ts --ui", "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.ts", + "test:php:rest": "wp-env --config=.wp-env.test.json run cli --env-cwd=wp-content/plugins/ai bash -c \"WPAI_ABILITIES_REST_BACKEND=1 vendor/bin/phpunit -c phpunit.xml.dist\"", "test:php": "wp-env --config=.wp-env.test.json run cli --env-cwd=wp-content/plugins/ai vendor/bin/phpunit -c phpunit.xml.dist", "typecheck": "tsc --noEmit", "wp-env": "wp-env", diff --git a/tests/Integration/Includes/Abilities/Rest/Rest_BackendTest.php b/tests/Integration/Includes/Abilities/Rest/Rest_BackendTest.php new file mode 100644 index 000000000..45a6fb721 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Rest/Rest_BackendTest.php @@ -0,0 +1,344 @@ +show_in_abilities = new Show_In_Abilities(); + $this->show_in_abilities->register(); + register_initial_settings(); + + foreach ( array( 'content', 'site', 'user' ) as $category ) { + $this->ensure_ability_category( $category ); + } + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + remove_filter( 'wpai_abilities_rest_backend', '__return_true' ); + remove_filter( 'register_setting_args', array( $this->show_in_abilities, 'mark_setting' ), 10 ); + + foreach ( array( 'core/read-content', 'core/read-settings', 'core/read-users' ) as $ability ) { + if ( ! wp_has_ability( $ability ) ) { + continue; + } + + wp_unregister_ability( $ability ); + } + + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + if ( ! $object ) { + continue; + } + + unset( $object->show_in_abilities ); + } + + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Reading a post type that REST does not expose leaves no route behind for it. + * + * The post type is exposed to REST for the length of the request, which means the REST + * server built during that request carries a route the post type must not have once the + * flag is restored. That server has to go, whether or not one existed beforehand. + * + * @since x.x.x + */ + public function test_reading_an_unexposed_post_type_leaves_no_route_behind(): void { + register_post_type( + 'wpai_rest_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_rest_cpt', + 'post_status' => 'publish', + ) + ); + + $this->register_content_ability(); + + // The ability runs where no REST server has been built yet, as it does under + // WP-CLI or in any request that is not a REST request. + unset( $GLOBALS['wp_rest_server'] ); + + $result = wp_get_ability( 'core/read-content' )->execute( array( 'post_type' => 'wpai_rest_cpt' ) ); + + $this->assertNotWPError( $result, 'The unexposed post type should still be readable through the ability.' ); + $this->assertContains( $post_id, wp_list_pluck( $result['posts'], 'id' ), 'The post of the unexposed post type should be returned.' ); + + $this->assertArrayNotHasKey( + '/wp/v2/wpai_rest_cpt', + rest_get_server()->get_routes(), + 'A post type that REST does not expose should have no route left after the ability ran.' + ); + } finally { + unregister_post_type( 'wpai_rest_cpt' ); + unset( $GLOBALS['wp_rest_server'] ); + } + } + + /** + * A refused settings endpoint is reported, not answered from the stored options. + * + * The endpoint is the execution path here, so what it refuses must not come back from + * `get_option()` instead. Otherwise any policy the endpoint applies is bypassed. + * + * @since x.x.x + */ + public function test_a_refused_settings_endpoint_is_not_answered_from_the_options(): void { + $deny = static function ( $result, $server, $request ) { + return '/wp/v2/settings' === $request->get_route() + ? new WP_Error( 'rest_forbidden', 'Denied for the test.', array( 'status' => 403 ) ) + : $result; + }; + add_filter( 'rest_pre_dispatch', $deny, 10, 3 ); + + try { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $this->register_settings_ability(); + + $result = wp_get_ability( 'core/read-settings' )->execute( array( 'fields' => array( 'blogname' ) ) ); + + $this->assertWPError( $result, 'A refused settings endpoint should be reported as an error.' ); + $this->assertSame( 'rest_forbidden', $result->get_error_code(), 'The error from the endpoint should be passed on unchanged.' ); + } finally { + remove_filter( 'rest_pre_dispatch', $deny, 10 ); + } + } + + /** + * A user row that cannot be read is reported, not dropped from the page. + * + * Rows asking for sensitive fields are read one by one. When such a read fails, keeping + * the rest of the page would return fewer users than the totals promise, and the caller + * would have no way to tell a withheld user from one that does not exist. + * + * @since x.x.x + */ + public function test_a_user_row_that_cannot_be_read_is_reported(): void { + $deny = static function ( $result, $server, $request ) { + return 0 === strpos( $request->get_route(), '/wp/v2/users/' ) + ? new WP_Error( 'rest_user_cannot_view', 'Denied for the test.', array( 'status' => 403 ) ) + : $result; + }; + add_filter( 'rest_pre_dispatch', $deny, 10, 3 ); + + try { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $this->register_users_ability(); + + // `email` is a sensitive field, so the row is read through the single-user route. + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'include' => array( $admin_id ), + 'fields' => array( 'id', 'email' ), + ) + ); + + $this->assertWPError( $result, 'A user row that cannot be read should be reported as an error.' ); + $this->assertSame( 'rest_user_cannot_view', $result->get_error_code(), 'The error from the endpoint should be passed on unchanged.' ); + } finally { + remove_filter( 'rest_pre_dispatch', $deny, 10 ); + } + } + + /** + * A successful response the mapping cannot read is reported, not read as empty. + * + * @since x.x.x + */ + public function test_a_response_in_an_unexpected_shape_is_reported(): void { + $mangle = static function ( $response, $handler, $request ) { + return '/wp/v2/users' === $request->get_route() + ? new WP_REST_Response( 'malformed-success-body', 200 ) + : $response; + }; + add_filter( 'rest_request_after_callbacks', $mangle, 10, 3 ); + + try { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $this->register_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( array( 'fields' => array( 'id', 'name' ) ) ); + + $this->assertWPError( $result, 'A response the mapping cannot read should be reported as an error.' ); + $this->assertSame( 'rest_unexpected_response', $result->get_error_code(), 'The unexpected response should have its own error code.' ); + } finally { + remove_filter( 'rest_request_after_callbacks', $mangle, 10 ); + } + } + + /** + * Request parameters survive a reordered REST parameter order. + * + * Plugins may filter `rest_request_parameter_order`. When `URL` comes first, parameters + * written without naming their type land there, and dispatching replaces the URL + * parameters with the ones matched from the route, dropping them. + * + * @since x.x.x + */ + public function test_request_parameters_survive_a_reordered_parameter_order(): void { + $url_first = static function () { + return array( 'URL', 'GET', 'defaults' ); + }; + add_filter( 'rest_request_parameter_order', $url_first ); + + try { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $target_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + self::factory()->user->create( array( 'role' => 'editor' ) ); + + $this->register_users_ability(); + + $result = wp_get_ability( 'core/read-users' )->execute( + array( + 'include' => array( $target_id ), + 'fields' => array( 'id', 'name' ), + ) + ); + + $this->assertNotWPError( $result, 'The users query should succeed under a reordered parameter order.' ); + $this->assertSame( + array( $target_id ), + wp_list_pluck( $result['users'], 'id' ), + 'Only the included user should be returned, so the request parameters reached the endpoint.' + ); + } finally { + remove_filter( 'rest_request_parameter_order', $url_first ); + } + } + + /** + * Ensures an ability category exists for an ability to attach to. + * + * @since x.x.x + * + * @param string $slug The ability category slug. + */ + private function ensure_ability_category( string $slug ): void { + if ( wp_has_ability_category( $slug ) ) { + return; + } + + 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( + $slug, + array( + 'label' => ucfirst( $slug ), + 'description' => ucfirst( $slug ) . '.', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Registers the plugin's core/read-content ability inside a faked init action. + * + * @since x.x.x + */ + private function register_content_ability(): void { + global $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 { + ( new Content() )->register(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Registers the plugin's core/read-users ability inside a faked init action. + * + * @since x.x.x + */ + private function register_users_ability(): void { + global $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 { + ( new Users() )->register(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Registers the plugin's core/read-settings ability inside a faked init action. + * + * @since x.x.x + */ + private function register_settings_ability(): void { + global $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 { + ( new Settings() )->register(); + } finally { + array_pop( $wp_current_filter ); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index beba5abd8..7dfca787d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,6 +12,18 @@ define( 'WPAI_IS_TEST', true ); } +/* + * Run the core read abilities (`core/read-content`, `core/read-settings`, `core/read-users`) + * against their REST-backed execute implementations, so the same suite covers both. + * + * WPAI_ABILITIES_REST_BACKEND=1 npm run test:php + * + * @see \WordPress\AI\Abilities\Rest\Rest_Backend + */ +if ( ! defined( 'WPAI_ABILITIES_REST_BACKEND' ) ) { + define( 'WPAI_ABILITIES_REST_BACKEND', (bool) getenv( 'WPAI_ABILITIES_REST_BACKEND' ) ); +} + // Load Composer dependencies if applicable. if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/autoload.php' ) ) { require_once TESTS_REPO_ROOT_DIR . '/vendor/autoload.php';