' .
- '
' .
- '' .
- '
' .
- '
' .
+
+ if ( $include_msg_preview ) {
+ $output = '';
+ ob_start();
+
+ if ( in_array( $extension, bp_get_document_preview_music_extensions(), true ) ) {
+ $audio_url = bp_document_get_preview_audio_url( $document->id, $document->attachment_id, $extension );
+
+ echo '
' .
+ '
' .
+ '' .
+ esc_html__( 'Your browser does not support the audio element.', 'buddyboss' ) .
+ ' ' .
'
';
- if ( true === $more_text ) {
- printf(
- /* translators: %s: download string */
- '
%s
',
- sprintf(
- /* translators: %s: download url */
- wp_kses_post( 'This file was truncated for preview. Please
download to view the full file.', 'buddyboss' ),
- esc_url( $data['download_url'] )
- )
- );
+ }
+
+ if ( function_exists( 'bp_document_get_preview_url' ) ) {
+ $attachment_url = bp_document_get_preview_url( $document->id, $document->attachment_id );
+ } else {
+ $attachment_url = bp_document_get_preview_image_url( $document->id, $extension, $document->attachment_id );
+ }
+
+ if ( $attachment_url ) {
+ echo '
' .
+ '
' .
+ '
';
+ }
+ $sizes = is_file( get_attached_file( $document->attachment_id ) ) ? get_attached_file( $document->attachment_id ) : 0;
+ if ( $sizes && filesize( $sizes ) / 1e+6 < 2 ) {
+ if ( in_array( $extension, bp_get_document_preview_code_extensions(), true ) ) {
+ $data_temp = bp_document_get_preview_text_from_attachment( $document->attachment_id );
+ $file_data = $data_temp['text'];
+ $more_text = $data_temp['more_text'];
+
+ echo '
' .
+ '
' .
+ '' .
+ '
' .
+ '
' .
+ '
';
+
+ if ( true === $more_text ) {
+ printf(
+ /* translators: %s: download string */
+ '
%s
',
+ sprintf(
+ /* translators: %s: download url */
+ wp_kses_post( 'This file was truncated for preview. Please
download to view the full file.', 'buddyboss' ),
+ esc_url( $download_url )
+ )
+ );
+ }
}
}
- }
- $output .= ob_get_clean();
+ $output .= ob_get_clean();
- $data['msg_preview'] = $output;
+ $data['msg_preview'] = $output;
+ }
} else {
- $child_doc = count( bp_document_get_folder_document_ids( $document->id ) );
- $child_folder = count( $this->bp_document_get_folder_children_ids( $document->id ) );
- $data['count'] = (int) $child_doc + (int) $child_folder;
- $data['svg_icon'] = bp_document_svg_icon( 'folder', '', 'svg' );
- $data['download_url'] = bp_document_folder_download_link( $document->id );
+ if ( $include_count ) {
+ $child_doc = count( bp_document_get_folder_document_ids( $document->id ) );
+ $child_folder = count( $this->bp_document_get_folder_children_ids( $document->id ) );
+ $data['count'] = (int) $child_doc + (int) $child_folder;
+ }
+
+ if ( $include_svg_icon ) {
+ $data['svg_icon'] = bp_document_svg_icon( 'folder', '', 'svg' );
+ }
+
+ if ( $include_download_url ) {
+ $data['download_url'] = bp_document_folder_download_link( $document->id );
+ }
}
return $data;
@@ -1494,6 +1643,15 @@ public function document_get_prepare_response( $document, $request ) {
* @since 0.1.0
*/
public function get_item_schema() {
+ if ( ! empty( $this->schema ) ) {
+ /**
+ * Filters the document schema.
+ *
+ * @param array $schema The endpoint schema.
+ */
+ return apply_filters( 'bp_rest_document_schema', $this->add_additional_fields_schema( $this->schema ) );
+ }
+
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'bp_document',
@@ -1717,12 +1875,22 @@ public function get_item_schema() {
),
);
- /**
- * Filters the document schema.
- *
- * @param array $schema The endpoint schema.
+ /*
+ * A response field the controller has always returned but never
+ * declared. Appended rather than written into the array above so
+ * that the surrounding alignment is left alone.
*/
- return apply_filters( 'bp_rest_document_schema', $this->add_additional_fields_schema( $schema ) );
+ $schema['properties']['user_permissions'] = array(
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'description' => __( 'Current user\'s permission with the document.', 'buddyboss' ),
+ 'readonly' => true,
+ 'type' => 'object',
+ );
+
+ $this->schema = $schema;
+
+ /** This filter is documented in bp-document/classes/class-bp-rest-document-endpoint.php */
+ return apply_filters( 'bp_rest_document_schema', $this->add_additional_fields_schema( $this->schema ) );
}
/**
@@ -1734,6 +1902,8 @@ public function get_item_schema() {
public function get_collection_params() {
$params = parent::get_collection_params();
+ $params['embed_fields'] = bb_rest_embed_fields_param();
+
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'buddyboss' ),
'default' => 'asc',
@@ -2350,10 +2520,11 @@ public function bp_rest_document_support() {
*
* @param BP_Activity_Activity $activity Activity Array.
* @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return string The value of the REST Field to include into the REST response.
*/
- protected function bp_documents_get_rest_field_callback( $activity, $attribute ) {
+ protected function bp_documents_get_rest_field_callback( $activity, $attribute, $request = null ) {
$activity_id = $activity['id'];
if ( empty( $activity_id ) ) {
@@ -2423,6 +2594,7 @@ protected function bp_documents_get_rest_field_callback( $activity, $attribute )
$object = new WP_REST_Request();
$object->set_param( 'support', 'activity' );
$object->set_param( 'context', 'view' );
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $documents['documents'] as $document ) {
$retval[] = $this->prepare_response_for_collection(
@@ -2665,12 +2837,13 @@ protected function bp_rest_user_can_comment_upload_document( $activity, $attribu
/**
* The function to use to get documents of the messages REST Field.
*
- * @param array $data The message value for the REST response.
- * @param string $attribute The REST Field key used into the REST response.
+ * @param array $data The message value for the REST response.
+ * @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return array|void The value of the REST Field to include into the REST response.
*/
- protected function bp_documents_get_rest_field_callback_messages( $data, $attribute ) {
+ protected function bp_documents_get_rest_field_callback_messages( $data, $attribute, $request = null ) {
$message_id = $data['id'];
if ( empty( $message_id ) ) {
@@ -2730,6 +2903,7 @@ protected function bp_documents_get_rest_field_callback_messages( $data, $attrib
$retval = array();
$object = new WP_REST_Request();
$object->set_param( 'support', 'message' );
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $documents['documents'] as $document ) {
$retval[] = $this->prepare_response_for_collection(
@@ -2832,12 +3006,13 @@ protected function bp_documents_update_rest_field_callback_messages( $object, $v
/**
* The function to use to get documents of the topic/reply REST Field.
*
- * @param array $post WP_Post object as array.
- * @param string $attribute The REST Field key used into the REST response.
+ * @param array $post WP_Post object as array.
+ * @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return string The value of the REST Field to include into the REST response.
*/
- protected function bbp_document_get_rest_field_callback( $post, $attribute ) {
+ protected function bbp_document_get_rest_field_callback( $post, $attribute, $request = null ) {
$p_id = $post['id'];
@@ -2905,6 +3080,7 @@ protected function bbp_document_get_rest_field_callback( $post, $attribute ) {
$retval = array();
$object = new WP_REST_Request();
$object->set_param( 'support', 'forums' );
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $documents['documents'] as $document ) {
$retval[] = $this->prepare_response_for_collection(
diff --git a/src/bp-groups/classes/class-bp-rest-groups-endpoint.php b/src/bp-groups/classes/class-bp-rest-groups-endpoint.php
index e9ffa9759d1..795d16a1040 100644
--- a/src/bp-groups/classes/class-bp-rest-groups-endpoint.php
+++ b/src/bp-groups/classes/class-bp-rest-groups-endpoint.php
@@ -130,6 +130,7 @@ public function register_routes() {
* @apiParam {Boolean} [show_hidden] Whether results should include hidden Groups.
* @apiParam {String=all,personal} [scope=all] Limit result set to items with a specific scope.
* @apiParam {Boolean} [can_post] Fetch current users groups which can post activity in it.
+ * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time.
*/
public function get_items( $request ) {
$args = array(
@@ -297,6 +298,7 @@ public function get_items_permissions_check( $request ) {
* @apiVersion 1.0.0
* @apiPermission LoggedInUser if the site is in Private Network.
* @apiParam {Number} id A unique numeric ID for the Group.
+ * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time.
*/
public function get_item( $request ) {
$group = $this->get_group_object( $request );
@@ -634,7 +636,7 @@ public function delete_item( $request ) {
// Get the group before it's deleted.
$group = $this->get_group_object( $request );
- $previous = $this->prepare_item_for_response( $group, $request );
+ $previous = $this->prepare_item_for_response( $group, bb_rest_request_for_nested_item( $request ) );
// Delete group forum.
if ( isset( $request['delete_group_forum'] ) && true === $request['delete_group_forum'] ) {
@@ -738,53 +740,177 @@ public function delete_item_permissions_check( $request ) {
* @since 0.1.0
*/
public function prepare_item_for_response( $item, $request ) {
- $data = array(
- 'id' => $item->id,
- 'creator_id' => bp_get_group_creator_id( $item ),
- 'parent_id' => $item->parent_id,
- 'date_created' => bp_rest_prepare_date_response( $item->date_created ),
- 'description' => array(
+ /*
+ * The fields the request asked for. When the request carries no
+ * `_fields`, this is every property of the item schema, so each of the
+ * branches below runs exactly as it did before the controller became
+ * field-aware.
+ */
+ $fields = $this->get_fields_for_response( $request );
+
+ $include_types = rest_is_field_included( 'types', $fields );
+ $include_group_type = rest_is_field_included( 'group_type', $fields );
+ $include_group_type_label = rest_is_field_included( 'group_type_label', $fields );
+ $include_role = rest_is_field_included( 'role', $fields );
+ $include_plural_role = rest_is_field_included( 'plural_role', $fields );
+ $include_admins = rest_is_field_included( 'admins', $fields );
+ $include_mods = rest_is_field_included( 'mods', $fields );
+
+ /*
+ * Held in locals: the group type block refines them and the plural
+ * role falls back to the singular, so no guarded field may depend on
+ * a value another guard produced.
+ */
+ $types = ( $include_types || $include_group_type ) ? bp_groups_get_group_type( $item->id, false ) : array();
+ $group_type_label = ( $include_group_type_label || $include_group_type ) ? $this->get_group_type_label( $item ) : '';
+ $role = '';
+
+ $data = array();
+
+ $data['id'] = $item->id;
+
+ if ( rest_is_field_included( 'creator_id', $fields ) ) {
+ $data['creator_id'] = bp_get_group_creator_id( $item );
+ }
+
+ if ( rest_is_field_included( 'parent_id', $fields ) ) {
+ $data['parent_id'] = $item->parent_id;
+ }
+
+ if ( rest_is_field_included( 'date_created', $fields ) ) {
+ $data['date_created'] = bp_rest_prepare_date_response( $item->date_created );
+ }
+
+ if ( rest_is_field_included( 'description', $fields ) ) {
+ $data['description'] = array(
'raw' => $item->description,
'rendered' => bp_get_group_description( $item ),
- ),
- 'enable_forum' => $this->bp_rest_group_is_forum_enabled( $item ),
- 'link' => bp_get_group_permalink( $item ),
- 'name' => bp_get_group_name( $item ),
- 'name_raw' => $item->name,
- 'slug' => bp_get_group_slug( $item ),
- 'status' => bp_get_group_status( $item ),
- 'types' => bp_groups_get_group_type( $item->id, false ),
- 'group_type_label' => $this->get_group_type_label( $item ),
- 'subgroups_id' => $this->bp_rest_get_sub_groups( $item->id ),
- 'admins' => array(),
- 'mods' => array(),
- 'total_member_count' => null,
- 'last_activity' => null,
- 'is_member' => groups_is_user_member( get_current_user_id(), $item->id ) ? true : false,
- 'invite_id' => groups_is_user_invited( get_current_user_id(), $item->id ),
- 'request_id' => groups_is_user_pending( get_current_user_id(), $item->id ),
- 'is_admin' => ( ! empty( groups_is_user_admin( get_current_user_id(), $item->id ) ) ? true : false ),
- 'is_mod' => ( ! empty( groups_is_user_mod( get_current_user_id(), $item->id ) ) ? true : false ),
- 'members_count' => groups_get_total_member_count( $item->id ),
- 'role' => '',
- 'plural_role' => '',
- 'can_join' => $this->bp_rest_user_can_join( $item ),
- 'can_post' => $this->bp_rest_user_can_post( $item ),
- 'create_media' => ( bp_is_active( 'media' ) && groups_can_user_manage_media( bp_loggedin_user_id(), $item->id ) ),
- 'create_album' => ( bp_is_active( 'media' ) && groups_can_user_manage_albums( bp_loggedin_user_id(), $item->id ) ),
- 'create_video' => ( bp_is_active( 'video' ) && groups_can_user_manage_video( bp_loggedin_user_id(), $item->id ) ),
- 'create_document' => ( bp_is_active( 'document' ) && groups_can_user_manage_document( bp_loggedin_user_id(), $item->id ) ),
- 'can_schedule' => function_exists( 'bb_is_enabled_activity_schedule_posts' ) &&
- bb_is_enabled_activity_schedule_posts() &&
- function_exists( 'bb_can_user_schedule_activity' ) &&
- bb_can_user_schedule_activity(
- array(
- 'object' => 'group',
- 'group_id' => $item->id,
- 'user_id' => bp_loggedin_user_id(),
- )
- ),
- 'can_create_poll' => function_exists( 'bb_is_enabled_activity_post_polls' ) &&
+ );
+ }
+
+ if ( rest_is_field_included( 'enable_forum', $fields ) ) {
+ $data['enable_forum'] = $this->bp_rest_group_is_forum_enabled( $item );
+ }
+
+ if ( rest_is_field_included( 'link', $fields ) ) {
+ $data['link'] = bp_get_group_permalink( $item );
+ }
+
+ if ( rest_is_field_included( 'name', $fields ) ) {
+ $data['name'] = bp_get_group_name( $item );
+ }
+
+ if ( rest_is_field_included( 'name_raw', $fields ) ) {
+ $data['name_raw'] = $item->name;
+ }
+
+ if ( rest_is_field_included( 'slug', $fields ) ) {
+ $data['slug'] = bp_get_group_slug( $item );
+ }
+
+ if ( rest_is_field_included( 'status', $fields ) ) {
+ $data['status'] = bp_get_group_status( $item );
+ }
+
+ if ( $include_types ) {
+ $data['types'] = $types;
+ }
+
+ if ( $include_group_type_label ) {
+ $data['group_type_label'] = $group_type_label;
+ }
+
+ if ( rest_is_field_included( 'subgroups_id', $fields ) ) {
+ $data['subgroups_id'] = $this->bp_rest_get_sub_groups( $item->id );
+ }
+
+ if ( rest_is_field_included( 'admins', $fields ) ) {
+ $data['admins'] = array();
+ }
+
+ if ( rest_is_field_included( 'mods', $fields ) ) {
+ $data['mods'] = array();
+ }
+
+ if ( rest_is_field_included( 'total_member_count', $fields ) ) {
+ $data['total_member_count'] = null;
+ }
+
+ if ( rest_is_field_included( 'last_activity', $fields ) ) {
+ $data['last_activity'] = null;
+ }
+
+ if ( rest_is_field_included( 'is_member', $fields ) ) {
+ $data['is_member'] = groups_is_user_member( get_current_user_id(), $item->id ) ? true : false;
+ }
+
+ if ( rest_is_field_included( 'invite_id', $fields ) ) {
+ $data['invite_id'] = groups_is_user_invited( get_current_user_id(), $item->id );
+ }
+
+ if ( rest_is_field_included( 'request_id', $fields ) ) {
+ $data['request_id'] = groups_is_user_pending( get_current_user_id(), $item->id );
+ }
+
+ if ( rest_is_field_included( 'is_admin', $fields ) ) {
+ $data['is_admin'] = ( ! empty( groups_is_user_admin( get_current_user_id(), $item->id ) ) ? true : false );
+ }
+
+ if ( rest_is_field_included( 'is_mod', $fields ) ) {
+ $data['is_mod'] = ( ! empty( groups_is_user_mod( get_current_user_id(), $item->id ) ) ? true : false );
+ }
+
+ if ( rest_is_field_included( 'members_count', $fields ) ) {
+ $data['members_count'] = groups_get_total_member_count( $item->id );
+ }
+
+ if ( $include_role ) {
+ $data['role'] = '';
+ }
+
+ if ( $include_plural_role ) {
+ $data['plural_role'] = '';
+ }
+
+ if ( rest_is_field_included( 'can_join', $fields ) ) {
+ $data['can_join'] = $this->bp_rest_user_can_join( $item );
+ }
+
+ if ( rest_is_field_included( 'can_post', $fields ) ) {
+ $data['can_post'] = $this->bp_rest_user_can_post( $item );
+ }
+
+ if ( rest_is_field_included( 'create_media', $fields ) ) {
+ $data['create_media'] = ( bp_is_active( 'media' ) && groups_can_user_manage_media( bp_loggedin_user_id(), $item->id ) );
+ }
+
+ if ( rest_is_field_included( 'create_album', $fields ) ) {
+ $data['create_album'] = ( bp_is_active( 'media' ) && groups_can_user_manage_albums( bp_loggedin_user_id(), $item->id ) );
+ }
+
+ if ( rest_is_field_included( 'create_video', $fields ) ) {
+ $data['create_video'] = ( bp_is_active( 'video' ) && groups_can_user_manage_video( bp_loggedin_user_id(), $item->id ) );
+ }
+
+ if ( rest_is_field_included( 'create_document', $fields ) ) {
+ $data['create_document'] = ( bp_is_active( 'document' ) && groups_can_user_manage_document( bp_loggedin_user_id(), $item->id ) );
+ }
+
+ if ( rest_is_field_included( 'can_schedule', $fields ) ) {
+ $data['can_schedule'] = function_exists( 'bb_is_enabled_activity_schedule_posts' ) &&
+ bb_is_enabled_activity_schedule_posts() &&
+ function_exists( 'bb_can_user_schedule_activity' ) &&
+ bb_can_user_schedule_activity(
+ array(
+ 'object' => 'group',
+ 'group_id' => $item->id,
+ 'user_id' => bp_loggedin_user_id(),
+ )
+ );
+ }
+
+ if ( rest_is_field_included( 'can_create_poll', $fields ) ) {
+ $data['can_create_poll'] = function_exists( 'bb_is_enabled_activity_post_polls' ) &&
bb_is_enabled_activity_post_polls( false ) &&
function_exists( 'bb_can_user_create_poll_activity' ) &&
bb_can_user_create_poll_activity(
@@ -793,40 +919,48 @@ function_exists( 'bb_can_user_create_poll_activity' ) &&
'group_id' => $item->id,
'user_id' => bp_loggedin_user_id(),
)
- ),
- );
+ );
+ }
- // BuddyBoss Platform support.
- if ( function_exists( 'bp_get_user_group_role_title' ) && bp_loggedin_user_id() ) {
- $data['role'] = bp_get_user_group_role_title( bp_loggedin_user_id(), $item->id );
+ if ( $include_role || $include_plural_role ) {
+ // BuddyBoss Platform support.
+ if ( function_exists( 'bp_get_user_group_role_title' ) && bp_loggedin_user_id() ) {
+ $role = bp_get_user_group_role_title( bp_loggedin_user_id(), $item->id );
- // BuddyPress support.
- } elseif ( function_exists( 'bp_groups_get_group_roles' ) && bp_loggedin_user_id() ) {
- $group_role = bp_groups_get_group_roles();
+ // BuddyPress support.
+ } elseif ( function_exists( 'bp_groups_get_group_roles' ) && bp_loggedin_user_id() ) {
+ $group_role = bp_groups_get_group_roles();
- if ( groups_is_user_admin( bp_loggedin_user_id(), $item->id ) ) {
- $data['role'] = $group_role['admin']->name;
- } elseif ( groups_is_user_mod( bp_loggedin_user_id(), $item->id ) ) {
- $data['role'] = $group_role['mod']->name;
- } elseif ( groups_is_user_member( bp_loggedin_user_id(), $item->id ) ) {
- $data['role'] = $group_role['member']->name;
+ if ( groups_is_user_admin( bp_loggedin_user_id(), $item->id ) ) {
+ $role = $group_role['admin']->name;
+ } elseif ( groups_is_user_mod( bp_loggedin_user_id(), $item->id ) ) {
+ $role = $group_role['mod']->name;
+ } elseif ( groups_is_user_member( bp_loggedin_user_id(), $item->id ) ) {
+ $role = $group_role['member']->name;
+ }
+ }
+
+ if ( $include_role ) {
+ $data['role'] = $role;
}
}
- if ( function_exists( 'bp_get_group_member_section_title' ) && bp_loggedin_user_id() ) {
- $data['plural_role'] = $this->bp_get_group_member_section_title( $item->id, bp_loggedin_user_id() );
- if ( empty( $data['plural_role'] ) ) {
- $data['plural_role'] = $data['role'];
+ if ( $include_plural_role ) {
+ if ( function_exists( 'bp_get_group_member_section_title' ) && bp_loggedin_user_id() ) {
+ $data['plural_role'] = $this->bp_get_group_member_section_title( $item->id, bp_loggedin_user_id() );
+ if ( empty( $data['plural_role'] ) ) {
+ $data['plural_role'] = $role;
+ }
+ } else {
+ $data['plural_role'] = $role;
}
- } else {
- $data['plural_role'] = $data['role'];
}
// Get item schema.
$schema = $this->get_item_schema();
// Avatars.
- if ( ! empty( $schema['properties']['avatar_urls'] ) ) {
+ if ( ! empty( $schema['properties']['avatar_urls'] ) && rest_is_field_included( 'avatar_urls', $fields ) ) {
$data['avatar_urls'] = array(
'thumb' => bp_core_fetch_avatar(
array(
@@ -848,36 +982,52 @@ function_exists( 'bb_can_user_create_poll_activity' ) &&
);
}
- // Cover Image.
+ /*
+ * Cover image.
+ *
+ * Deliberately NOT gated on `_fields`. Consumers downstream of this
+ * controller read `cover_url` off the prepared group, and when it is
+ * absent they resolve it themselves -- once per group, against the
+ * attachment store. On a hosted install that is roughly 107 ms an item,
+ * so a page of twenty that omitted the field answered in ~2.9 s where
+ * the same page including it answered in ~0.7 s. Declining to build a
+ * field must never cost more than building it, and here it did.
+ *
+ * Measured on the dev host, 25 August 2026.
+ */
if ( ! empty( $schema['properties']['cover_url'] ) && function_exists( 'bp_get_group_cover_url' ) ) {
$data['cover_url'] = bp_get_group_cover_url( $item );
$data['cover_is_default'] = ! bp_attachments_get_group_has_cover_image( $item->id );
}
- if ( $this->bp_rest_group_is_forum_enabled( $item ) && function_exists( 'bbpress' ) ) {
+ if ( rest_is_field_included( 'forum', $fields ) && $this->bp_rest_group_is_forum_enabled( $item ) && function_exists( 'bbpress' ) ) {
$data['forum'] = groups_get_groupmeta( $item->id, 'forum_id' );
if ( is_array( $data['forum'] ) && ! empty( $data['forum'][0] ) ) {
$data['forum'] = $data['forum'][0];
} else {
$data['forum'] = 0;
}
- } else {
+ } elseif ( rest_is_field_included( 'forum', $fields ) ) {
$data['forum'] = 0;
}
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
// Get group type(s).
- if ( false === $data['types'] ) {
- $data['types'] = array();
+ if ( false === $types ) {
+ $types = array();
+ }
+
+ if ( $include_types ) {
+ $data['types'] = $types;
}
- if ( ! empty( $data['types'] ) ) {
+ if ( $include_group_type && ! empty( $types ) ) {
$group_type_data = array();
- $group_type_data['group_type_label'] = isset( $data['group_type_label'] ) && ! empty( $data['group_type_label'] ) ? $data['group_type_label'] : '';
+ $group_type_data['group_type_label'] = ! empty( $group_type_label ) ? $group_type_label : '';
$group_type_data['types'] = bp_groups_get_group_type( $item->id, false );
// Group type's label background and text color.
- $group_type = isset( $data['types'][0] ) ? $data['types'][0] : '';
+ $group_type = isset( $types[0] ) ? $types[0] : '';
$label_color_data = function_exists( 'bb_get_group_type_label_colors' ) ? bb_get_group_type_label_colors( $group_type ) : '';
if ( ! empty( $label_color_data ) ) {
$group_type_data['label_colors'] = $label_color_data;
@@ -915,20 +1065,28 @@ function_exists( 'bb_can_user_create_poll_activity' ) &&
// If this is the 'edit' context, fill in more details--similar to "populate_extras".
if ( 'edit' === $context || 'view' === $context ) {
- $data['last_activity'] = bp_rest_prepare_date_response( groups_get_groupmeta( $item->id, 'last_activity' ) );
+ if ( rest_is_field_included( 'last_activity', $fields ) ) {
+ $data['last_activity'] = bp_rest_prepare_date_response( groups_get_groupmeta( $item->id, 'last_activity' ) );
+ }
// Add admins and moderators to their respective arrays.
$args = array( 'admin' );
if ( 'edit' === $context ) {
- $args[] = 'mod';
- $data['total_member_count'] = groups_get_total_member_count( $item->id );
+ $args[] = 'mod';
+
+ if ( rest_is_field_included( 'total_member_count', $fields ) ) {
+ $data['total_member_count'] = groups_get_total_member_count( $item->id );
+ }
}
- $admin_mods = groups_get_group_members(
- array(
- 'group_id' => $item->id,
- 'group_role' => $args,
+
+ $admin_mods = ( $include_admins || $include_mods )
+ ? groups_get_group_members(
+ array(
+ 'group_id' => $item->id,
+ 'group_role' => $args,
+ )
)
- );
+ : array( 'members' => array() );
foreach ( (array) $admin_mods['members'] as $user ) {
$user->avatar = bp_core_fetch_avatar(
@@ -952,22 +1110,30 @@ function_exists( 'bb_can_user_create_poll_activity' ) &&
unset( $user->{$private_key} );
}
- if ( ! empty( $user->is_admin ) ) {
+ if ( $include_admins && ! empty( $user->is_admin ) ) {
$data['admins'][] = $user;
- } elseif ( ! empty( $user->is_mod ) ) {
+ } elseif ( $include_mods && ! empty( $user->is_mod ) ) {
$data['mods'][] = $user;
}
}
}
// Member subscribed the group or not?
- if ( function_exists( 'bb_is_enabled_subscription' ) && bb_is_enabled_subscription( 'group' ) ) {
+ $include_subscription = rest_is_field_included( 'is_subscribed', $fields ) || rest_is_field_included( 'subscribed_id', $fields );
+
+ if ( $include_subscription && function_exists( 'bb_is_enabled_subscription' ) && bb_is_enabled_subscription( 'group' ) ) {
$subscribed = 0;
if ( is_user_logged_in() && function_exists( 'bb_is_member_subscribed_group' ) ) {
$subscribed = bb_is_member_subscribed_group( $item->id, bp_loggedin_user_id() );
}
- $data['is_subscribed'] = ! empty( $subscribed );
- $data['subscribed_id'] = empty( $subscribed ) ? 0 : $subscribed;
+
+ if ( rest_is_field_included( 'is_subscribed', $fields ) ) {
+ $data['is_subscribed'] = ! empty( $subscribed );
+ }
+
+ if ( rest_is_field_included( 'subscribed_id', $fields ) ) {
+ $data['subscribed_id'] = empty( $subscribed ) ? 0 : $subscribed;
+ }
}
$data = $this->add_additional_fields_to_object( $data, $request );
@@ -1319,6 +1485,15 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
* @since 0.1.0
*/
public function get_item_schema() {
+ if ( ! empty( $this->schema ) ) {
+ /**
+ * Filters the group schema.
+ *
+ * @param array $schema The endpoint schema.
+ */
+ return apply_filters( 'bp_rest_group_schema', $this->add_additional_fields_schema( $this->schema ) );
+ }
+
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'bp_groups',
@@ -1624,6 +1799,18 @@ public function get_item_schema() {
),
);
+ /*
+ * A response field the controller has always returned but never
+ * declared. Appended rather than written into the array above so
+ * that the surrounding alignment is left alone.
+ */
+ $schema['properties']['can_create_poll'] = array(
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'description' => __( 'Whether the current user can create a poll in the group.', 'buddyboss' ),
+ 'readonly' => true,
+ 'type' => 'boolean',
+ );
+
// Avatars.
if ( ! bp_disable_group_avatar_uploads() ) {
$avatar_properties = array();
@@ -1699,7 +1886,9 @@ public function get_item_schema() {
*
* @param array $schema The endpoint schema.
*/
- return apply_filters( 'bp_rest_group_schema', $this->add_additional_fields_schema( $schema ) );
+ $this->schema = $schema;
+
+ return apply_filters( 'bp_rest_group_schema', $this->add_additional_fields_schema( $this->schema ) );
}
/**
@@ -1712,6 +1901,8 @@ public function get_collection_params() {
$params = parent::get_collection_params();
$params['context']['default'] = 'view';
+ $params['embed_fields'] = bb_rest_embed_fields_param();
+
$params['type'] = array(
'description' => __( 'Shorthand for certain orderby/order combinations.', 'buddyboss' ),
'default' => 'active',
diff --git a/src/bp-loader.php b/src/bp-loader.php
index 6892050e6cb..81eb3a40525 100644
--- a/src/bp-loader.php
+++ b/src/bp-loader.php
@@ -355,6 +355,9 @@ function buddypress() {
// load the member switch class so all the hook prior to bp_init can be hook in.
require dirname( __FILE__ ) . '/bp-members/classes/class-bp-core-members-switching.php';
+ // TEMPORARY: selective-fields performance tooling. Delete this line and `src/bb-perf-lab/` to remove it.
+ require dirname( __FILE__ ) . '/bb-perf-lab/bb-perf-lab.php';
+
/*
* Hook BuddyPress early onto the 'plugins_loaded' action.
*
diff --git a/src/bp-media/classes/class-bp-rest-media-endpoint.php b/src/bp-media/classes/class-bp-rest-media-endpoint.php
index 899c804dd66..6a815bcf6d2 100644
--- a/src/bp-media/classes/class-bp-rest-media-endpoint.php
+++ b/src/bp-media/classes/class-bp-rest-media-endpoint.php
@@ -170,6 +170,7 @@ public function register_routes() {
* @apiParam {Array} [exclude] Ensure result set excludes specific IDs.
* @apiParam {Array} [include] Ensure result set includes specific IDs.
* @apiParam {Boolean} [count_total=true] Show total count or not.
+ * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time.
*/
public function get_items( $request ) {
$args = array(
@@ -343,6 +344,7 @@ public function get_items_permissions_check( $request ) {
* @apiVersion 1.0.0
* @apiPermission LoggedInUser if the site is in Private Network.
* @apiParam {Number} id A unique numeric ID for the media photo.
+ * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time.
*/
public function get_item( $request ) {
@@ -997,7 +999,7 @@ public function delete_items( $request ) {
$previous = array();
foreach ( $medias['medias'] as $media ) {
$previous[] = $this->prepare_response_for_collection(
- $this->prepare_item_for_response( $media, $request )
+ $this->prepare_item_for_response( $media, bb_rest_request_for_nested_item( $request ) )
);
}
@@ -1120,7 +1122,7 @@ public function delete_item( $request ) {
$previous = '';
foreach ( $medias['medias'] as $media ) {
$previous = $this->prepare_response_for_collection(
- $this->prepare_item_for_response( $media, $request )
+ $this->prepare_item_for_response( $media, bb_rest_request_for_nested_item( $request ) )
);
}
@@ -1450,36 +1452,105 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
* @since 0.1.0
*/
public function prepare_item_for_response( $media, $request ) {
- $data = array(
- 'id' => $media->id,
- 'blog_id' => $media->blog_id,
- 'attachment_id' => $media->attachment_id,
- 'user_id' => $media->user_id,
- 'title' => $media->title,
- 'description' => wp_specialchars_decode( $media->description, ENT_QUOTES ),
- 'album_id' => $media->album_id,
- 'group_id' => $media->group_id,
- 'activity_id' => $media->activity_id,
- 'message_id' => $media->message_id,
- 'hide_activity_actions' => false,
- 'privacy' => $media->privacy,
- 'menu_order' => $media->menu_order,
- 'date_created' => $media->date_created,
- 'attachment_data' => $media->attachment_data,
- 'group_name' => ( isset( $media->group_name ) ? $media->group_name : '' ),
- 'visibility' => ( isset( $media->visibility ) ? $media->visibility : '' ),
- 'user_nicename' => get_the_author_meta( 'user_nicename', $media->user_id ),
- 'user_login' => get_the_author_meta( 'user_login', $media->user_id ),
- 'display_name' => bp_core_get_user_displayname( $media->user_id ),
- 'url' => bp_media_get_preview_image_url( $media->id, $media->attachment_id, 'bb-media-photos-popup-image' ),
- 'download_url' => bp_media_download_link( $media->attachment_id, $media->id ),
- 'user_permissions' => $this->get_media_current_user_permissions( $media ),
- 'type' => $media->type,
- );
+ /*
+ * The fields the request asked for. When the request carries no
+ * `_fields`, this is every property of the item schema, so each of the
+ * branches below runs exactly as it did before the controller became
+ * field-aware.
+ */
+ $fields = $this->get_fields_for_response( $request );
+
+ // Both are resolved twice: once here, and again for a video below.
+ $include_url = rest_is_field_included( 'url', $fields );
+ $include_download_url = rest_is_field_included( 'download_url', $fields );
+ $include_hide_activity_actions = rest_is_field_included( 'hide_activity_actions', $fields );
+
+ $data = array();
+
+ $data['id'] = $media->id;
+
+ if ( rest_is_field_included( 'blog_id', $fields ) ) {
+ $data['blog_id'] = $media->blog_id;
+ }
+ if ( rest_is_field_included( 'attachment_id', $fields ) ) {
+ $data['attachment_id'] = $media->attachment_id;
+ }
+ if ( rest_is_field_included( 'user_id', $fields ) ) {
+ $data['user_id'] = $media->user_id;
+ }
+ if ( rest_is_field_included( 'title', $fields ) ) {
+ $data['title'] = $media->title;
+ }
+ if ( rest_is_field_included( 'description', $fields ) ) {
+ $data['description'] = wp_specialchars_decode( $media->description, ENT_QUOTES );
+ }
+ if ( rest_is_field_included( 'album_id', $fields ) ) {
+ $data['album_id'] = $media->album_id;
+ }
+ if ( rest_is_field_included( 'group_id', $fields ) ) {
+ $data['group_id'] = $media->group_id;
+ }
+ if ( rest_is_field_included( 'activity_id', $fields ) ) {
+ $data['activity_id'] = $media->activity_id;
+ }
+ if ( rest_is_field_included( 'message_id', $fields ) ) {
+ $data['message_id'] = $media->message_id;
+ }
+
+ if ( $include_hide_activity_actions ) {
+ $data['hide_activity_actions'] = false;
+ }
+
+ if ( rest_is_field_included( 'privacy', $fields ) ) {
+ $data['privacy'] = $media->privacy;
+ }
+ if ( rest_is_field_included( 'menu_order', $fields ) ) {
+ $data['menu_order'] = $media->menu_order;
+ }
+ if ( rest_is_field_included( 'date_created', $fields ) ) {
+ $data['date_created'] = $media->date_created;
+ }
+ if ( rest_is_field_included( 'attachment_data', $fields ) ) {
+ $data['attachment_data'] = $media->attachment_data;
+ }
+ if ( rest_is_field_included( 'group_name', $fields ) ) {
+ $data['group_name'] = ( isset( $media->group_name ) ? $media->group_name : '' );
+ }
+ if ( rest_is_field_included( 'visibility', $fields ) ) {
+ $data['visibility'] = ( isset( $media->visibility ) ? $media->visibility : '' );
+ }
+
+ if ( rest_is_field_included( 'user_nicename', $fields ) ) {
+ $data['user_nicename'] = get_the_author_meta( 'user_nicename', $media->user_id );
+ }
+
+ if ( rest_is_field_included( 'user_login', $fields ) ) {
+ $data['user_login'] = get_the_author_meta( 'user_login', $media->user_id );
+ }
+
+ if ( rest_is_field_included( 'display_name', $fields ) ) {
+ $data['display_name'] = bp_core_get_user_displayname( $media->user_id );
+ }
+
+ if ( $include_url ) {
+ $data['url'] = bp_media_get_preview_image_url( $media->id, $media->attachment_id, 'bb-media-photos-popup-image' );
+ }
+
+ if ( $include_download_url ) {
+ $data['download_url'] = bp_media_download_link( $media->attachment_id, $media->id );
+ }
+
+ if ( rest_is_field_included( 'user_permissions', $fields ) ) {
+ $data['user_permissions'] = $this->get_media_current_user_permissions( $media );
+ }
+
+ if ( rest_is_field_included( 'type', $fields ) ) {
+ $data['type'] = $media->type;
+ }
// Below condition will check if media has comments then like/comment button will not visible for that particular media.
- if ( ! empty( $data['activity_id'] ) && bp_is_active( 'activity' ) ) {
- $activity = new BP_Activity_Activity( $data['activity_id'] );
+ if ( $include_hide_activity_actions && ! empty( $media->activity_id ) && bp_is_active( 'activity' ) ) {
+ $activity = new BP_Activity_Activity( $media->activity_id );
if ( isset( $activity->secondary_item_id ) ) {
$get_activity = new BP_Activity_Activity( $activity->secondary_item_id );
if (
@@ -1495,12 +1566,16 @@ public function prepare_item_for_response( $media, $request ) {
}
if ( 'video' === $media->type ) {
- add_filter( 'bb_check_ios_device', array( $this, 'bb_rest_disable_symlink' ), 1 );
- $data['url'] = bb_video_get_symlink( $media->id );
- remove_filter( 'bb_check_ios_device', array( $this, 'bb_rest_disable_symlink' ), 1 );
+ if ( $include_url ) {
+ add_filter( 'bb_check_ios_device', array( $this, 'bb_rest_disable_symlink' ), 1 );
+ $data['url'] = bb_video_get_symlink( $media->id );
+ remove_filter( 'bb_check_ios_device', array( $this, 'bb_rest_disable_symlink' ), 1 );
+ }
// Update the download link for the video.
- $data['download_url'] = bp_video_download_link( $media->attachment_id, $media->id );
+ if ( $include_download_url ) {
+ $data['download_url'] = bp_video_download_link( $media->attachment_id, $media->id );
+ }
}
$data = $this->add_additional_fields_to_object( $data, $request );
@@ -1585,6 +1660,15 @@ protected function prepare_links( $media ) {
* @since 0.1.0
*/
public function get_item_schema() {
+ if ( ! empty( $this->schema ) ) {
+ /**
+ * Filters the media schema.
+ *
+ * @param array $schema The endpoint schema.
+ */
+ return apply_filters( 'bp_rest_media_schema', $this->add_additional_fields_schema( $this->schema ) );
+ }
+
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'bp_media',
@@ -1765,12 +1849,10 @@ public function get_item_schema() {
),
);
- /**
- * Filters the media schema.
- *
- * @param array $schema The endpoint schema.
- */
- return apply_filters( 'bp_rest_media_schema', $this->add_additional_fields_schema( $schema ) );
+ $this->schema = $schema;
+
+ /** This filter is documented in bp-media/classes/class-bp-rest-media-endpoint.php */
+ return apply_filters( 'bp_rest_media_schema', $this->add_additional_fields_schema( $this->schema ) );
}
/**
@@ -1782,6 +1864,8 @@ public function get_item_schema() {
public function get_collection_params() {
$params = parent::get_collection_params();
+ $params['embed_fields'] = bb_rest_embed_fields_param();
+
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'buddyboss' ),
'default' => 'desc',
@@ -2332,10 +2416,11 @@ public function bp_rest_media_support() {
*
* @param BP_Activity_Activity $activity Activity Array.
* @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return string The value of the REST Field to include into the REST response.
*/
- protected function bp_media_ids_get_rest_field_callback( $activity, $attribute ) {
+ protected function bp_media_ids_get_rest_field_callback( $activity, $attribute, $request = null ) {
$activity_id = $activity['id'];
if ( empty( $activity_id ) ) {
@@ -2404,6 +2489,7 @@ protected function bp_media_ids_get_rest_field_callback( $activity, $attribute )
$retval = array();
$object = new WP_REST_Request();
$object->set_param( 'context', 'view' );
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $medias['medias'] as $media ) {
$retval[] = $this->prepare_response_for_collection(
@@ -2933,12 +3019,13 @@ public function bp_rest_message_query_arguments( $params ) {
/**
* The function to use to get medias of the topic REST Field.
*
- * @param array $post WP_Post object as array.
- * @param string $attribute The REST Field key used into the REST response.
+ * @param array $post WP_Post object as array.
+ * @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return string The value of the REST Field to include into the REST response.
*/
- protected function bbp_media_get_rest_field_callback( $post, $attribute ) {
+ protected function bbp_media_get_rest_field_callback( $post, $attribute, $request = null ) {
$p_id = $post['id'];
@@ -3005,6 +3092,7 @@ protected function bbp_media_get_rest_field_callback( $post, $attribute ) {
$retval = array();
$object = new WP_REST_Request();
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $medias['medias'] as $media ) {
$retval[] = $this->prepare_response_for_collection(
@@ -3315,12 +3403,13 @@ protected function bp_rest_media_forums_embed_gif( $id ) {
/**
* The function to use to get medias of the messages REST Field.
*
- * @param array $data The message value for the REST response.
- * @param string $attribute The REST Field key used into the REST response.
+ * @param array $data The message value for the REST response.
+ * @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return array|void The value of the REST Field to include into the REST response.
*/
- protected function bp_media_ids_get_rest_field_callback_messages( $data, $attribute ) {
+ protected function bp_media_ids_get_rest_field_callback_messages( $data, $attribute, $request = null ) {
$message_id = $data['id'];
if ( empty( $message_id ) ) {
@@ -3379,6 +3468,7 @@ protected function bp_media_ids_get_rest_field_callback_messages( $data, $attrib
$retval = array();
$object = new WP_REST_Request();
$object->set_param( 'context', 'view' );
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $medias['medias'] as $media ) {
$retval[] = $this->prepare_response_for_collection(
diff --git a/src/bp-members/classes/class-bp-rest-members-endpoint.php b/src/bp-members/classes/class-bp-rest-members-endpoint.php
index d6475b0b992..57b755376d6 100644
--- a/src/bp-members/classes/class-bp-rest-members-endpoint.php
+++ b/src/bp-members/classes/class-bp-rest-members-endpoint.php
@@ -535,7 +535,7 @@ public function delete_item( $request ) {
);
}
- $previous = $this->prepare_item_for_response( $user, $request );
+ $previous = $this->prepare_item_for_response( $user, bb_rest_request_for_nested_item( $request ) );
$status = false;
if ( bp_core_delete_account( $user_id ) ) {
$status = true;
@@ -706,13 +706,33 @@ public function prepare_item_for_response( $user, $request ) {
* @since 0.1.0
*/
public function user_data( $user, $request ) {
- $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+
+ /*
+ * The fields the request asked for. When the request carries no
+ * `_fields`, this is every property of the item schema, so each of the
+ * branches below runs exactly as it did before the controller became
+ * field-aware.
+ */
+ $fields = $this->get_fields_for_response( $request );
+
+ $include_member_types = rest_is_field_included( 'member_types', $fields );
+ $include_is_wp_admin = rest_is_field_included( 'is_wp_admin', $fields );
+ $include_xprofile = rest_is_field_included( 'xprofile', $fields );
+
$user_data = get_userdata( $user->ID );
- $followers = $this->rest_bp_get_follower_ids( array( 'user_id' => $user->ID ) );
- $following = $this->rest_bp_get_following_ids( array( 'user_id' => $user->ID ) );
+ /*
+ * Both of these materialise a whole list of IDs so that it can be
+ * counted, so neither is resolved unless its count was asked for.
+ */
+ $followers = rest_is_field_included( 'followers', $fields ) ? $this->rest_bp_get_follower_ids( array( 'user_id' => $user->ID ) ) : array();
+ $following = rest_is_field_included( 'following', $fields ) ? $this->rest_bp_get_following_ids( array( 'user_id' => $user->ID ) ) : array();
+
+ // Held in a local, because the fallbacks below refine it.
$member_types = array();
if (
+ $include_member_types &&
function_exists( 'bp_get_xprofile_member_type_field_id' ) &&
function_exists( 'bp_xprofile_get_hidden_fields_for_user' ) &&
! in_array( bp_get_xprofile_member_type_field_id(), bp_xprofile_get_hidden_fields_for_user( $user->ID ), true )
@@ -720,78 +740,141 @@ function_exists( 'bp_xprofile_get_hidden_fields_for_user' ) &&
$member_types = bp_get_member_type( $user->ID, false );
}
- $data = array(
- 'id' => $user->ID,
- 'name' => $user->display_name,
- 'user_login' => $user->user_login,
- 'link' => bp_core_get_user_domain( $user->ID, $user->user_nicename, $user->user_login ),
- 'member_types' => $member_types,
- 'roles' => array(),
- 'capabilities' => array(),
- 'extra_capabilities' => array(),
- 'registered_date' => bp_rest_prepare_date_response( $user_data->user_registered ),
- 'profile_name' => bp_core_get_user_displayname( $user->ID ),
- 'last_activity' => $this->bp_rest_get_member_last_active( $user->ID, array( 'relative' => false ) ),
- 'xprofile' => array(),
- 'followers' => ! empty( $followers ) ? count( $followers ) : 0,
- 'following' => ! empty( $following ) ? count( $following ) : 0,
- 'is_wp_admin' => false,
- );
+ $data = array();
+
+ $data['id'] = $user->ID;
+
+ if ( rest_is_field_included( 'name', $fields ) ) {
+ $data['name'] = $user->display_name;
+ }
+
+ if ( rest_is_field_included( 'user_login', $fields ) ) {
+ $data['user_login'] = $user->user_login;
+ }
+
+ if ( rest_is_field_included( 'link', $fields ) ) {
+ $data['link'] = bp_core_get_user_domain( $user->ID, $user->user_nicename, $user->user_login );
+ }
+
+ if ( $include_member_types ) {
+ $data['member_types'] = $member_types;
+ }
+
+ if ( rest_is_field_included( 'roles', $fields ) ) {
+ $data['roles'] = array();
+ }
+
+ if ( rest_is_field_included( 'capabilities', $fields ) ) {
+ $data['capabilities'] = array();
+ }
+
+ if ( rest_is_field_included( 'extra_capabilities', $fields ) ) {
+ $data['extra_capabilities'] = array();
+ }
+
+ if ( rest_is_field_included( 'registered_date', $fields ) ) {
+ $data['registered_date'] = bp_rest_prepare_date_response( $user_data->user_registered );
+ }
+
+ if ( rest_is_field_included( 'profile_name', $fields ) ) {
+ $data['profile_name'] = bp_core_get_user_displayname( $user->ID );
+ }
+
+ if ( rest_is_field_included( 'last_activity', $fields ) ) {
+ $data['last_activity'] = $this->bp_rest_get_member_last_active( $user->ID, array( 'relative' => false ) );
+ }
+
+ if ( $include_xprofile ) {
+ $data['xprofile'] = array();
+ }
+
+ if ( rest_is_field_included( 'followers', $fields ) ) {
+ $data['followers'] = ! empty( $followers ) ? count( $followers ) : 0;
+ }
+
+ if ( rest_is_field_included( 'following', $fields ) ) {
+ $data['following'] = ! empty( $following ) ? count( $following ) : 0;
+ }
+
+ if ( $include_is_wp_admin ) {
+ $data['is_wp_admin'] = false;
- // Fetch user roles.
- $user_roles = ! empty( $user->ID ) ? $user_data->roles : '';
- if ( ! empty( $user_roles ) ) {
- // If user is admin then set true, otherwise it should be false.
- $data['is_wp_admin'] = in_array( 'administrator', $user_roles, true ) ? true : false;
+ // Fetch user roles.
+ $user_roles = ! empty( $user->ID ) ? $user_data->roles : '';
+ if ( ! empty( $user_roles ) ) {
+ // If user is admin then set true, otherwise it should be false.
+ $data['is_wp_admin'] = in_array( 'administrator', $user_roles, true ) ? true : false;
+ }
}
// Load xprofile data when required.
- if ( 'embed' !== $context ) {
+ if ( $include_xprofile && 'embed' !== $context ) {
$data['xprofile'] = $this->xprofile_data( $user->ID );
}
- $data['friendship_status'] = (
- (
- bp_is_active( 'friends' )
- && function_exists( 'friends_check_friendship_status' )
- )
- ? friends_check_friendship_status( get_current_user_id(), $user->ID )
- : ''
- );
+ if ( rest_is_field_included( 'friendship_status', $fields ) ) {
+ $data['friendship_status'] = (
+ (
+ bp_is_active( 'friends' )
+ && function_exists( 'friends_check_friendship_status' )
+ )
+ ? friends_check_friendship_status( get_current_user_id(), $user->ID )
+ : ''
+ );
+ }
- $data['friendship_id'] = (
- (
- bp_is_active( 'friends' )
- && function_exists( 'friends_get_friendship_id' )
- )
- ? friends_get_friendship_id( get_current_user_id(), $user->ID )
- : ''
- );
+ if ( rest_is_field_included( 'friendship_id', $fields ) ) {
+ $data['friendship_id'] = (
+ (
+ bp_is_active( 'friends' )
+ && function_exists( 'friends_get_friendship_id' )
+ )
+ ? friends_get_friendship_id( get_current_user_id(), $user->ID )
+ : ''
+ );
+ }
- $data['create_friendship'] = ( bp_is_active( 'friends' ) && is_user_logged_in() && apply_filters( 'bp_rest_user_can_create_friendship', true, $user->ID ) );
+ if ( rest_is_field_included( 'create_friendship', $fields ) ) {
+ $data['create_friendship'] = ( bp_is_active( 'friends' ) && is_user_logged_in() && apply_filters( 'bp_rest_user_can_create_friendship', true, $user->ID ) );
+ }
- $data['is_following'] = (bool) (
- function_exists( 'bp_is_following' )
- ? bp_is_following(
- array(
- 'leader_id' => $user->ID,
- 'follower_id' => get_current_user_id(),
+ if ( rest_is_field_included( 'is_following', $fields ) ) {
+ $data['is_following'] = (bool) (
+ function_exists( 'bp_is_following' )
+ ? bp_is_following(
+ array(
+ 'leader_id' => $user->ID,
+ 'follower_id' => get_current_user_id(),
+ )
)
- )
- : '0'
- );
+ : '0'
+ );
+ }
- $data['can_follow'] = bp_is_active( 'activity' ) && function_exists( 'bp_is_activity_follow_active' ) && bp_is_activity_follow_active();;
+ if ( rest_is_field_included( 'can_follow', $fields ) ) {
+ $data['can_follow'] = bp_is_active( 'activity' ) && function_exists( 'bp_is_activity_follow_active' ) && bp_is_activity_follow_active();
+ }
if ( 'edit' === $context ) {
- $data['registered_date'] = bp_rest_prepare_date_response( $user_data->user_registered );
- $data['roles'] = (array) array_values( $user_data->roles );
- $data['capabilities'] = (array) array_keys( $user_data->allcaps );
- $data['extra_capabilities'] = (array) array_keys( $user_data->caps );
+ if ( rest_is_field_included( 'registered_date', $fields ) ) {
+ $data['registered_date'] = bp_rest_prepare_date_response( $user_data->user_registered );
+ }
+
+ if ( rest_is_field_included( 'roles', $fields ) ) {
+ $data['roles'] = (array) array_values( $user_data->roles );
+ }
+
+ if ( rest_is_field_included( 'capabilities', $fields ) ) {
+ $data['capabilities'] = (array) array_keys( $user_data->allcaps );
+ }
+
+ if ( rest_is_field_included( 'extra_capabilities', $fields ) ) {
+ $data['extra_capabilities'] = (array) array_keys( $user_data->caps );
+ }
}
// The name used for that user in @-mentions.
- if ( bp_is_active( 'activity' ) ) {
+ if ( rest_is_field_included( 'mention_name', $fields ) && bp_is_active( 'activity' ) ) {
$data['mention_name'] = bp_activity_get_user_mentionname( $user->ID );
}
@@ -799,7 +882,7 @@ function_exists( 'bp_is_following' )
$schema = $this->get_item_schema();
// Avatars.
- if ( ! empty( $schema['properties']['avatar_urls'] ) ) {
+ if ( ! empty( $schema['properties']['avatar_urls'] ) && rest_is_field_included( 'avatar_urls', $fields ) ) {
$blocked_by_show_avatar = false;
$group_ids = $request->get_param( 'group_id' );
if ( ! empty( $group_ids ) ) {
@@ -840,8 +923,20 @@ function_exists( 'bp_is_following' )
}
}
- // Cover Image.
- $data['cover_url'] = (
+ /*
+ * Cover image.
+ *
+ * Deliberately NOT gated on `_fields`. Consumers downstream of this
+ * controller read `cover_url` off the prepared member, and when it is
+ * absent they resolve it themselves -- once per member, against the
+ * attachment store. On a hosted install that is roughly 107 ms an item,
+ * so a page of twenty that omitted the field answered in ~2.9 s where
+ * the same page including it answered in ~0.7 s. Declining to build a
+ * field must never cost more than building it, and here it did.
+ *
+ * Measured on the dev host, 25 August 2026.
+ */
+ $data['cover_url'] = (
empty( bp_disable_cover_image_uploads() )
? bp_attachments_get_attachment(
'url',
@@ -852,54 +947,62 @@ function_exists( 'bp_is_following' )
)
: false
);
+
$data['cover_is_default'] = ! bp_attachments_get_user_has_cover_image( $user->ID );
// Fallback.
- if ( false === $data['member_types'] ) {
- $data['member_types'] = array();
+ if ( false === $member_types ) {
+ $member_types = array();
}
if ( function_exists( 'bp_member_type_enable_disable' ) && bp_member_type_enable_disable() === false ) {
- $data['member_types'] = array();
+ $member_types = array();
}
- if ( ! empty( $data['member_types'] ) ) {
- $member_types = array();
- foreach ( $data['member_types'] as $name ) {
- $member_types[ $name ] = bp_get_member_type_object( $name );
+ if ( ! empty( $member_types ) ) {
+ $member_type_objects = array();
+ foreach ( $member_types as $name ) {
+ $member_type_objects[ $name ] = bp_get_member_type_object( $name );
// Member type's label background and text color.
$label_color_data = function_exists( 'bb_get_member_type_label_colors' ) ? bb_get_member_type_label_colors( $name ) : '';
if ( ! empty( $label_color_data ) ) {
- $member_types[ $name ]->label_colors = $label_color_data;
+ $member_type_objects[ $name ]->label_colors = $label_color_data;
}
}
+ $member_types = $member_type_objects;
+ }
+
+ // Re-assigning keeps the key where it already sits in the response.
+ if ( $include_member_types ) {
$data['member_types'] = $member_types;
}
- // It will check non-admin members can send message or not before they can connected to each other.
- $allowed_message = false;
+ if ( rest_is_field_included( 'can_send_message', $fields ) ) {
+ // It will check non-admin members can send message or not before they can connected to each other.
+ $allowed_message = false;
- if (
- bp_is_active( 'messages' ) &&
- bb_messages_user_can_send_message(
- array(
- 'sender_id' => bp_loggedin_user_id(),
- 'recipients_id' => $user->ID,
+ if (
+ bp_is_active( 'messages' ) &&
+ bb_messages_user_can_send_message(
+ array(
+ 'sender_id' => bp_loggedin_user_id(),
+ 'recipients_id' => $user->ID,
+ )
)
- )
- ) {
- $allowed_message = true;
- }
+ ) {
+ $allowed_message = true;
+ }
- // It will check non-admin members can send message or not before they can connected to each other.
- // Also check access controls settings.
- $data['can_send_message'] = (
- bp_is_active( 'messages' ) &&
- bp_loggedin_user_id() &&
- apply_filters( 'bp_rest_user_can_show_send_message_button', true, $user->ID ) &&
- $allowed_message
- );
+ // It will check non-admin members can send message or not before they can connected to each other.
+ // Also check access controls settings.
+ $data['can_send_message'] = (
+ bp_is_active( 'messages' ) &&
+ bp_loggedin_user_id() &&
+ apply_filters( 'bp_rest_user_can_show_send_message_button', true, $user->ID ) &&
+ $allowed_message
+ );
+ }
return $data;
}
@@ -1126,6 +1229,15 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
* @since 0.1.0
*/
public function get_item_schema() {
+ if ( ! empty( $this->schema ) ) {
+ /**
+ * Filters the members schema.
+ *
+ * @param array $schema The endpoint schema.
+ */
+ return apply_filters( 'bp_rest_members_schema', $this->add_additional_fields_schema( $this->schema ) );
+ }
+
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'bp_members',
@@ -1336,12 +1448,10 @@ public function get_item_schema() {
'readonly' => true,
);
- /**
- * Filters the members schema.
- *
- * @param array $schema The endpoint schema.
- */
- return apply_filters( 'bp_rest_members_schema', $this->add_additional_fields_schema( $schema ) );
+ $this->schema = $schema;
+
+ /** This filter is documented in bp-members/classes/class-bp-rest-members-endpoint.php */
+ return apply_filters( 'bp_rest_members_schema', $this->add_additional_fields_schema( $this->schema ) );
}
/**
diff --git a/src/bp-video/classes/class-bp-rest-video-endpoint.php b/src/bp-video/classes/class-bp-rest-video-endpoint.php
index 8cfc3158f73..e97a8f2239e 100644
--- a/src/bp-video/classes/class-bp-rest-video-endpoint.php
+++ b/src/bp-video/classes/class-bp-rest-video-endpoint.php
@@ -1091,7 +1091,7 @@ public function delete_item( $request ) {
$previous = '';
foreach ( $videos['videos'] as $video ) {
$previous = $this->prepare_response_for_collection(
- $this->media_endpoint->prepare_item_for_response( $video, $request )
+ $this->media_endpoint->prepare_item_for_response( $video, bb_rest_request_for_nested_item( $request ) )
);
}
@@ -1286,6 +1286,15 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
* @since 0.1.0
*/
public function get_item_schema() {
+ if ( ! empty( $this->schema ) ) {
+ /**
+ * Filters the video schema.
+ *
+ * @param array $schema The endpoint schema.
+ */
+ return apply_filters( 'bp_rest_video_schema', $this->add_additional_fields_schema( $this->schema ) );
+ }
+
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'bp_video',
@@ -1466,12 +1475,10 @@ public function get_item_schema() {
),
);
- /**
- * Filters the video schema.
- *
- * @param array $schema The endpoint schema.
- */
- return apply_filters( 'bp_rest_video_schema', $this->add_additional_fields_schema( $schema ) );
+ $this->schema = $schema;
+
+ /** This filter is documented in bp-video/classes/class-bp-rest-video-endpoint.php */
+ return apply_filters( 'bp_rest_video_schema', $this->add_additional_fields_schema( $this->schema ) );
}
/**
@@ -1898,10 +1905,11 @@ public function bp_rest_video_support() {
*
* @param BP_Activity_Activity $activity Activity Array.
* @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return string The value of the REST Field to include into the REST response.
*/
- protected function bp_video_ids_get_rest_field_callback( $activity, $attribute ) {
+ protected function bp_video_ids_get_rest_field_callback( $activity, $attribute, $request = null ) {
$activity_id = $activity['id'];
if ( empty( $activity_id ) ) {
@@ -1942,6 +1950,7 @@ protected function bp_video_ids_get_rest_field_callback( $activity, $attribute )
$retval = array();
$object = new WP_REST_Request();
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $videos['videos'] as $video ) {
$retval[] = $this->prepare_response_for_collection(
$this->media_endpoint->prepare_item_for_response( $video, $object )
@@ -2185,12 +2194,13 @@ public function bp_rest_message_query_arguments( $params ) {
/**
* The function to use to get videos of the topic REST Field.
*
- * @param array $post WP_Post object as array.
- * @param string $attribute The REST Field key used into the REST response.
+ * @param array $post WP_Post object as array.
+ * @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return string The value of the REST Field to include into the REST response.
*/
- protected function bbp_video_get_rest_field_callback( $post, $attribute ) {
+ protected function bbp_video_get_rest_field_callback( $post, $attribute, $request = null ) {
$p_id = $post['id'];
@@ -2227,6 +2237,7 @@ protected function bbp_video_get_rest_field_callback( $post, $attribute ) {
$retval = array();
$object = new WP_REST_Request();
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $videos['videos'] as $video ) {
$retval[] = $this->prepare_response_for_collection(
@@ -2383,12 +2394,13 @@ protected function bbp_video_update_rest_field_callback( $object, $value ) {
/**
* The function to use to get videos of the messages REST Field.
*
- * @param array $data The message value for the REST response.
- * @param string $attribute The REST Field key used into the REST response.
+ * @param array $data The message value for the REST response.
+ * @param string $attribute The REST Field key used into the REST response.
+ * @param WP_REST_Request $request Full details about the request.
*
* @return array|void The value of the REST Field to include into the REST response.
*/
- protected function bp_video_ids_get_rest_field_callback_messages( $data, $attribute ) {
+ protected function bp_video_ids_get_rest_field_callback_messages( $data, $attribute, $request = null ) {
$message_id = $data['id'];
if ( empty( $message_id ) ) {
@@ -2448,6 +2460,7 @@ protected function bp_video_ids_get_rest_field_callback_messages( $data, $attrib
$retval = array();
$object = new WP_REST_Request();
$object->set_param( 'context', 'view' );
+ bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' );
foreach ( $videos['videos'] as $video ) {
$retval[] = $this->prepare_response_for_collection(
diff --git a/tests/phpunit/testcases/activity/rest-fields.php b/tests/phpunit/testcases/activity/rest-fields.php
new file mode 100644
index 00000000000..c38d0f0db80
--- /dev/null
+++ b/tests/phpunit/testcases/activity/rest-fields.php
@@ -0,0 +1,899 @@
+plugin_dir . 'bp-core/admin/bp-core-admin-schema.php';
+ bp_core_install_emails();
+ }
+
+ /*
+ * The test case restores the hook snapshot after every test, which
+ * unregisters the callbacks `rest_api_init` added -- including
+ * `rest_filter_response_fields()`. Rebuild the server per test so the
+ * dispatch pipeline is the one a real request goes through.
+ */
+ global $wp_rest_server;
+ $wp_rest_server = new WP_REST_Server();
+ do_action( 'rest_api_init', $wp_rest_server );
+
+ $this->server = $wp_rest_server;
+ $this->endpoint = new BP_REST_Activity_Endpoint();
+ $this->endpoint_url = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/activity';
+
+ $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+
+ $this->activity_id = self::factory()->activity->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'component' => 'activity',
+ 'type' => 'activity_update',
+ 'content' => 'Field selection fixture.',
+ )
+ );
+
+ wp_set_current_user( $this->user_id );
+ }
+
+ /**
+ * Drop the REST server so the next test builds a fresh one.
+ */
+ public function tearDown(): void {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ parent::tearDown();
+ }
+
+ /**
+ * Fields whose construction is guarded by the `_fields` selection.
+ *
+ * `comments` is deliberately absent: it is gated on `display_comments`
+ * and is covered by its own test.
+ *
+ * @return array
+ */
+ public static function guarded_field_provider() {
+ return array(
+ array( 'name' ),
+ array( 'mention_name' ),
+ array( 'user_link' ),
+ array( 'link' ),
+ array( 'content' ),
+ array( 'favorited' ),
+ array( 'favorite_count' ),
+ array( 'activity_data' ),
+ array( 'feature_media' ),
+ array( 'preview_data' ),
+ array( 'link_embed_url' ),
+ array( 'is_pinned' ),
+ array( 'can_pin' ),
+ array( 'reacted_names' ),
+ array( 'reacted_counts' ),
+ array( 'reacted_id' ),
+ array( 'can_close_comment' ),
+ array( 'comment_count' ),
+ array( 'user_avatar' ),
+ array( 'can_toggle_notification' ),
+ array( 'is_receive_notification' ),
+ array( 'bb_activity_post_feature_image' ),
+ array( 'can_edit' ),
+ array( 'can_delete' ),
+ array( 'can_comment' ),
+ array( 'can_favorite' ),
+ array( 'content_stripped' ),
+ array( 'title' ),
+ array( 'is_edited' ),
+ );
+ }
+
+ /**
+ * Response fields that shipped for years without a schema entry.
+ *
+ * @return array
+ */
+ public static function undeclared_field_provider() {
+ return array(
+ array( 'bb_activity_post_feature_image' ),
+ array( 'comment_closed_notice' ),
+ array( 'comment_depth' ),
+ );
+ }
+
+ /**
+ * Dispatch a request the way `WP_REST_Server::serve_request()` does.
+ *
+ * `rest_do_request()` skips `rest_post_dispatch`, which is where
+ * WordPress trims the response down to `_fields`. Without it these
+ * tests would not exercise the trimming at all.
+ *
+ * @param WP_REST_Request $request Request to dispatch.
+ *
+ * @return WP_REST_Response
+ */
+ protected function dispatch( $request ) {
+ $response = $this->server->dispatch( $request );
+
+ return apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request );
+ }
+
+ /**
+ * Fetch the activity collection and return the first item.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return array
+ */
+ protected function get_first_item( $params = array() ) {
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url );
+ $request->set_param( 'context', 'view' );
+
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ $data = $this->dispatch( $request )->get_data();
+
+ $this->assertNotEmpty( $data, 'The activity collection came back empty.' );
+
+ return $data[0];
+ }
+
+ /**
+ * Count the queries a single collection request costs, from a cold cache.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_queries( $params = array() ) {
+ global $wpdb;
+
+ wp_cache_flush();
+
+ $before = $wpdb->num_queries;
+ $this->get_first_item( $params );
+
+ return $wpdb->num_queries - $before;
+ }
+
+ /**
+ * Count the queries a request sends to the reaction tables.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_reaction_queries( $params = array() ) {
+ $count = 0;
+
+ $counter = function ( $query ) use ( &$count ) {
+ if ( false !== strpos( $query, 'bb_user_reactions' ) || false !== strpos( $query, 'bb_reactions_data' ) ) {
+ ++$count;
+ }
+
+ return $query;
+ };
+
+ wp_cache_flush();
+
+ add_filter( 'query', $counter );
+
+ try {
+ $this->get_first_item( $params );
+ } finally {
+ remove_filter( 'query', $counter );
+ }
+
+ return $count;
+ }
+
+ /**
+ * A request that sends no `_fields` must behave exactly as it did before
+ * the controller became field-aware: every field is still built.
+ */
+ public function test_no_field_selection_returns_every_field() {
+ $item = $this->get_first_item();
+ $properties = $this->endpoint->get_item_schema();
+ $properties = $properties['properties'];
+
+ foreach ( self::guarded_field_provider() as $args ) {
+ $field = $args[0];
+
+ // Fields whose schema entry is registered conditionally are only
+ // expected when the feature behind them is active on this install.
+ if ( ! isset( $properties[ $field ] ) ) {
+ continue;
+ }
+
+ $this->assertArrayHasKey(
+ $field,
+ $item,
+ sprintf( 'Field "%s" disappeared from an unfiltered response.', $field )
+ );
+ }
+ }
+
+ /**
+ * A narrow selection returns exactly the requested keys.
+ */
+ public function test_narrow_field_selection_returns_only_the_requested_keys() {
+ $item = $this->get_first_item( array( '_fields' => 'id,user_id,date' ) );
+
+ $actual = array_keys( $item );
+ sort( $actual );
+
+ $this->assertSame( array( 'date', 'id', 'user_id' ), $actual );
+ }
+
+ /**
+ * A nested selection still builds the parent field.
+ *
+ * @dataProvider guarded_field_provider
+ *
+ * @param string $field Field name.
+ */
+ public function test_guarded_field_is_returned_when_explicitly_selected( $field ) {
+ $properties = $this->endpoint->get_item_schema();
+ $properties = $properties['properties'];
+
+ if ( ! isset( $properties[ $field ] ) ) {
+ $this->markTestSkipped( sprintf( 'Field "%s" is not registered on this install.', $field ) );
+ }
+
+ $item = $this->get_first_item( array( '_fields' => 'id,' . $field ) );
+
+ $this->assertArrayHasKey( $field, $item );
+ }
+
+ /**
+ * `_fields=content.rendered` must still build `content`.
+ */
+ public function test_nested_field_selection_still_builds_the_parent() {
+ $item = $this->get_first_item( array( '_fields' => 'id,content.rendered' ) );
+
+ $this->assertArrayHasKey( 'content', $item );
+ $this->assertArrayHasKey( 'rendered', $item['content'] );
+ $this->assertNotEmpty( $item['content']['rendered'] );
+
+ // WordPress trims the sibling away; the controller must not have to.
+ $this->assertArrayNotHasKey( 'raw', $item['content'] );
+ }
+
+ /**
+ * A selection combined with `_embed` still embeds.
+ */
+ public function test_field_selection_with_embed_still_embeds() {
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url );
+ $request->set_param( 'context', 'view' );
+ $request->set_param( '_embed', true );
+ $request->set_param( '_fields', 'id,user_id,_links,_embedded' );
+
+ $response = $this->dispatch( $request );
+ $data = $this->server->response_to_data( $response, true );
+
+ $this->assertNotEmpty( $data );
+ $this->assertArrayHasKey( '_links', $data[0] );
+ $this->assertArrayHasKey( 'user', $data[0]['_links'] );
+ $this->assertArrayHasKey( '_embedded', $data[0] );
+ $this->assertArrayHasKey( 'user', $data[0]['_embedded'] );
+ }
+
+ /**
+ * `comments` is gated on `display_comments`, not on `_fields`, and must
+ * keep working when no selection is sent.
+ */
+ public function test_threaded_comments_are_returned_without_a_field_selection() {
+ $this->create_comment();
+
+ $item = $this->get_first_item(
+ array(
+ 'display_comments' => 'threaded',
+ 'include' => $this->activity_id,
+ )
+ );
+
+ $this->assertArrayHasKey( 'comments', $item );
+ $this->assertNotEmpty( $item['comments'] );
+ }
+
+ /**
+ * Selecting `comments` must still return whole comment objects: WordPress
+ * does not trim inside a numerically indexed list, so the controller must
+ * not narrow the nested items either.
+ */
+ public function test_selecting_comments_returns_whole_comment_objects() {
+ $this->create_comment();
+
+ $item = $this->get_first_item(
+ array(
+ 'display_comments' => 'threaded',
+ 'include' => $this->activity_id,
+ '_fields' => 'id,comments',
+ )
+ );
+
+ $this->assertArrayHasKey( 'comments', $item );
+ $this->assertNotEmpty( $item['comments'] );
+
+ $comment = $item['comments'][0];
+
+ $this->assertArrayHasKey( 'content', $comment );
+ $this->assertArrayHasKey( 'user_id', $comment );
+ $this->assertArrayHasKey( 'date', $comment );
+
+ // Fields added with `bp_rest_register_field()` belong to a nested
+ // comment too: `_fields` addresses the parent, never its comments.
+ $properties = $this->endpoint->get_item_schema();
+
+ if ( isset( $properties['properties']['can_report'] ) ) {
+ $this->assertArrayHasKey( 'can_report', $comment );
+ }
+ }
+
+ /**
+ * An unselected `content` must not be rendered.
+ */
+ public function test_content_is_not_rendered_when_it_is_not_selected() {
+ $renders = 0;
+ $counter = function ( $content ) use ( &$renders ) {
+ $renders++;
+
+ return $content;
+ };
+
+ add_filter( 'bp_get_activity_content_body', $counter );
+
+ try {
+ $this->get_first_item( array( '_fields' => 'id,user_id' ) );
+ } finally {
+ remove_filter( 'bp_get_activity_content_body', $counter );
+ }
+
+ $this->assertSame( 0, $renders );
+ }
+
+ /**
+ * ...but a selected `content` still is.
+ */
+ public function test_content_is_rendered_when_it_is_selected() {
+ $renders = 0;
+ $counter = function ( $content ) use ( &$renders ) {
+ $renders++;
+
+ return $content;
+ };
+
+ add_filter( 'bp_get_activity_content_body', $counter );
+
+ try {
+ $this->get_first_item( array( '_fields' => 'id,content' ) );
+ } finally {
+ remove_filter( 'bp_get_activity_content_body', $counter );
+ }
+
+ $this->assertGreaterThan( 0, $renders );
+ }
+
+ /**
+ * `link_embed_url` is stored in the activity metadata whenever the activity
+ * has one, so selecting it alone must not drag in the embed resolution --
+ * nor the content render that resolution reads.
+ */
+ public function test_link_embed_url_alone_skips_the_embed_resolution() {
+ bp_activity_update_meta( $this->activity_id, '_link_embed', 'https://example.org/embedded' );
+ wp_cache_delete( $this->activity_id, 'activity_meta' );
+
+ $renders = 0;
+ $counter = function ( $content ) use ( &$renders ) {
+ $renders++;
+
+ return $content;
+ };
+
+ add_filter( 'bp_get_activity_content_body', $counter );
+
+ try {
+ $item = $this->get_first_item(
+ array(
+ 'include' => $this->activity_id,
+ '_fields' => 'id,link_embed_url',
+ )
+ );
+ } finally {
+ remove_filter( 'bp_get_activity_content_body', $counter );
+ }
+
+ $this->assertSame( 'https://example.org/embedded', $item['link_embed_url'] );
+ $this->assertSame( 0, $renders );
+ }
+
+ /**
+ * `can_close_comment` is derived from whether comments are closed. That
+ * value has to be resolved independently of the `is_comment_closed`
+ * selection, or one guard would be reading what another produced.
+ */
+ public function test_close_comment_permission_is_independent_of_is_comment_closed() {
+ $full = $this->get_first_item( array( 'include' => $this->activity_id ) );
+ $narrow = $this->get_first_item(
+ array(
+ 'include' => $this->activity_id,
+ '_fields' => 'id,can_close_comment',
+ )
+ );
+
+ if ( ! isset( $full['can_close_comment'] ) ) {
+ $this->markTestSkipped( 'Closing activity comments is not available on this install.' );
+ }
+
+ $this->assertArrayNotHasKey( 'is_comment_closed', $narrow );
+ $this->assertSame( $full['can_close_comment'], $narrow['can_close_comment'] );
+ }
+
+ /**
+ * An unselected `activity_data` must not build the edit payload.
+ */
+ public function test_activity_data_is_not_built_when_it_is_not_selected() {
+ $builds = 0;
+ $counter = function ( $data ) use ( &$builds ) {
+ $builds++;
+
+ return $data;
+ };
+
+ add_filter( 'bp_activity_get_edit_data', $counter );
+
+ try {
+ $this->get_first_item( array( '_fields' => 'id,user_id' ) );
+ } finally {
+ remove_filter( 'bp_activity_get_edit_data', $counter );
+ }
+
+ $this->assertSame( 0, $builds );
+ }
+
+ /**
+ * Skip a test that needs the reactions feature.
+ */
+ protected function require_reactions() {
+ if ( ! function_exists( 'bb_load_reaction' ) || ! bb_load_reaction() ) {
+ $this->markTestSkipped( 'The reactions feature is not available on this install.' );
+ }
+ }
+
+ /**
+ * The reaction lookups behind `reacted_*` and `favorite_count` are three
+ * of the most expensive things the feed does per row. An unselected
+ * reaction field must not reach the reaction tables at all.
+ */
+ public function test_reaction_tables_are_untouched_when_no_reaction_field_is_selected() {
+ $this->require_reactions();
+
+ $this->assertSame( 0, $this->count_reaction_queries( array( '_fields' => 'id,user_id,date' ) ) );
+ }
+
+ /**
+ * ...and a selected one still does.
+ */
+ public function test_reaction_tables_are_read_when_a_reaction_field_is_selected() {
+ $this->require_reactions();
+
+ $this->assertGreaterThan( 0, $this->count_reaction_queries( array( '_fields' => 'id,reacted_counts' ) ) );
+ }
+
+ /**
+ * A narrow selection must cost measurably fewer queries than no selection.
+ *
+ * The floor is deliberately conservative, because the exact saving depends
+ * on which components are active. Measured on the stock test install the
+ * saving is ~13 queries per row; before the controller honoured `_fields`
+ * the same comparison differed by ~1.5 per row, which is the churn of the
+ * caches those two requests happen to share. Three sits between the two
+ * with room on either side. Most of the saving is the reaction lookups, so
+ * the test stands down where that feature is absent.
+ */
+ public function test_narrow_field_selection_runs_fewer_queries() {
+ $this->require_reactions();
+
+ $rows = 10;
+ $minimum_saved_row = 3;
+
+ // Distinct authors, so the per-author lookups are not shared.
+ foreach ( self::factory()->user->create_many( $rows ) as $user_id ) {
+ self::factory()->activity->create(
+ array(
+ 'user_id' => $user_id,
+ 'component' => 'activity',
+ 'type' => 'activity_update',
+ )
+ );
+ }
+
+ /*
+ * Warm up first. Some of what the feed touches is memoised outside the
+ * object cache, so an unwarmed first request would make whichever
+ * measurement ran first look more expensive than it is.
+ */
+ $this->get_first_item( array( 'per_page' => $rows ) );
+
+ $full = $this->count_queries( array( 'per_page' => $rows ) );
+ $narrow = $this->count_queries(
+ array(
+ 'per_page' => $rows,
+ '_fields' => 'id,user_id,date',
+ )
+ );
+
+ $this->assertGreaterThanOrEqual(
+ $minimum_saved_row * $rows,
+ $full - $narrow,
+ sprintf( 'No selection cost %d queries, a narrow selection cost %d, over %d rows.', $full, $narrow, $rows )
+ );
+ }
+
+ /**
+ * A field the controller returns has to be in the schema, otherwise
+ * `get_fields_for_response()` cannot see it and it can never be guarded.
+ *
+ * @dataProvider undeclared_field_provider
+ *
+ * @param string $field Field name.
+ */
+ public function test_previously_undeclared_field_is_in_the_schema( $field ) {
+ $schema = $this->endpoint->get_item_schema();
+
+ $this->assertArrayHasKey( $field, $schema['properties'] );
+ $this->assertNotEmpty( $schema['properties'][ $field ]['readonly'] );
+ }
+
+ /**
+ * ...and being in the schema must not make it writable.
+ *
+ * @dataProvider undeclared_field_provider
+ *
+ * @param string $field Field name.
+ */
+ public function test_previously_undeclared_field_is_not_a_request_argument( $field ) {
+ foreach ( array( WP_REST_Server::CREATABLE, WP_REST_Server::EDITABLE ) as $method ) {
+ $this->assertArrayNotHasKey(
+ $field,
+ $this->endpoint->get_endpoint_args_for_item_schema( $method ),
+ sprintf( 'Field "%s" became a writable argument.', $field )
+ );
+ }
+ }
+
+ /**
+ * `comment_fields` is the selection that reaches nested comments, since
+ * the parent's `_fields` cannot: WordPress hands a list back whole.
+ */
+ public function test_comment_fields_narrows_the_nested_comments() {
+ $this->create_comment();
+
+ $item = $this->get_first_item(
+ array(
+ 'display_comments' => 'threaded',
+ 'include' => $this->activity_id,
+ 'comment_fields' => 'id,content',
+ )
+ );
+
+ $this->assertNotEmpty( $item['comments'] );
+
+ $comment = $item['comments'][0];
+
+ $this->assertArrayHasKey( 'id', $comment );
+ $this->assertArrayHasKey( 'content', $comment );
+ $this->assertArrayNotHasKey( 'activity_data', $comment );
+ $this->assertArrayNotHasKey( 'reacted_counts', $comment );
+ }
+
+ /**
+ * ...and it applies to the comments only, never to their parent.
+ */
+ public function test_comment_fields_leaves_the_parent_activity_whole() {
+ $this->create_comment();
+
+ $item = $this->get_first_item(
+ array(
+ 'display_comments' => 'threaded',
+ 'include' => $this->activity_id,
+ 'comment_fields' => 'id',
+ )
+ );
+
+ $this->assertArrayHasKey( 'content', $item );
+ $this->assertArrayHasKey( 'activity_data', $item );
+ }
+
+ /**
+ * The activity comment controller honours it too.
+ */
+ public function test_activity_comment_endpoint_honours_comment_fields() {
+ $this->create_comment();
+
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url . '/' . $this->activity_id . '/comment' );
+ $request->set_param( 'context', 'view' );
+ $request->set_param( 'comment_fields', 'id,content' );
+
+ $data = $this->dispatch( $request )->get_data();
+
+ $this->assertNotEmpty( $data['comments'] );
+
+ $comment = $data['comments'][0];
+
+ $this->assertArrayHasKey( 'content', $comment );
+ $this->assertArrayNotHasKey( 'activity_data', $comment );
+ }
+
+ /**
+ * An unselected comment field must not be built for the comments either.
+ */
+ public function test_comment_fields_skips_the_work_behind_unselected_comment_fields() {
+ $this->create_comment();
+
+ $builds = 0;
+ $counter = function ( $data ) use ( &$builds ) {
+ $builds++;
+
+ return $data;
+ };
+
+ add_filter( 'bp_activity_get_edit_data', $counter );
+
+ try {
+ $this->get_first_item(
+ array(
+ 'display_comments' => 'threaded',
+ 'include' => $this->activity_id,
+ 'comment_fields' => 'id,content',
+ '_fields' => 'id,comments',
+ )
+ );
+ } finally {
+ remove_filter( 'bp_activity_get_edit_data', $counter );
+ }
+
+ $this->assertSame( 0, $builds );
+ }
+
+ /**
+ * Fields added with `bp_rest_register_field()` are handed the prepared
+ * activity and read keys off it, so those keys have to survive a selection
+ * that does not name them.
+ */
+ public function test_registered_rest_field_resolves_under_a_narrow_selection() {
+ $properties = $this->endpoint->get_item_schema();
+
+ if ( ! isset( $properties['properties']['can_report'] ) ) {
+ $this->markTestSkipped( 'Moderation is not active on this install.' );
+ }
+
+ $item = $this->get_first_item( array( '_fields' => 'id,can_report' ) );
+
+ $this->assertArrayHasKey( 'can_report', $item );
+ }
+
+ /**
+ * `DELETE` answers with an envelope, so its `_fields` names envelope keys.
+ * The activity nested under `previous` must still be built in full.
+ */
+ public function test_delete_returns_a_whole_previous_activity() {
+ $request = new WP_REST_Request( 'DELETE', $this->endpoint_url . '/' . $this->activity_id );
+ $request->set_param( 'context', 'edit' );
+ $request->set_param( '_fields', 'deleted,previous' );
+
+ $data = $this->dispatch( $request )->get_data();
+
+ $this->assertTrue( $data['deleted'] );
+ $this->assertArrayHasKey( 'previous', $data );
+ $this->assertArrayHasKey( 'content', $data['previous'] );
+ $this->assertArrayHasKey( 'user_id', $data['previous'] );
+ $this->assertArrayHasKey( 'activity_data', $data['previous'] );
+ }
+
+ /**
+ * The activity comment controller has a schema of its own, so its
+ * `_fields` must not narrow the activity items nested inside it.
+ */
+ public function test_activity_comment_endpoint_returns_whole_comments() {
+ $this->create_comment();
+
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url . '/' . $this->activity_id . '/comment' );
+ $request->set_param( 'context', 'view' );
+ $request->set_param( '_fields', 'comments' );
+
+ $data = $this->dispatch( $request )->get_data();
+
+ $this->assertArrayHasKey( 'comments', $data );
+ $this->assertNotEmpty( $data['comments'] );
+
+ $comment = $data['comments'][0];
+
+ $this->assertArrayHasKey( 'content', $comment );
+ $this->assertArrayHasKey( 'user_id', $comment );
+
+ $properties = $this->endpoint->get_item_schema();
+
+ if ( isset( $properties['properties']['can_report'] ) ) {
+ $this->assertArrayHasKey( 'can_report', $comment );
+ }
+ }
+
+ /**
+ * Capability checks that used to run for every activity whatever the
+ * request asked for.
+ *
+ * Each costs a settings read and a permission check per item, so on a page
+ * of twenty they were twenty checks nobody had asked for.
+ *
+ * @return array
+ */
+ public static function permission_field_provider() {
+ return array(
+ 'can_delete' => array( 'can_delete', 'bp_activity_user_can_delete' ),
+ 'can_comment' => array( 'can_comment', 'bp_activity_can_comment' ),
+ 'can_edit' => array( 'can_edit', 'bp_activity_user_can_edit' ),
+ );
+ }
+
+ /**
+ * A permission the request did not ask about must not be worked out.
+ *
+ * @dataProvider permission_field_provider
+ *
+ * @param string $field Field name.
+ * @param string $hook Filter the permission check fires.
+ */
+ public function test_permission_is_not_resolved_when_its_field_is_not_selected( $field, $hook ) {
+ $this->assertSame( 0, $this->count_hook( $hook, array( '_fields' => 'id' ) ) );
+ }
+
+ /**
+ * ...and one it did ask about still is.
+ *
+ * @dataProvider permission_field_provider
+ *
+ * @param string $field Field name.
+ * @param string $hook Filter the permission check fires.
+ */
+ public function test_permission_is_resolved_when_its_field_is_selected( $field, $hook ) {
+ $this->assertGreaterThan( 0, $this->count_hook( $hook, array( '_fields' => 'id,' . $field ) ) );
+ }
+
+ /**
+ * `can_favorite` is a special case: `prepare_links()` asks the same question
+ * to decide whether to offer the favourite link, and that is link building
+ * rather than field building. So the field's own check has to show up as an
+ * increase over what the links already cost, not as a count from zero.
+ */
+ public function test_favourite_permission_is_only_resolved_once_more_when_selected() {
+ $without = $this->count_hook( 'bp_activity_can_favorite', array( '_fields' => 'id' ) );
+ $with = $this->count_hook( 'bp_activity_can_favorite', array( '_fields' => 'id,can_favorite' ) );
+
+ $this->assertGreaterThan( $without, $with );
+ }
+
+ /**
+ * Count how many times a hook fires while a collection request is served.
+ *
+ * WordPress asks every route's permission callback for each `self` link, to
+ * fill in the `targetHints` it attaches. That happens outside the
+ * controller and would swamp what is being measured here, so the hint is
+ * declared up front for the duration of the count, which is what makes
+ * WordPress skip the probe.
+ *
+ * @param string $hook Hook name.
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_hook( $hook, $params ) {
+ $calls = 0;
+
+ $counter = function ( $value ) use ( &$calls ) {
+ $calls++;
+
+ return $value;
+ };
+
+ $declare_hints = function ( $links ) {
+ $links['self']['targetHints'] = array( 'allow' => array( 'GET' ) );
+
+ return $links;
+ };
+
+ add_filter( $hook, $counter );
+ add_filter( 'bp_rest_activity_prepare_links', $declare_hints, 999 );
+
+ /*
+ * The edit check is only reached when editing is switched on, and a
+ * fresh install has it off. Without this the `can_edit` case would
+ * short-circuit and prove nothing either way.
+ */
+ add_filter( 'bp_is_activity_edit_enabled', '__return_true' );
+
+ try {
+ $this->get_first_item( $params );
+ } finally {
+ remove_filter( $hook, $counter );
+ remove_filter( 'bp_rest_activity_prepare_links', $declare_hints, 999 );
+ remove_filter( 'bp_is_activity_edit_enabled', '__return_true' );
+ }
+
+ return $calls;
+ }
+
+ /**
+ * Add a comment to the fixture activity.
+ *
+ * @return int Activity comment ID.
+ */
+ protected function create_comment() {
+ return bp_activity_new_comment(
+ array(
+ 'activity_id' => $this->activity_id,
+ 'parent_id' => $this->activity_id,
+ 'user_id' => $this->user_id,
+ 'content' => 'A comment on the fixture.',
+ 'skip_error_return' => true,
+ )
+ );
+ }
+}
diff --git a/tests/phpunit/testcases/core/rest-embed-fields.php b/tests/phpunit/testcases/core/rest-embed-fields.php
new file mode 100644
index 00000000000..fa0e0009e3f
--- /dev/null
+++ b/tests/phpunit/testcases/core/rest-embed-fields.php
@@ -0,0 +1,504 @@
+plugin_dir . 'bp-core/admin/bp-core-admin-schema.php';
+ bp_core_install_emails();
+ }
+
+ /*
+ * The test case restores the hook snapshot after every test, which
+ * unregisters the callbacks `rest_api_init` added -- including
+ * `rest_filter_response_fields()`. Rebuild the server per test so the
+ * dispatch pipeline is the one a real request goes through.
+ */
+ global $wp_rest_server;
+ $wp_rest_server = new WP_REST_Server();
+ do_action( 'rest_api_init', $wp_rest_server );
+
+ $this->server = $wp_rest_server;
+ $this->endpoint = new BP_REST_Activity_Endpoint();
+ $this->endpoint_url = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/activity';
+
+ $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+
+ $this->group_id = self::factory()->group->create(
+ array(
+ 'creator_id' => $this->user_id,
+ 'status' => 'public',
+ )
+ );
+
+ $this->activity_id = self::factory()->activity->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'component' => 'groups',
+ 'item_id' => $this->group_id,
+ 'type' => 'activity_update',
+ 'content' => 'Embedded field selection fixture.',
+ )
+ );
+
+ wp_set_current_user( $this->user_id );
+ }
+
+ /**
+ * Drop the REST server so the next test builds a fresh one.
+ */
+ public function tearDown(): void {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ parent::tearDown();
+ }
+
+ /**
+ * Dispatch a request the way `WP_REST_Server::serve_request()` does.
+ *
+ * @param WP_REST_Request $request Request to dispatch.
+ *
+ * @return WP_REST_Response
+ */
+ protected function dispatch( $request ) {
+ $response = $this->server->dispatch( $request );
+
+ return apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request );
+ }
+
+ /**
+ * Fetch the first activity of the collection with its links embedded.
+ *
+ * @param array $params Request parameters.
+ * @param array|true $rels Relations to embed.
+ *
+ * @return array The first item of the collection, embeds included.
+ */
+ protected function get_first_item( $params = array(), $rels = true ) {
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url );
+ $request->set_param( 'context', 'view' );
+ $request->set_param( '_fields', 'id,_links,_embedded' );
+
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ $response = $this->dispatch( $request );
+ $data = $this->server->response_to_data( $response, $rels );
+
+ $this->assertNotEmpty( $data, 'The activity collection came back empty.' );
+ $this->assertArrayHasKey( '_embedded', $data[0], 'The activity came back with nothing embedded.' );
+
+ return $data[0];
+ }
+
+ /**
+ * Read one embedded item off an activity.
+ *
+ * @param array $item Activity item.
+ * @param string $rel Link relation.
+ *
+ * @return array The embedded item.
+ */
+ protected function get_embedded( $item, $rel ) {
+ $this->assertArrayHasKey( $rel, $item['_embedded'], "Nothing was embedded for the `{$rel}` relation." );
+ $this->assertNotEmpty( $item['_embedded'][ $rel ] );
+
+ return $item['_embedded'][ $rel ][0];
+ }
+
+ /**
+ * Count how many times a hook fires while a collection is embedded.
+ *
+ * @param string $hook Hook name.
+ * @param array $params Request parameters.
+ * @param array|true $rels Relations to embed.
+ *
+ * @return int
+ */
+ protected function count_hook( $hook, $params, $rels = true ) {
+ $calls = 0;
+
+ $counter = function ( $value ) use ( &$calls ) {
+ $calls++;
+
+ return $value;
+ };
+
+ add_filter( $hook, $counter );
+
+ try {
+ $this->get_first_item( $params, $rels );
+ } finally {
+ remove_filter( $hook, $counter );
+ }
+
+ return $calls;
+ }
+
+ /**
+ * Without a selection the embedded items stay exactly as they were.
+ */
+ public function test_embedded_items_are_whole_without_a_selection() {
+ $item = $this->get_first_item();
+
+ $user = $this->get_embedded( $item, 'user' );
+
+ $this->assertArrayHasKey( 'id', $user );
+ $this->assertArrayHasKey( 'mention_name', $user );
+ $this->assertArrayHasKey( 'link', $user );
+ $this->assertArrayHasKey( '_links', $user );
+
+ $group = $this->get_embedded( $item, 'group' );
+
+ $this->assertArrayHasKey( 'name', $group );
+ $this->assertArrayHasKey( 'status', $group );
+ }
+
+ /**
+ * One list narrows every embedded relation.
+ */
+ public function test_embed_fields_narrows_every_relation() {
+ $item = $this->get_first_item( array( 'embed_fields' => 'id,name' ) );
+
+ $this->assertSame( array( 'id', 'name' ), array_keys( $this->get_embedded( $item, 'user' ) ) );
+ $this->assertSame( array( 'id', 'name' ), array_keys( $this->get_embedded( $item, 'group' ) ) );
+ }
+
+ /**
+ * A list per relation narrows only the relations it names.
+ */
+ public function test_embed_fields_narrows_one_relation_at_a_time() {
+ $item = $this->get_first_item( array( 'embed_fields' => array( 'user' => 'id,name' ) ) );
+
+ $this->assertSame( array( 'id', 'name' ), array_keys( $this->get_embedded( $item, 'user' ) ) );
+
+ $group = $this->get_embedded( $item, 'group' );
+
+ $this->assertArrayHasKey( 'status', $group );
+ $this->assertArrayHasKey( '_links', $group );
+ }
+
+ /**
+ * `*` is the selection every relation without one of its own falls back to.
+ */
+ public function test_embed_fields_falls_back_to_the_default_selection() {
+ $item = $this->get_first_item(
+ array(
+ 'embed_fields' => array(
+ '*' => 'id',
+ 'user' => 'id,name',
+ ),
+ )
+ );
+
+ $this->assertSame( array( 'id', 'name' ), array_keys( $this->get_embedded( $item, 'user' ) ) );
+ $this->assertSame( array( 'id' ), array_keys( $this->get_embedded( $item, 'group' ) ) );
+ }
+
+ /**
+ * The selection reaches the embedded items only, never their parent.
+ */
+ public function test_embed_fields_leaves_the_outer_item_whole() {
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url );
+ $request->set_param( 'context', 'view' );
+ $request->set_param( 'embed_fields', 'id,name' );
+
+ $data = $this->dispatch( $request )->get_data();
+
+ $this->assertNotEmpty( $data );
+ $this->assertArrayHasKey( 'content', $data[0] );
+ $this->assertArrayHasKey( 'activity_data', $data[0] );
+ }
+
+ /**
+ * A selection that does not name `_links` is answered without them, the
+ * same way `_fields` answers a collection.
+ */
+ public function test_embed_fields_drops_the_links_it_was_not_asked_for() {
+ $item = $this->get_first_item( array( 'embed_fields' => 'id,name' ) );
+
+ $this->assertArrayNotHasKey( '_links', $this->get_embedded( $item, 'user' ) );
+ }
+
+ /**
+ * ...and a selection that names them keeps them.
+ */
+ public function test_embed_fields_keeps_the_links_it_was_asked_for() {
+ $item = $this->get_first_item( array( 'embed_fields' => 'id,_links' ) );
+
+ $user = $this->get_embedded( $item, 'user' );
+
+ $this->assertArrayHasKey( '_links', $user );
+ $this->assertArrayNotHasKey( 'name', $user );
+ }
+
+ /**
+ * A single item keeps its links on the response rather than in its data,
+ * and the selection has to find them there too.
+ */
+ public function test_embed_fields_narrows_a_single_item_response() {
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url . '/' . $this->activity_id );
+ $request->set_param( 'context', 'view' );
+ $request->set_param( 'embed_fields', 'id,name' );
+
+ $response = $this->dispatch( $request );
+ $data = $this->server->response_to_data( $response, true );
+
+ $this->assertArrayHasKey( '_embedded', $data );
+ $this->assertSame( array( 'id', 'name' ), array_keys( $data['_embedded']['user'][0] ) );
+
+ // The activity itself was asked for nothing, so it comes back whole.
+ $this->assertArrayHasKey( 'content', $data );
+ }
+
+ /**
+ * The selection stands on its own: the caller does not have to send a
+ * `_fields` of its own for it to reach the embedded items.
+ */
+ public function test_embed_fields_needs_no_field_selection_of_its_own() {
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url );
+ $request->set_param( 'context', 'view' );
+ $request->set_param( 'embed_fields', 'id,name' );
+
+ $response = $this->dispatch( $request );
+ $data = $this->server->response_to_data( $response, array( 'user' ) );
+
+ $this->assertNotEmpty( $data );
+ $this->assertArrayHasKey( 'content', $data[0] );
+ $this->assertSame( array( 'id', 'name' ), array_keys( $data[0]['_embedded']['user'][0] ) );
+ }
+
+ /**
+ * An unselected field of an embedded item must not be built either.
+ */
+ public function test_embed_fields_skips_the_work_behind_an_unselected_field() {
+ $this->assertSame(
+ 0,
+ $this->count_hook(
+ 'bp_core_fetch_avatar_url',
+ array( 'embed_fields' => 'id,name' ),
+ array( 'user' )
+ )
+ );
+ }
+
+ /**
+ * ...and a selected one still is.
+ */
+ public function test_embed_fields_does_the_work_behind_a_selected_field() {
+ $this->assertGreaterThan(
+ 0,
+ $this->count_hook(
+ 'bp_core_fetch_avatar_url',
+ array( 'embed_fields' => 'id,name,avatar_urls' ),
+ array( 'user' )
+ )
+ );
+ }
+
+ /**
+ * Every BuddyBoss controller answers a single item the way it answers a
+ * member of a collection, with the links flattened into the data. A
+ * controller that keeps them on the response instead -- the shape
+ * `WP_REST_Controller` produces -- has to be read just the same.
+ */
+ public function test_links_kept_on_the_response_are_mapped_too() {
+ $response = new WP_REST_Response( array( 'id' => 1 ) );
+
+ $response->add_link( 'self', 'https://example.org/self' );
+ $response->add_link( 'user', 'https://example.org/user', array( 'embeddable' => true ) );
+
+ $map = bb_rest_map_embed_fields_to_links( $response, array( '*' => 'id,name' ) );
+
+ $this->assertSame( array( 'https://example.org/user' => 'id,name' ), $map );
+ }
+
+ /**
+ * A relation with no selection of its own, and no `*` to fall back to, is
+ * left out of the map entirely, so its item is built in full.
+ */
+ public function test_a_relation_without_a_selection_is_not_mapped() {
+ $response = new WP_REST_Response( array( 'id' => 1 ) );
+
+ $response->add_link( 'user', 'https://example.org/user', array( 'embeddable' => true ) );
+
+ $this->assertSame( array(), bb_rest_map_embed_fields_to_links( $response, array( 'group' => 'id' ) ) );
+ }
+
+ /**
+ * Parsing a parsed selection returns it unchanged, so that a controller
+ * can sanitise the parameter with the parser and still read it later.
+ */
+ public function test_parsing_a_selection_is_idempotent() {
+ $once = bb_rest_parse_embed_fields( ' id , name ' );
+
+ $this->assertSame( array( '*' => 'id,name' ), $once );
+ $this->assertSame( $once, bb_rest_parse_embed_fields( $once ) );
+ }
+
+ /**
+ * `embed_fields[]=id,name` is a spelling a client reaches for as readily
+ * as the bare string, and it has to mean the same thing rather than
+ * quietly resolving to no selection at all.
+ */
+ public function test_a_list_without_relations_is_read_as_one_selection() {
+ $this->assertSame( array( '*' => 'id,name' ), bb_rest_parse_embed_fields( array( 'id,name' ) ) );
+ $this->assertSame( array( '*' => 'id,name' ), bb_rest_parse_embed_fields( array( 'id', 'name' ) ) );
+ }
+
+ /**
+ * WordPress builds and caches an embedded item once per `href`, so two
+ * relations sharing a URL cannot be answered with two selections. Neither
+ * may be applied, or one of them silently loses fields it asked for.
+ */
+ public function test_a_shared_href_with_conflicting_selections_is_left_whole() {
+ $response = new WP_REST_Response( array( 'id' => 1 ) );
+
+ $response->add_link( 'user', 'https://example.org/members/5', array( 'embeddable' => true ) );
+ $response->add_link( 'author', 'https://example.org/members/5', array( 'embeddable' => true ) );
+
+ $map = bb_rest_map_embed_fields_to_links(
+ $response,
+ array(
+ 'user' => 'id,name',
+ 'author' => 'avatar_urls',
+ )
+ );
+
+ $this->assertSame( array(), $map );
+ }
+
+ /**
+ * ...but relations that agree are still narrowed.
+ */
+ public function test_a_shared_href_with_one_selection_is_narrowed() {
+ $response = new WP_REST_Response( array( 'id' => 1 ) );
+
+ $response->add_link( 'user', 'https://example.org/members/5', array( 'embeddable' => true ) );
+ $response->add_link( 'author', 'https://example.org/members/5', array( 'embeddable' => true ) );
+
+ $map = bb_rest_map_embed_fields_to_links( $response, array( '*' => 'id,name' ) );
+
+ $this->assertSame( array( 'https://example.org/members/5' => 'id,name' ), $map );
+ }
+
+ /**
+ * An embedded item is recognised by identity, never by anything the client
+ * can send. A request that dresses itself up as one keeps its links, and
+ * still clears what an earlier response left held.
+ */
+ public function test_a_client_cannot_pass_itself_off_as_an_embedded_item() {
+ bb_rest_held_embed_fields( array( 'https://example.org/members/5' => 'id' ) );
+
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url );
+ $request->set_param( 'bb_embedded_item', true );
+ $request->set_param( '_fields', 'id' );
+
+ $response = new WP_REST_Response( array( 'id' => 1 ) );
+ $response->add_link( 'self', 'https://example.org/activity/1' );
+
+ bb_rest_prepare_embedded_fields( $response, $this->server, $request );
+
+ $this->assertArrayHasKey( 'self', $response->get_links() );
+ $this->assertSame( array(), bb_rest_held_embed_fields() );
+ }
+
+ /**
+ * Controllers whose items carry embeddable links.
+ *
+ * @return array
+ */
+ public static function embeddable_controller_provider() {
+ return array(
+ array( 'BP_REST_Activity_Endpoint' ),
+ array( 'BP_REST_Groups_Endpoint' ),
+ array( 'BP_REST_Media_Endpoint' ),
+ array( 'BP_REST_Document_Endpoint' ),
+ );
+ }
+
+ /**
+ * The parameter is declared, so that it shows up in `OPTIONS`.
+ *
+ * @dataProvider embeddable_controller_provider
+ *
+ * @param string $controller Controller class name.
+ */
+ public function test_embed_fields_is_a_collection_parameter( $controller ) {
+ if ( ! class_exists( $controller ) ) {
+ $this->markTestSkipped( "{$controller} is not available on this install." );
+ }
+
+ $endpoint = new $controller();
+
+ $this->assertArrayHasKey( 'embed_fields', $endpoint->get_collection_params() );
+ }
+}
diff --git a/tests/phpunit/testcases/groups/rest-fields.php b/tests/phpunit/testcases/groups/rest-fields.php
new file mode 100644
index 00000000000..602d8fcb4c1
--- /dev/null
+++ b/tests/phpunit/testcases/groups/rest-fields.php
@@ -0,0 +1,297 @@
+markTestSkipped( 'Groups are not active on this install.' );
+ }
+
+ if ( ! get_term_by( 'name', 'invites-member-invite', bp_get_email_tax_type() ) ) {
+ require_once buddypress()->plugin_dir . 'bp-core/admin/bp-core-admin-schema.php';
+ bp_core_install_emails();
+ }
+
+ global $wp_rest_server;
+ $wp_rest_server = new WP_REST_Server();
+ do_action( 'rest_api_init', $wp_rest_server );
+ $this->server = $wp_rest_server;
+
+ $this->endpoint = new BP_REST_Groups_Endpoint();
+ $this->endpoint_url = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/groups';
+
+ $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $this->user_id );
+
+ $this->group_id = self::factory()->group->create(
+ array(
+ 'creator_id' => $this->user_id,
+ 'name' => 'Field selection fixture',
+ 'status' => 'public',
+ )
+ );
+ }
+
+ /**
+ * Drop the REST server so the next test builds a fresh one.
+ */
+ public function tearDown(): void {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ parent::tearDown();
+ }
+
+ /**
+ * Dispatch the way `WP_REST_Server::serve_request()` does.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return array First group in the collection.
+ */
+ protected function get_first_group( $params = array() ) {
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url );
+ $request->set_param( 'context', 'view' );
+
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ $response = $this->server->dispatch( $request );
+ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request );
+ $data = $response->get_data();
+
+ $this->assertNotEmpty( $data, 'The groups collection came back empty.' );
+
+ return $data[0];
+ }
+
+ /**
+ * Count how many times a hook fires while a request is dispatched.
+ *
+ * @param string $hook Hook name.
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_hook( $hook, $params ) {
+ $calls = 0;
+
+ $counter = function ( $value ) use ( &$calls ) {
+ $calls++;
+
+ return $value;
+ };
+
+ add_filter( $hook, $counter );
+
+ try {
+ $this->get_first_group( $params );
+ } finally {
+ remove_filter( $hook, $counter );
+ }
+
+ return $calls;
+ }
+
+ /**
+ * Count the queries one groups request costs, from a cold cache.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_queries( $params ) {
+ global $wpdb;
+
+ wp_cache_flush();
+
+ $before = $wpdb->num_queries;
+ $this->get_first_group( $params );
+
+ return $wpdb->num_queries - $before;
+ }
+
+ /**
+ * A request that sends no `_fields` must still return every field the
+ * `view` context exposes.
+ */
+ public function test_no_field_selection_returns_every_view_field() {
+ $group = $this->get_first_group();
+ $schema = $this->endpoint->get_item_schema();
+
+ foreach ( $schema['properties'] as $field => $property ) {
+ if ( empty( $property['context'] ) || ! in_array( 'view', (array) $property['context'], true ) ) {
+ continue;
+ }
+
+ // Only set when the group actually carries one.
+ if ( in_array( $field, array( 'group_type', 'cover_url', 'cover_is_default', 'is_subscribed', 'subscribed_id' ), true ) ) {
+ continue;
+ }
+
+ $this->assertArrayHasKey( $field, $group, sprintf( 'Group field "%s" went missing.', $field ) );
+ }
+ }
+
+ /**
+ * Every key the unfiltered response returns has to be declared in the
+ * schema. An undeclared one cannot be guarded -- it would disappear from
+ * responses that send no `_fields` at all.
+ */
+ public function test_every_returned_field_is_declared_in_the_schema() {
+ $schema = $this->endpoint->get_item_schema();
+ $item = $this->get_first_group();
+
+ foreach ( array_keys( $item ) as $field ) {
+ if ( '_links' === $field || '_embedded' === $field ) {
+ continue;
+ }
+
+ $this->assertArrayHasKey(
+ $field,
+ $schema['properties'],
+ sprintf( 'The groups controller returns "%s" but does not declare it.', $field )
+ );
+ }
+ }
+
+ /**
+ * A narrow selection returns exactly the requested keys.
+ */
+ public function test_narrow_field_selection_returns_only_the_requested_keys() {
+ $group = $this->get_first_group( array( '_fields' => 'id,name,slug' ) );
+
+ $actual = array_keys( $group );
+ sort( $actual );
+
+ $this->assertSame( array( 'id', 'name', 'slug' ), $actual );
+ }
+
+ /**
+ * Avatars cost two resolutions per row.
+ */
+ public function test_avatars_are_not_fetched_when_not_selected() {
+ $this->assertSame( 0, $this->count_hook( 'bp_core_fetch_avatar_url', array( '_fields' => 'id,name' ) ) );
+ }
+
+ /**
+ * ...but a selected one still is.
+ */
+ public function test_avatars_are_fetched_when_selected() {
+ $schema = $this->endpoint->get_item_schema();
+
+ if ( empty( $schema['properties']['avatar_urls'] ) ) {
+ $this->markTestSkipped( 'Avatars are disabled on this install.' );
+ }
+
+ $this->assertGreaterThan( 0, $this->count_hook( 'bp_core_fetch_avatar_url', array( '_fields' => 'id,avatar_urls' ) ) );
+ }
+
+ /**
+ * The admin and moderator lists cost a member query plus an avatar each;
+ * they must not be assembled unless asked for.
+ */
+ public function test_admin_list_is_not_assembled_when_not_selected() {
+ $group = $this->get_first_group( array( '_fields' => 'id,name' ) );
+
+ $this->assertArrayNotHasKey( 'admins', $group );
+ }
+
+ /**
+ * ...and is still assembled when it is.
+ */
+ public function test_admin_list_is_assembled_when_selected() {
+ $group = $this->get_first_group( array( '_fields' => 'id,admins' ) );
+
+ $this->assertArrayHasKey( 'admins', $group );
+ $this->assertNotEmpty( $group['admins'] );
+ }
+
+ /**
+ * `plural_role` falls back to `role`; that fallback must not depend on
+ * `role` having been selected.
+ */
+ public function test_plural_role_resolves_without_role_being_selected() {
+ $full = $this->get_first_group();
+ $narrow = $this->get_first_group( array( '_fields' => 'id,plural_role' ) );
+
+ $this->assertArrayNotHasKey( 'role', $narrow );
+ $this->assertSame( $full['plural_role'], $narrow['plural_role'] );
+ }
+
+ /**
+ * The group type block refines the type list; it must read the resolved
+ * value rather than the response key.
+ */
+ public function test_types_resolve_without_the_label_being_selected() {
+ $full = $this->get_first_group();
+ $narrow = $this->get_first_group( array( '_fields' => 'id,types' ) );
+
+ $this->assertArrayNotHasKey( 'group_type_label', $narrow );
+ $this->assertSame( $full['types'], $narrow['types'] );
+ }
+
+ /**
+ * A narrow selection must cost measurably fewer queries than no selection.
+ */
+ public function test_narrow_field_selection_runs_fewer_queries() {
+ self::factory()->group->create_many( 4, array( 'creator_id' => $this->user_id, 'status' => 'public' ) );
+
+ // Warm anything memoised outside the object cache first.
+ $this->get_first_group();
+
+ $full = $this->count_queries( array() );
+ $narrow = $this->count_queries( array( '_fields' => 'id,name,slug' ) );
+
+ $this->assertLessThan(
+ $full,
+ $narrow,
+ sprintf( 'No selection cost %d queries, a narrow selection cost %d.', $full, $narrow )
+ );
+ }
+}
diff --git a/tests/phpunit/testcases/media/rest-fields.php b/tests/phpunit/testcases/media/rest-fields.php
new file mode 100644
index 00000000000..978776a6335
--- /dev/null
+++ b/tests/phpunit/testcases/media/rest-fields.php
@@ -0,0 +1,482 @@
+markTestSkipped( 'Media and documents are not both active on this install.' );
+ }
+
+ /*
+ * Booting the REST API brings up every BuddyBoss controller, and some
+ * of them read the email taxonomy while registering their routes.
+ */
+ if ( ! get_term_by( 'name', 'invites-member-invite', bp_get_email_tax_type() ) ) {
+ require_once buddypress()->plugin_dir . 'bp-core/admin/bp-core-admin-schema.php';
+ bp_core_install_emails();
+ }
+
+ global $wp_rest_server;
+ $wp_rest_server = new WP_REST_Server();
+ do_action( 'rest_api_init', $wp_rest_server );
+ $this->server = $wp_rest_server;
+
+ $this->media_endpoint = new BP_REST_Media_Endpoint();
+ $this->document_endpoint = new BP_REST_Document_Endpoint();
+
+ $base = '/' . bp_rest_namespace() . '/' . bp_rest_version();
+ $this->activity_url = $base . '/activity';
+ $this->media_url = $base . '/media';
+ $this->document_url = $base . '/document';
+
+ $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $this->user_id );
+
+ $this->activity_id = self::factory()->activity->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'component' => 'activity',
+ 'type' => 'activity_update',
+ 'content' => 'Activity carrying attachments.',
+ )
+ );
+
+ // The attachment callbacks are gated on profile support being on.
+ add_filter( 'bp_is_profile_media_support_enabled', '__return_true' );
+ add_filter( 'bp_is_profile_document_support_enabled', '__return_true' );
+
+ $this->media_id = $this->create_media();
+ $this->document_id = $this->create_document();
+
+ // They find the attachments through the activity's metadata, not
+ // through the attachment's own activity_id.
+ bp_activity_update_meta( $this->activity_id, 'bp_media_ids', (string) $this->media_id );
+ bp_activity_update_meta( $this->activity_id, 'bp_document_ids', (string) $this->document_id );
+ wp_cache_delete( $this->activity_id, 'activity_meta' );
+ }
+
+ /**
+ * Drop the REST server so the next test builds a fresh one.
+ */
+ public function tearDown(): void {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ parent::tearDown();
+ }
+
+ /**
+ * Upload a real file and return its attachment ID.
+ *
+ * A genuine upload rather than a stub post: the document controller stats
+ * the file for `size` and the media controller reads image metadata.
+ *
+ * @param string $source Full path to the file to upload.
+ *
+ * @return int
+ */
+ protected function create_attachment( $source ) {
+ return self::factory()->attachment->create_upload_object( $source );
+ }
+
+ /**
+ * Attach a media item to the fixture activity.
+ *
+ * @return int
+ */
+ protected function create_media() {
+ return bp_media_add(
+ array(
+ 'attachment_id' => $this->create_attachment( DIR_TESTDATA . '/images/canola.jpg' ),
+ 'user_id' => $this->user_id,
+ 'title' => 'Fixture photo',
+ 'activity_id' => $this->activity_id,
+ 'privacy' => 'public',
+ )
+ );
+ }
+
+ /**
+ * Attach a document to the fixture activity.
+ *
+ * @return int
+ */
+ protected function create_document() {
+ return bp_document_add(
+ array(
+ 'attachment_id' => $this->create_attachment( $this->create_text_file() ),
+ 'user_id' => $this->user_id,
+ 'title' => 'Fixture document',
+ 'activity_id' => $this->activity_id,
+ 'privacy' => 'public',
+ )
+ );
+ }
+
+ /**
+ * Write a small text file for the document fixture to upload.
+ *
+ * @return string Full path.
+ */
+ protected function create_text_file() {
+ $path = get_temp_dir() . 'bb-rest-fields-fixture.txt';
+ file_put_contents( $path, 'attachment fixture' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+
+ return $path;
+ }
+
+ /**
+ * Dispatch a request the way `WP_REST_Server::serve_request()` does.
+ *
+ * @param string $route Route.
+ * @param array $params Request parameters.
+ *
+ * @return array
+ */
+ protected function get_data( $route, $params = array() ) {
+ $request = new WP_REST_Request( 'GET', $route );
+ $request->set_param( 'context', 'view' );
+
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ $response = $this->server->dispatch( $request );
+ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request );
+
+ return $response->get_data();
+ }
+
+ /**
+ * Fetch the fixture activity.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return array
+ */
+ protected function get_activity( $params = array() ) {
+ $data = $this->get_data(
+ $this->activity_url,
+ array_merge( array( 'include' => $this->activity_id ), $params )
+ );
+
+ $this->assertNotEmpty( $data, 'The activity collection came back empty.' );
+
+ return $data[0];
+ }
+
+ /**
+ * Count how many times a hook fires while a request is dispatched.
+ *
+ * @param string $hook Hook name.
+ * @param string $route Route.
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_hook( $hook, $route, $params = array() ) {
+ $calls = 0;
+
+ $counter = function ( $value ) use ( &$calls ) {
+ $calls++;
+
+ return $value;
+ };
+
+ add_filter( $hook, $counter );
+
+ try {
+ $this->get_data( $route, $params );
+ } finally {
+ remove_filter( $hook, $counter );
+ }
+
+ return $calls;
+ }
+
+ /**
+ * Every key the media controller builds has to be declared in its schema,
+ * otherwise `get_fields_for_response()` cannot see it and it can never be
+ * guarded without disappearing from unfiltered responses.
+ */
+ public function test_every_media_field_is_declared_in_the_schema() {
+ $schema = $this->media_endpoint->get_item_schema();
+ $item = $this->get_data( $this->media_url );
+
+ $this->assertNotEmpty( $item );
+
+ foreach ( array_keys( $item[0] ) as $field ) {
+ if ( '_links' === $field || '_embedded' === $field ) {
+ continue;
+ }
+
+ $this->assertArrayHasKey(
+ $field,
+ $schema['properties'],
+ sprintf( 'Media returns "%s" but does not declare it.', $field )
+ );
+ }
+ }
+
+ /**
+ * The same for documents. A field the controller returns but does not
+ * declare cannot be guarded: `get_fields_for_response()` would not list it
+ * when no `_fields` is sent, and it would vanish from every response.
+ */
+ public function test_every_document_field_is_declared_in_the_schema() {
+ $schema = $this->document_endpoint->get_item_schema();
+ $item = $this->get_data( $this->document_url . '/' . $this->document_id );
+
+ $this->assertNotEmpty( $item );
+
+ foreach ( array_keys( $item ) as $field ) {
+ if ( '_links' === $field || '_embedded' === $field ) {
+ continue;
+ }
+
+ $this->assertArrayHasKey(
+ $field,
+ $schema['properties'],
+ sprintf( 'The document controller returns "%s" but does not declare it.', $field )
+ );
+ }
+ }
+
+ /**
+ * A request that sends no `_fields` must return every media field.
+ */
+ public function test_no_field_selection_returns_every_media_field() {
+ $schema = $this->media_endpoint->get_item_schema();
+ $item = $this->get_data( $this->media_url );
+
+ foreach ( array_keys( $schema['properties'] ) as $field ) {
+ $this->assertArrayHasKey( $field, $item[0], sprintf( 'Media field "%s" went missing.', $field ) );
+ }
+ }
+
+ /**
+ * A narrow selection returns exactly the requested media keys.
+ */
+ public function test_narrow_field_selection_returns_only_the_requested_media_keys() {
+ $item = $this->get_data( $this->media_url, array( '_fields' => 'id,title' ) );
+
+ $actual = array_keys( $item[0] );
+ sort( $actual );
+
+ $this->assertSame( array( 'id', 'title' ), $actual );
+ }
+
+ /**
+ * An unselected `display_name` must not be resolved.
+ */
+ public function test_media_display_name_is_not_resolved_when_it_is_not_selected() {
+ $this->assertSame(
+ 0,
+ $this->count_hook( 'bp_core_get_user_displayname', $this->media_url, array( '_fields' => 'id,title' ) )
+ );
+ }
+
+ /**
+ * ...but a selected one still is.
+ */
+ public function test_media_display_name_is_resolved_when_it_is_selected() {
+ $this->assertGreaterThan(
+ 0,
+ $this->count_hook( 'bp_core_get_user_displayname', $this->media_url, array( '_fields' => 'id,display_name' ) )
+ );
+ }
+
+ /**
+ * The same contract for documents.
+ */
+ public function test_narrow_field_selection_returns_only_the_requested_document_keys() {
+ $item = $this->get_data( $this->document_url . '/' . $this->document_id, array( '_fields' => 'id,title' ) );
+
+ $actual = array_keys( $item );
+ sort( $actual );
+
+ $this->assertSame( array( 'id', 'title' ), $actual );
+ }
+
+ /**
+ * An unselected document `display_name` must not be resolved.
+ */
+ public function test_document_display_name_is_not_resolved_when_it_is_not_selected() {
+ $this->assertSame(
+ 0,
+ $this->count_hook( 'bp_core_get_user_displayname', $this->document_url . '/' . $this->document_id, array( '_fields' => 'id,title' ) )
+ );
+ }
+
+ /**
+ * Nested attachments are returned whole unless `attachment_fields` says
+ * otherwise: the activity's own `_fields` cannot reach inside a list.
+ */
+ public function test_nested_media_is_whole_without_attachment_fields() {
+ $activity = $this->get_activity();
+
+ $this->assertNotEmpty( $activity['bp_media_ids'] );
+
+ $media = $activity['bp_media_ids'][0];
+
+ $this->assertArrayHasKey( 'title', $media );
+ $this->assertArrayHasKey( 'attachment_data', $media );
+ $this->assertArrayHasKey( 'display_name', $media );
+ }
+
+ /**
+ * ...and `attachment_fields` narrows them.
+ */
+ public function test_attachment_fields_narrows_nested_media() {
+ $activity = $this->get_activity( array( 'attachment_fields' => 'id,title' ) );
+
+ $this->assertNotEmpty( $activity['bp_media_ids'] );
+
+ $media = $activity['bp_media_ids'][0];
+
+ $this->assertArrayHasKey( 'id', $media );
+ $this->assertArrayHasKey( 'title', $media );
+ $this->assertArrayNotHasKey( 'attachment_data', $media );
+ $this->assertArrayNotHasKey( 'display_name', $media );
+ }
+
+ /**
+ * It reaches nested documents too.
+ */
+ public function test_attachment_fields_narrows_nested_documents() {
+ $activity = $this->get_activity( array( 'attachment_fields' => 'id,title' ) );
+
+ $this->assertNotEmpty( $activity['bp_documents'] );
+
+ $document = $activity['bp_documents'][0];
+
+ $this->assertArrayHasKey( 'title', $document );
+ $this->assertArrayNotHasKey( 'msg_preview', $document );
+ $this->assertArrayNotHasKey( 'display_name', $document );
+ }
+
+ /**
+ * It applies to the attachments only, never to their parent activity.
+ */
+ public function test_attachment_fields_leaves_the_parent_activity_whole() {
+ $activity = $this->get_activity( array( 'attachment_fields' => 'id' ) );
+
+ $this->assertArrayHasKey( 'content', $activity );
+ $this->assertArrayHasKey( 'activity_data', $activity );
+ }
+
+ /**
+ * Narrowing the nested attachments has to cost less, not merely return
+ * less: the guards sit in front of the preview URLs, the download links
+ * and the per-user permission checks.
+ *
+ * `bp_core_get_user_displayname` is deliberately not used as the probe
+ * here -- it also fires while the activity action is generated, well
+ * before any attachment is prepared.
+ */
+ public function test_attachment_fields_reduces_the_work_behind_nested_attachments() {
+ // Warm anything memoised outside the object cache first.
+ $this->get_activity();
+
+ $full = $this->count_activity_queries( array() );
+ $narrow = $this->count_activity_queries( array( 'attachment_fields' => 'id,title' ) );
+
+ $this->assertLessThan(
+ $full,
+ $narrow,
+ sprintf( 'No selection cost %d queries, attachment_fields cost %d.', $full, $narrow )
+ );
+ }
+
+ /**
+ * Count the queries one activity request costs, from a cold cache.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_activity_queries( $params ) {
+ global $wpdb;
+
+ wp_cache_flush();
+
+ $before = $wpdb->num_queries;
+ $this->get_activity( $params );
+
+ return $wpdb->num_queries - $before;
+ }
+
+ /**
+ * `DELETE` answers with an envelope, so its `_fields` names envelope keys.
+ * The media nested under `previous` must still be built in full.
+ */
+ public function test_delete_returns_a_whole_previous_media() {
+ $request = new WP_REST_Request( 'DELETE', $this->media_url . '/' . $this->media_id );
+ $request->set_param( 'context', 'edit' );
+ $request->set_param( '_fields', 'deleted,previous' );
+
+ $response = $this->server->dispatch( $request );
+ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request );
+ $data = $response->get_data();
+
+ $this->assertArrayHasKey( 'previous', $data );
+ $this->assertArrayHasKey( 'title', $data['previous'] );
+ $this->assertArrayHasKey( 'attachment_data', $data['previous'] );
+ }
+}
diff --git a/tests/phpunit/testcases/members/rest-fields.php b/tests/phpunit/testcases/members/rest-fields.php
new file mode 100644
index 00000000000..da39ba06880
--- /dev/null
+++ b/tests/phpunit/testcases/members/rest-fields.php
@@ -0,0 +1,297 @@
+plugin_dir . 'bp-core/admin/bp-core-admin-schema.php';
+ bp_core_install_emails();
+ }
+
+ global $wp_rest_server;
+ $wp_rest_server = new WP_REST_Server();
+ do_action( 'rest_api_init', $wp_rest_server );
+ $this->server = $wp_rest_server;
+
+ $this->endpoint = new BP_REST_Members_Endpoint();
+ $this->endpoint_url = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/members';
+
+ $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $this->user_id );
+ }
+
+ /**
+ * Drop the REST server so the next test builds a fresh one.
+ */
+ public function tearDown(): void {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ parent::tearDown();
+ }
+
+ /**
+ * Dispatch the way `WP_REST_Server::serve_request()` does.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return array First member in the collection.
+ */
+ protected function get_first_member( $params = array() ) {
+ $request = new WP_REST_Request( 'GET', $this->endpoint_url );
+ $request->set_param( 'context', 'view' );
+
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ $response = $this->server->dispatch( $request );
+ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request );
+ $data = $response->get_data();
+
+ $this->assertNotEmpty( $data, 'The members collection came back empty.' );
+
+ return $data[0];
+ }
+
+ /**
+ * Count how many times a hook fires while a request is dispatched.
+ *
+ * @param string $hook Hook name.
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_hook( $hook, $params ) {
+ $calls = 0;
+
+ $counter = function ( $value ) use ( &$calls ) {
+ $calls++;
+
+ return $value;
+ };
+
+ add_filter( $hook, $counter );
+
+ try {
+ $this->get_first_member( $params );
+ } finally {
+ remove_filter( $hook, $counter );
+ }
+
+ return $calls;
+ }
+
+ /**
+ * Count the queries one members request costs, from a cold cache.
+ *
+ * @param array $params Request parameters.
+ *
+ * @return int
+ */
+ protected function count_queries( $params ) {
+ global $wpdb;
+
+ wp_cache_flush();
+
+ $before = $wpdb->num_queries;
+ $this->get_first_member( $params );
+
+ return $wpdb->num_queries - $before;
+ }
+
+ /**
+ * A request that sends no `_fields` must still return every field the
+ * `view` context exposes.
+ */
+ public function test_no_field_selection_returns_every_view_field() {
+ $member = $this->get_first_member();
+ $schema = $this->endpoint->get_item_schema();
+
+ foreach ( $schema['properties'] as $field => $property ) {
+ if ( empty( $property['context'] ) || ! in_array( 'view', (array) $property['context'], true ) ) {
+ continue;
+ }
+
+ $this->assertArrayHasKey( $field, $member, sprintf( 'Member field "%s" went missing.', $field ) );
+ }
+ }
+
+ /**
+ * Every key the unfiltered response returns has to be declared in the
+ * schema. An undeclared one cannot be guarded -- it would disappear from
+ * responses that send no `_fields` at all.
+ */
+ public function test_every_returned_field_is_declared_in_the_schema() {
+ $schema = $this->endpoint->get_item_schema();
+ $item = $this->get_first_member();
+
+ foreach ( array_keys( $item ) as $field ) {
+ if ( '_links' === $field || '_embedded' === $field ) {
+ continue;
+ }
+
+ $this->assertArrayHasKey(
+ $field,
+ $schema['properties'],
+ sprintf( 'The members controller returns "%s" but does not declare it.', $field )
+ );
+ }
+ }
+
+ /**
+ * A narrow selection returns exactly the requested keys.
+ */
+ public function test_narrow_field_selection_returns_only_the_requested_keys() {
+ $member = $this->get_first_member( array( '_fields' => 'id,name,user_login' ) );
+
+ $actual = array_keys( $member );
+ sort( $actual );
+
+ $this->assertSame( array( 'id', 'name', 'user_login' ), $actual );
+ }
+
+ /**
+ * Avatars cost two resolutions per row; an unselected `avatar_urls` must
+ * not fetch them.
+ */
+ public function test_avatars_are_not_fetched_when_not_selected() {
+ $this->assertSame( 0, $this->count_hook( 'bp_core_fetch_avatar_url', array( '_fields' => 'id,name' ) ) );
+ }
+
+ /**
+ * ...but a selected one still does.
+ */
+ public function test_avatars_are_fetched_when_selected() {
+ $schema = $this->endpoint->get_item_schema();
+
+ if ( empty( $schema['properties']['avatar_urls'] ) ) {
+ $this->markTestSkipped( 'Avatars are disabled on this install.' );
+ }
+
+ $this->assertGreaterThan( 0, $this->count_hook( 'bp_core_fetch_avatar_url', array( '_fields' => 'id,avatar_urls' ) ) );
+ }
+
+ /**
+ * The xprofile field set is the most expensive thing a member row builds.
+ * `bp_xprofile_get_groups` fires only from the assembly itself, so it is a
+ * clean probe -- unlike a raw query count, which the member query pollutes
+ * by resolving display names through the same tables.
+ */
+ public function test_xprofile_is_not_assembled_when_not_selected() {
+ if ( ! bp_is_active( 'xprofile' ) ) {
+ $this->markTestSkipped( 'XProfile is not active on this install.' );
+ }
+
+ $this->assertSame( 0, $this->count_hook( 'bp_xprofile_get_groups', array( '_fields' => 'id,name' ) ) );
+ }
+
+ /**
+ * ...but a selected one still is.
+ */
+ public function test_xprofile_is_assembled_when_selected() {
+ if ( ! bp_is_active( 'xprofile' ) ) {
+ $this->markTestSkipped( 'XProfile is not active on this install.' );
+ }
+
+ $this->assertGreaterThan( 0, $this->count_hook( 'bp_xprofile_get_groups', array( '_fields' => 'id,xprofile' ) ) );
+ }
+
+ /**
+ * A narrow selection must cost measurably fewer queries than no selection.
+ */
+ public function test_narrow_field_selection_runs_fewer_queries() {
+ self::factory()->user->create_many( 5 );
+
+ // Warm anything memoised outside the object cache first.
+ $this->get_first_member();
+
+ $full = $this->count_queries( array() );
+ $narrow = $this->count_queries( array( '_fields' => 'id,name,user_login' ) );
+
+ $this->assertLessThan(
+ $full,
+ $narrow,
+ sprintf( 'No selection cost %d queries, a narrow selection cost %d.', $full, $narrow )
+ );
+ }
+
+ /**
+ * `member_types` is refined after it is first resolved; the refinement
+ * must not depend on the field having been selected.
+ */
+ public function test_member_types_survive_a_selection_that_names_only_them() {
+ $member = $this->get_first_member( array( '_fields' => 'id,member_types' ) );
+
+ $this->assertArrayHasKey( 'member_types', $member );
+ $this->assertIsArray( $member['member_types'] );
+ }
+
+ /**
+ * The group membership controller merges `user_data()` into a payload of
+ * its own, so a selection there must not break either half.
+ */
+ public function test_group_membership_still_merges_member_data() {
+ if ( ! bp_is_active( 'groups' ) ) {
+ $this->markTestSkipped( 'Groups are not active on this install.' );
+ }
+
+ $group_id = self::factory()->group->create( array( 'creator_id' => $this->user_id ) );
+ $member = self::factory()->user->create();
+ groups_join_group( $group_id, $member );
+
+ $request = new WP_REST_Request( 'GET', '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/groups/' . $group_id . '/members' );
+ $request->set_param( 'context', 'view' );
+
+ $response = $this->server->dispatch( $request );
+ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request );
+ $data = $response->get_data();
+
+ $this->assertNotEmpty( $data );
+ $this->assertArrayHasKey( 'name', $data[0] );
+ $this->assertArrayHasKey( 'is_admin', $data[0] );
+ }
+}