Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.33% covered (warning)
83.33%
230 / 276
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Connections_Post_Field
83.33% covered (warning)
83.33%
230 / 276
35.71% covered (danger)
35.71%
5 / 14
148.23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_fields
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 get_schema
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 post_connection_schema
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
1
 permission_check
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
7.10
 get
74.36% covered (warning)
74.36%
29 / 39
0.00% covered (danger)
0.00%
0 / 1
23.46
 rest_pre_insert
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 rest_insert
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 get_meta_to_update
77.78% covered (warning)
77.78%
35 / 45
0.00% covered (danger)
0.00%
0 / 1
27.31
 update
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 save_connection_overrides
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
16.43
 sanitize_attached_media
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
8.12
 filter_response_by_context
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
12.37
 is_valid_for_context
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Registers the API field for Publicize connections.
4 *
5 * @package automattic/jetpack-publicize
6 */
7
8namespace Automattic\Jetpack\Publicize\REST_API;
9
10use Automattic\Jetpack\Current_Plan;
11use Automattic\Jetpack\Publicize\Publicize_Base;
12use WP_Error;
13use WP_Post;
14use WP_REST_Request;
15
16/**
17 * The class to register the field and augment requests
18 * to Publicize supported post types.
19 *
20 * @phan-constructor-used-for-side-effects
21 */
22class Connections_Post_Field {
23
24    const FIELD_NAME = 'jetpack_publicize_connections';
25
26    /**
27     * Array of post IDs that have been updated.
28     *
29     * @var array
30     */
31    private $meta_saved = array();
32
33    /**
34     * Used to memoize the updates for a given post.
35     *
36     * @var array
37     */
38    public $memoized_updates = array();
39
40    /**
41     * Constructor.
42     */
43    public function __construct() {
44        add_action( 'rest_api_init', array( $this, 'register_fields' ) );
45    }
46
47    /**
48     * Registers the jetpack_publicize_connections field. Called
49     * automatically on `rest_api_init()`.
50     */
51    public function register_fields() {
52        $post_types = get_post_types_by_support( 'publicize' );
53        foreach ( $post_types as $post_type ) {
54            // Adds meta support for those post types that don't already have it.
55            // Only runs during REST API requests, so it doesn't impact UI.
56            if ( ! post_type_supports( $post_type, 'custom-fields' ) ) {
57                add_post_type_support( $post_type, 'custom-fields' );
58            }
59
60            // We use these hooks and not the update_callback because we must updateth meta
61            // before we set the post as published, otherwise the wrong connections could be used.
62            add_filter( 'rest_pre_insert_' . $post_type, array( $this, 'rest_pre_insert' ), 10, 2 );
63            add_action( 'rest_insert_' . $post_type, array( $this, 'rest_insert' ), 10, 3 );
64
65            register_rest_field(
66                $post_type,
67                self::FIELD_NAME,
68                array(
69                    'get_callback' => array( $this, 'get' ),
70                    'schema'       => $this->get_schema(),
71                )
72            );
73        }
74    }
75
76    /**
77     * Defines data structure and what elements are visible in which contexts
78     */
79    public function get_schema() {
80        return array(
81            '$schema' => 'http://json-schema.org/draft-04/schema#',
82            'title'   => 'jetpack-publicize-post-connections',
83            'type'    => 'array',
84            'context' => array( 'view', 'edit' ),
85            'items'   => $this->post_connection_schema(),
86            'default' => array(),
87        );
88    }
89
90    /**
91     * Schema for the endpoint.
92     */
93    private function post_connection_schema() {
94        $connection_fields = Connections_Controller::get_the_item_schema();
95        $deprecated_fields = array(
96            'id'             => array(
97                'type'        => 'string',
98                'description' => __( 'Unique identifier for the Jetpack Social connection.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
99                    /* translators: %s is the new field name */
100                    __( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
101                    'connection_id'
102                ),
103            ),
104            'username'       => array(
105                'type'        => 'string',
106                'description' => __( 'Username of the connected account.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
107                    /* translators: %s is the new field name */
108                    __( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
109                    'external_handle'
110                ),
111            ),
112            'can_disconnect' => array(
113                'description' => __( 'Whether the current user can disconnect this connection.', 'jetpack-publicize-pkg' ) . ' ' . __( 'Deprecated.', 'jetpack-publicize-pkg' ),
114                'type'        => 'boolean',
115                'context'     => array( 'view', 'edit' ),
116                'readonly'    => true,
117            ),
118        );
119
120        return array(
121            '$schema'    => 'http://json-schema.org/draft-04/schema#',
122            'title'      => 'jetpack-publicize-post-connection',
123            'type'       => 'object',
124            'properties' => array_merge(
125                $deprecated_fields,
126                $connection_fields,
127                array(
128                    'enabled'        => array(
129                        'description' => __( 'Whether to share to this connection.', 'jetpack-publicize-pkg' ),
130                        'type'        => 'boolean',
131                        'context'     => array( 'edit' ),
132                    ),
133                    'message'        => array(
134                        'description' => __( 'Custom message to use for this connection instead of the global message.', 'jetpack-publicize-pkg' ),
135                        'type'        => 'string',
136                        'context'     => array( 'edit' ),
137                    ),
138                    'attached_media' => array(
139                        'description' => __( 'Custom media to attach for this connection instead of the global media.', 'jetpack-publicize-pkg' ),
140                        'type'        => 'array',
141                        'context'     => array( 'edit' ),
142                        'items'       => array(
143                            'type'       => 'object',
144                            'properties' => array(
145                                'id'   => array( 'type' => 'number' ),
146                                'url'  => array( 'type' => 'string' ),
147                                'type' => array( 'type' => 'string' ),
148                            ),
149                        ),
150                    ),
151                    'media_source'   => array(
152                        'type' => 'string',
153                        'enum' => array(
154                            'featured-image',
155                            'sig',
156                            'media-library',
157                            'upload-video',
158                            'none',
159                        ),
160                    ),
161                )
162            ),
163        );
164    }
165
166    /**
167     * Permission check, based on module availability and user capabilities.
168     *
169     * @param int $post_id Post ID.
170     *
171     * @return true|WP_Error
172     */
173    public function permission_check( $post_id ) {
174        global $publicize;
175
176        if ( ! $publicize ) {
177            return new WP_Error(
178                'publicize_not_available',
179                __( 'Sorry, Jetpack Social is not available on your site right now.', 'jetpack-publicize-pkg' ),
180                array( 'status' => rest_authorization_required_code() )
181            );
182        }
183
184        if ( $publicize->current_user_can_access_publicize_data( $post_id ) ) {
185            return true;
186        }
187
188        return new WP_Error(
189            'invalid_user_permission_publicize',
190            __( 'Sorry, you are not allowed to access Jetpack Social data for this post.', 'jetpack-publicize-pkg' ),
191            array( 'status' => rest_authorization_required_code() )
192        );
193    }
194
195    /**
196     * The field's wrapped getter. Does permission checks and output preparation.
197     *
198     * This cannot be extended: implement `->get()` instead.
199     *
200     * @param mixed           $post_array Probably an array. Whatever the endpoint returns.
201     * @param string          $field_name  Should always match `->field_name`.
202     * @param WP_REST_Request $request     WP API request.
203     * @param string          $object_type Should always match `->object_type`.
204     *
205     * @return mixed
206     */
207    public function get( $post_array, $field_name, $request, $object_type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
208        global $publicize;
209
210        $post_id          = $post_array['id'] ?? 0;
211        $full_schema      = $this->get_schema();
212        $permission_check = $this->permission_check( $post_id );
213        if ( is_wp_error( $permission_check ) ) {
214            return $full_schema['default'];
215        }
216
217        $schema      = $full_schema['items'];
218        $properties  = array_keys( $schema['properties'] );
219        $connections = $publicize->get_filtered_connection_data( $post_id );
220
221        if ( $publicize && $publicize->has_paid_features() ) {
222            // Check if per-network customization is enabled.
223            $customize_per_network = get_post_meta( $post_id, Publicize_Base::POST_CUSTOMIZE_PER_NETWORK, true );
224            // Get per-connection overrides from post meta.
225            $connection_overrides = get_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES, true );
226
227            if ( ! is_array( $connection_overrides ) ) {
228                $connection_overrides = array();
229            }
230        } else {
231            $customize_per_network = false;
232            $connection_overrides  = array();
233        }
234
235        $message_templates_enabled = Current_Plan::supports( 'social-message-templates' );
236
237        $output_connections = array();
238        foreach ( $connections as $connection ) {
239            $output_connection = array();
240            foreach ( $properties as $property ) {
241                if ( isset( $connection[ $property ] ) ) {
242                    $output_connection[ $property ] = $connection[ $property ];
243                }
244            }
245
246            // Default `message` to the connection's own template when set
247            if ( $message_templates_enabled && ! empty( $output_connection['template'] ) ) {
248                $output_connection['message'] = $output_connection['template'];
249            }
250
251            // Merge per-connection overrides if global flag is enabled.
252            $connection_id = $connection['connection_id'] ?? '';
253            if ( $customize_per_network && ! empty( $connection_id ) && isset( $connection_overrides[ $connection_id ] ) ) {
254                $override = $connection_overrides[ $connection_id ];
255                if ( isset( $override['message'] ) ) {
256                    $output_connection['message'] = $override['message'];
257                }
258                if ( isset( $override['attached_media'] ) ) {
259                    $output_connection['attached_media'] = $override['attached_media'];
260                }
261                if ( isset( $override['media_source'] ) ) {
262                    $output_connection['media_source'] = $override['media_source'];
263                }
264            }
265
266            $output_connections[] = $output_connection;
267        }
268
269        // TODO: Work out if this is necessary. We shouldn't be creating an invalid value here.
270        $is_valid = rest_validate_value_from_schema( $output_connections, $full_schema, self::FIELD_NAME );
271        if ( is_wp_error( $is_valid ) ) {
272            return $is_valid;
273        }
274
275        $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
276        return $this->filter_response_by_context( $output_connections, $full_schema, $context );
277    }
278
279    /**
280     * Prior to updating the post, first calculate which Services to
281     * Publicize to and which to skip.
282     *
283     * @param object          $post    Post data to insert/update.
284     * @param WP_REST_Request $request API request.
285     *
286     * @return object|WP_Error Filtered $post
287     */
288    public function rest_pre_insert( $post, $request ) {
289        $request_connections = ! empty( $request['jetpack_publicize_connections'] ) ? $request['jetpack_publicize_connections'] : array();
290
291        $permission_check = $this->permission_check( empty( $post->ID ) ? 0 : $post->ID );
292        if ( is_wp_error( $permission_check ) ) {
293            return empty( $request_connections ) ? $post : $permission_check;
294        }
295        // memoize.
296        $this->get_meta_to_update( $request_connections, $post->ID ?? 0 );
297
298        if ( isset( $post->ID ) ) {
299            // Set the meta before we mark the post as published so that publicize works as expected.
300            // If this is not the case post end up on social media when they are marked as skipped.
301            $this->update( $request_connections, $post, $request );
302        }
303
304        return $post;
305    }
306
307    /**
308     * After creating a new post, update our cached data to reflect
309     * the new post ID.
310     *
311     * @param WP_Post         $post    Post data to update.
312     * @param WP_REST_Request $request API request.
313     * @param bool            $is_new  Is this a new post.
314     */
315    public function rest_insert( $post, $request, $is_new ) {
316        if ( ! $is_new ) {
317            // An existing post was edited - no need to update
318            // our cache - we started out knowing the correct
319            // post ID.
320            return;
321        }
322
323        if ( ! isset( $this->memoized_updates[0] ) ) {
324            return;
325        }
326
327        $this->memoized_updates[ $post->ID ] = $this->memoized_updates[0];
328        unset( $this->memoized_updates[0] );
329    }
330
331    /**
332     * Get list of meta data to update per post ID.
333     *
334     * @param array $requested_connections Publicize connections to update.
335     *              Items are either `{ id: (string) }` or `{ service_name: (string) }`.
336     * @param int   $post_id    Post ID.
337     */
338    protected function get_meta_to_update( $requested_connections, $post_id = 0 ) {
339        global $publicize;
340
341        if ( ! $publicize || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ) {
342            return array();
343        }
344
345        $post = get_post( $post_id );
346        if ( isset( $post->post_status ) && 'publish' === $post->post_status ) {
347            return array();
348        }
349
350        if ( isset( $this->memoized_updates[ $post_id ] ) ) {
351            return $this->memoized_updates[ $post_id ];
352        }
353
354        $available_connections = $publicize->get_filtered_connection_data( $post_id );
355
356        $changed_connections = array();
357
358        // Build lookup mappings.
359        $available_connections_by_connection_id = array();
360        $available_connections_by_service_name  = array();
361        foreach ( $available_connections as $available_connection ) {
362            $available_connections_by_connection_id[ $available_connection['connection_id'] ] = $available_connection;
363
364            if ( ! isset( $available_connections_by_service_name[ $available_connection['service_name'] ] ) ) {
365                $available_connections_by_service_name[ $available_connection['service_name'] ] = array();
366            }
367            $available_connections_by_service_name[ $available_connection['service_name'] ][] = $available_connection;
368        }
369
370        // Handle { service_name: $service_name, enabled: (bool) }.
371        // If the service is not available, it will be skipped.
372        foreach ( $requested_connections as $requested_connection ) {
373            if ( ! isset( $requested_connection['service_name'] ) ) {
374                continue;
375            }
376
377            if ( ! isset( $available_connections_by_service_name[ $requested_connection['service_name'] ] ) ) {
378                continue;
379            }
380
381            foreach ( $available_connections_by_service_name[ $requested_connection['service_name'] ] as $available_connection ) {
382                if ( $requested_connection['connection_id'] === $available_connection['connection_id'] ) {
383                    $changed_connections[ $available_connection['connection_id'] ] = $requested_connection['enabled'];
384                    break;
385                }
386            }
387        }
388
389        // Handle { id: $id, enabled: (bool) }
390        // These override the service_name settings.
391        foreach ( $requested_connections as $requested_connection ) {
392            if ( ! isset( $requested_connection['connection_id'] ) ) {
393                continue;
394            }
395
396            if ( ! isset( $available_connections_by_connection_id[ $requested_connection['connection_id'] ] ) ) {
397                continue;
398            }
399
400            $changed_connections[ $requested_connection['connection_id'] ] = $requested_connection['enabled'];
401        }
402
403        // Set all changed connections to their new value.
404        foreach ( $changed_connections as $id => $enabled ) {
405            $connection = $available_connections_by_connection_id[ $id ];
406
407            if ( $connection['done'] ) {
408                continue;
409            }
410
411            $available_connections_by_connection_id[ $id ]['enabled'] = $enabled;
412        }
413
414        foreach ( Publicize_Base::get_x_connection_ids_to_skip( $available_connections_by_connection_id ) as $skip_id ) {
415            $available_connections_by_connection_id[ $skip_id ]['enabled'] = false;
416        }
417
418        $meta_to_update = array();
419        // For all connections, ensure correct post_meta.
420        foreach ( $available_connections_by_connection_id as $connection_id => $available_connection ) {
421            if ( $available_connection['enabled'] ) {
422                $meta_to_update[ $publicize->POST_SKIP_PUBLICIZE . $connection_id ] = null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
423            } else {
424                $meta_to_update[ $publicize->POST_SKIP_PUBLICIZE . $connection_id ] = 1; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
425            }
426        }
427
428        $this->memoized_updates[ $post_id ] = $meta_to_update;
429
430        return $meta_to_update;
431    }
432
433    /**
434     * Update the connections slated to be shared to.
435     *
436     * @param array           $requested_connections Publicize connections to update.
437     *              Items are either `{ id: (string) }` or `{ service_name: (string) }`.
438     * @param WP_Post         $post    Post data.
439     * @param WP_REST_Request $request API request.
440     */
441    public function update( $requested_connections, $post, $request = null ) {
442        global $publicize;
443
444        if ( isset( $this->meta_saved[ $post->ID ] ) ) { // Make sure we only save it once - per request.
445            return;
446        }
447        foreach ( $this->get_meta_to_update( $requested_connections, $post->ID ) as $meta_key => $meta_value ) {
448            if ( null === $meta_value ) {
449                delete_post_meta( $post->ID, $meta_key );
450            } else {
451                update_post_meta( $post->ID, $meta_key, $meta_value );
452            }
453        }
454
455        // Save per-connection overrides.
456        if ( $publicize && $publicize->has_paid_features() ) {
457            $this->save_connection_overrides( $requested_connections, $post->ID, $request );
458        }
459
460        $this->meta_saved[ $post->ID ] = true;
461    }
462
463    /**
464     * Save per-connection customization overrides.
465     *
466     * Extracts message and attached_media from each connection and persists
467     * them to post meta when per-network customization is enabled.
468     *
469     * @param array           $requested_connections Array of connection data from the request.
470     * @param int             $post_id               Post ID.
471     * @param WP_REST_Request $request               API request.
472     */
473    private function save_connection_overrides( $requested_connections, $post_id, $request = null ) {
474        // Check if per-network customization is enabled - prefer request value over database.
475        $customize_per_network = null;
476        if ( $request && isset( $request['meta'][ Publicize_Base::POST_CUSTOMIZE_PER_NETWORK ] ) ) {
477            $customize_per_network = $request['meta'][ Publicize_Base::POST_CUSTOMIZE_PER_NETWORK ];
478        }
479        if ( null === $customize_per_network ) {
480            $customize_per_network = get_post_meta( $post_id, Publicize_Base::POST_CUSTOMIZE_PER_NETWORK, true );
481        }
482
483        // If customization is disabled, remove any existing overrides.
484        if ( ! $customize_per_network ) {
485            delete_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES );
486            return;
487        }
488
489        // If the request does not have connections, skip.
490        if ( ! isset( $request[ self::FIELD_NAME ] ) ) {
491            return;
492        }
493
494        $overrides = array();
495
496        foreach ( $requested_connections as $connection ) {
497            // Only process if connection has a connection_id.
498            if ( empty( $connection['connection_id'] ) ) {
499                continue;
500            }
501
502            // Only save if connection has custom message or attached_media.
503            if ( ! isset( $connection['message'] ) && ! isset( $connection['attached_media'] ) && ! isset( $connection['media_source'] ) ) {
504                continue;
505            }
506
507            $connection_id               = $connection['connection_id'];
508            $overrides[ $connection_id ] = array();
509
510            // Save message (can be empty to use empty message).
511            if ( isset( $connection['message'] ) ) {
512                $overrides[ $connection_id ]['message'] = sanitize_textarea_field( $connection['message'] );
513            }
514
515            // Save attached_media (can be empty array to clear media).
516            if ( isset( $connection['attached_media'] ) ) {
517                $overrides[ $connection_id ]['attached_media'] = $this->sanitize_attached_media( $connection['attached_media'] );
518            }
519
520            // Save media_source (can be empty to use default).
521            if ( isset( $connection['media_source'] ) ) {
522                $overrides[ $connection_id ]['media_source'] = sanitize_text_field( $connection['media_source'] );
523            }
524        }
525
526        // Only save if there are overrides, otherwise delete the meta.
527        if ( ! empty( $overrides ) ) {
528            update_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES, $overrides );
529        } else {
530            delete_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES );
531        }
532    }
533
534    /**
535     * Sanitize attached media array.
536     *
537     * @param array $attached_media Array of media items.
538     * @return array Sanitized array of media items.
539     */
540    private function sanitize_attached_media( $attached_media ) {
541        if ( empty( $attached_media ) ) {
542            return array();
543        }
544
545        $sanitized = array();
546
547        foreach ( $attached_media as $media_item ) {
548            if ( ! is_array( $media_item ) ) {
549                continue;
550            }
551
552            $sanitized_item = array();
553
554            if ( isset( $media_item['id'] ) ) {
555                $sanitized_item['id'] = absint( $media_item['id'] );
556            }
557
558            if ( isset( $media_item['url'] ) ) {
559                $sanitized_item['url'] = esc_url_raw( $media_item['url'] );
560            }
561
562            if ( isset( $media_item['type'] ) ) {
563                $sanitized_item['type'] = sanitize_text_field( $media_item['type'] );
564            }
565
566            if ( ! empty( $sanitized_item ) ) {
567                $sanitized[] = $sanitized_item;
568            }
569        }
570
571        return $sanitized;
572    }
573
574    /**
575     * Removes properties that should not appear in the current
576     * request's context
577     *
578     * $context is a Core REST API Framework request attribute that is
579     * always one of:
580     * * view (what you see on the blog)
581     * * edit (what you see in an editor)
582     * * embed (what you see in, e.g., an oembed)
583     *
584     * Fields (and sub-fields, and sub-sub-...) can be flagged for a
585     * set of specific contexts via the field's schema.
586     *
587     * The Core API will filter out top-level fields with the wrong
588     * context, but will not recurse deeply enough into arrays/objects
589     * to remove all levels of sub-fields with the wrong context.
590     *
591     * This function handles that recursion.
592     *
593     * @param mixed  $value   Value passed to API request.
594     * @param array  $schema  Schema to validate against.
595     * @param string $context REST API Request context.
596     *
597     * @return mixed Filtered $value
598     */
599    public function filter_response_by_context( $value, $schema, $context ) {
600        if ( ! $this->is_valid_for_context( $schema, $context ) ) {
601            // We use this intentionally odd looking WP_Error object
602            // internally only in this recursive function (see below
603            // in the `object` case). It will never be output by the REST API.
604            // If we return this for the top level object, Core
605            // correctly remove the top level object from the response
606            // for us.
607            return new WP_Error( '__wrong-context__' );
608        }
609
610        switch ( $schema['type'] ) {
611            case 'array':
612                if ( ! isset( $schema['items'] ) ) {
613                    return $value;
614                }
615
616                // Shortcircuit if we know none of the items are valid for this context.
617                // This would only happen in a strangely written schema.
618                if ( ! $this->is_valid_for_context( $schema['items'], $context ) ) {
619                    return array();
620                }
621
622                // Recurse to prune sub-properties of each item.
623                foreach ( $value as $key => $item ) {
624                    $value[ $key ] = $this->filter_response_by_context( $item, $schema['items'], $context );
625                }
626
627                return $value;
628            case 'object':
629                if ( ! isset( $schema['properties'] ) ) {
630                    return $value;
631                }
632
633                foreach ( $value as $field_name => $field_value ) {
634                    if ( isset( $schema['properties'][ $field_name ] ) ) {
635                        $field_value = $this->filter_response_by_context( $field_value, $schema['properties'][ $field_name ], $context );
636                        if ( is_wp_error( $field_value ) && '__wrong-context__' === $field_value->get_error_code() ) {
637                            unset( $value[ $field_name ] );
638                        } else {
639                            // Respect recursion that pruned sub-properties of each property.
640                            $value[ $field_name ] = $field_value;
641                        }
642                    }
643                }
644
645                return (object) $value;
646        }
647
648        return $value;
649    }
650
651    /**
652     * Ensure that our request matches its expected context.
653     *
654     * @param array  $schema  Schema to validate against.
655     * @param string $context REST API Request context.
656     * @return bool
657     */
658    private function is_valid_for_context( $schema, $context ) {
659        return empty( $schema['context'] ) || in_array( $context, $schema['context'], true );
660    }
661}