Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
28.75% covered (danger)
28.75%
46 / 160
14.29% covered (danger)
14.29%
2 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Field_Controller
28.75% covered (danger)
28.75%
46 / 160
14.29% covered (danger)
14.29%
2 / 14
846.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
18.18% covered (danger)
18.18%
4 / 22
0.00% covered (danger)
0.00%
0 / 1
7.93
 register_fields
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 is_registered
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prepare_for_response
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 get_default_value
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
11
 get_for_response
26.67% covered (danger)
26.67%
4 / 15
0.00% covered (danger)
0.00%
0 / 1
6.55
 update_from_request
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 get_permission_check
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 get
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 update_permission_check
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 update
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 get_schema
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 is_valid_for_context
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 filter_response_by_context
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
12
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * `WP_REST_Controller` is basically a wrapper for `register_rest_route()`
4 * `WPCOM_REST_API_V2_Field_Controller` is a mostly-analogous wrapper for `register_rest_field()`
5 *
6 * @todo - nicer API for array values?
7 *
8 * @package automattic/jetpack
9 */
10
11/**
12 * Abstract WPCOM_REST_API_V2_Field_Controller class extended for different fields needed in the Jetpack plugin.
13 */
14abstract class WPCOM_REST_API_V2_Field_Controller {
15    /**
16     * The REST Object Type(s) to which the field should be added.
17     *
18     * @var string|string[]
19     */
20    protected $object_type;
21
22    /**
23     * The name of the REST API field to add.
24     *
25     * @var string
26     */
27    protected $field_name;
28
29    /**
30     * Constructor
31     */
32    public function __construct() {
33        if ( ! $this->object_type ) {
34            _doing_it_wrong(
35                'WPCOM_REST_API_V2_Field_Controller::$object_type',
36                sprintf(
37                    /* translators: %s: object_type */
38                    esc_html__( "Property '%s' must be overridden.", 'jetpack' ),
39                    'object_type'
40                ),
41                'jetpack-6.8'
42            );
43            return;
44        }
45
46        if ( ! $this->field_name ) {
47            _doing_it_wrong(
48                'WPCOM_REST_API_V2_Field_Controller::$field_name',
49                sprintf(
50                    /* translators: %s: field_name */
51                    esc_html__( "Property '%s' must be overridden.", 'jetpack' ),
52                    'field_name'
53                ),
54                'jetpack-6.8'
55            );
56            return;
57        }
58
59        add_action( 'rest_api_init', array( $this, 'register_fields' ) );
60
61        // do this again later to collect any CPTs that get registered later.
62        add_action( 'restapi_theme_init', array( $this, 'register_fields' ), 20 );
63    }
64
65    /**
66     * Registers the field with the appropriate schema and callbacks.
67     */
68    public function register_fields() {
69        foreach ( (array) $this->object_type as $object_type ) {
70            if ( $this->is_registered( $object_type ) ) {
71                continue;
72            }
73            register_rest_field(
74                $object_type,
75                $this->field_name,
76                array(
77                    'get_callback'    => array( $this, 'get_for_response' ),
78                    'update_callback' => array( $this, 'update_from_request' ),
79                    'schema'          => $this->get_schema(),
80                )
81            );
82        }
83    }
84
85    /**
86     * Checks if the field is already registered for the object_type
87     *
88     * @param string $object_type The name of the object type.
89     * @return boolean Whether the field has been registered for the type.
90     */
91    public function is_registered( $object_type ) {
92        global $wp_rest_additional_fields;
93
94        return ! empty( $wp_rest_additional_fields[ $object_type ][ $this->field_name ] );
95    }
96
97    /**
98     * Ensures the response matches the schema and request context.
99     *
100     * @param mixed           $value   Value passed in request.
101     * @param WP_REST_Request $request WP API request.
102     *
103     * @return mixed
104     */
105    private function prepare_for_response( $value, $request ) {
106        $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
107        $schema  = $this->get_schema();
108
109        $is_valid = rest_validate_value_from_schema( $value, $schema, $this->field_name );
110        if ( is_wp_error( $is_valid ) ) {
111            return $is_valid;
112        }
113
114        return $this->filter_response_by_context( $value, $schema, $context );
115    }
116
117    /**
118     * Returns the schema's default value
119     *
120     * If there is no default, returns the type's falsey value.
121     *
122     * @param array $schema Schema to validate against.
123     *
124     * @return mixed
125     */
126    final public function get_default_value( $schema ) {
127        if ( isset( $schema['default'] ) ) {
128            return $schema['default'];
129        }
130
131        // If you have something more complicated, use $schema['default'].
132        switch ( isset( $schema['type'] ) ? $schema['type'] : 'null' ) {
133            case 'string':
134                return '';
135            case 'integer':
136            case 'number':
137                return 0;
138            case 'object':
139                return (object) array();
140            case 'array':
141                return array();
142            case 'boolean':
143                return false;
144            case 'null':
145            default:
146                return null;
147        }
148    }
149
150    /**
151     * The field's wrapped getter. Does permission checks and output preparation.
152     *
153     * This cannot be extended: implement `->get()` instead.
154     *
155     * @param mixed           $object_data Probably an array. Whatever the endpoint returns.
156     * @param string          $field_name  Should always match `->field_name`.
157     * @param WP_REST_Request $request     WP API request.
158     * @param string          $object_type Should always match `->object_type`.
159     *
160     * @return mixed
161     */
162    final public function get_for_response( $object_data, $field_name, $request, $object_type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
163        $permission_check = $this->get_permission_check( $object_data, $request );
164
165        if ( ! $permission_check ) {
166            _doing_it_wrong(
167                'WPCOM_REST_API_V2_Field_Controller::get_permission_check',
168                sprintf(
169                    /* translators: %s: get_permission_check() */
170                    esc_html__( "Method '%s' must return either true or WP_Error.", 'jetpack' ),
171                    'get_permission_check'
172                ),
173                'jetpack-6.8'
174            );
175            return $this->get_default_value( $this->get_schema() );
176        }
177
178        if ( is_wp_error( $permission_check ) ) {
179            return $this->get_default_value( $this->get_schema() );
180        }
181
182        $value = $this->get( $object_data, $request );
183
184        return $this->prepare_for_response( $value, $request );
185    }
186
187    /**
188     * The field's wrapped setter. Does permission checks.
189     *
190     * This cannot be extended: implement `->update()` instead.
191     *
192     * @param mixed           $value       The new value for the field.
193     * @param mixed           $object_data Probably a WordPress object (e.g., WP_Post).
194     * @param string          $field_name  Should always match `->field_name`.
195     * @param WP_REST_Request $request     WP API request.
196     * @param string          $object_type Should always match `->object_type`.
197     * @return void|WP_Error
198     */
199    final public function update_from_request( $value, $object_data, $field_name, $request, $object_type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
200        $permission_check = $this->update_permission_check( $value, $object_data, $request );
201
202        if ( ! $permission_check ) {
203            _doing_it_wrong(
204                'WPCOM_REST_API_V2_Field_Controller::update_permission_check',
205                sprintf(
206                    /* translators: %s: update_permission_check() */
207                    esc_html__( "Method '%s' must return either true or WP_Error.", 'jetpack' ),
208                    'update_permission_check'
209                ),
210                'jetpack-6.8'
211            );
212
213            return new WP_Error(
214                'invalid_user_permission',
215                sprintf(
216                    /* translators: %s: the name of an API response field */
217                    __( "You are not allowed to access the '%s' field.", 'jetpack' ),
218                    $this->field_name
219                )
220            );
221        }
222
223        if ( is_wp_error( $permission_check ) ) {
224            return $permission_check;
225        }
226
227        $updated = $this->update( $value, $object_data, $request );
228
229        if ( is_wp_error( $updated ) ) {
230            return $updated;
231        }
232    }
233
234    /**
235     * Permission Check for the field's getter. Must be implemented in the inheriting class.
236     *
237     * @param mixed           $object_data Whatever the endpoint would return for its response.
238     * @param WP_REST_Request $request     WP API request.
239     *
240     * @return true|WP_Error
241     */
242    public function get_permission_check( $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
243        _doing_it_wrong(
244            'WPCOM_REST_API_V2_Field_Controller::get_permission_check',
245            sprintf(
246                /* translators: %s: method name. */
247                esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
248                __METHOD__
249            ),
250            'jetpack-6.8'
251        );
252        return null;
253    }
254
255    /**
256     * The field's "raw" getter. Must be implemented in the inheriting class.
257     *
258     * @param mixed           $object_data Whatever the endpoint would return for its response.
259     * @param WP_REST_Request $request     WP API request.
260     * @return mixed
261     */
262    public function get( $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
263        _doing_it_wrong(
264            'WPCOM_REST_API_V2_Field_Controller::get',
265            sprintf(
266                /* translators: %s: method name. */
267                esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
268                __METHOD__
269            ),
270            'jetpack-6.8'
271        );
272    }
273
274    /**
275     * Permission Check for the field's setter. Must be implemented in the inheriting class.
276     *
277     * @param mixed           $value The new value for the field.
278     * @param mixed           $object_data Probably a WordPress object (e.g., WP_Post).
279     * @param WP_REST_Request $request     WP API request.
280     *
281     * @return true|WP_Error
282     */
283    public function update_permission_check( $value, $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
284        _doing_it_wrong(
285            'WPCOM_REST_API_V2_Field_Controller::update_permission_check',
286            sprintf(
287                /* translators: %s: method name. */
288                esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
289                __METHOD__
290            ),
291            'jetpack-6.8'
292        );
293        return null;
294    }
295
296    /**
297     * The field's "raw" setter. Must be implemented in the inheriting class.
298     *
299     * @param mixed           $value The new value for the field.
300     * @param mixed           $object_data Probably a WordPress object (e.g., WP_Post).
301     * @param WP_REST_Request $request     WP API request.
302     *
303     * @return mixed
304     */
305    public function update( $value, $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
306        _doing_it_wrong(
307            'WPCOM_REST_API_V2_Field_Controller::update',
308            sprintf(
309                /* translators: %s: method name. */
310                esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
311                __METHOD__
312            ),
313            'jetpack-6.8'
314        );
315    }
316
317    /**
318     * The JSON Schema for the field
319     *
320     * @link https://json-schema.org/understanding-json-schema/
321     * As of WordPress 5.0, Core currently understands:
322     * * type
323     *   * string - not minLength, not maxLength, not pattern
324     *   * integer - minimum, maximum, exclusiveMinimum, exclusiveMaximum, not multipleOf
325     *   * number  - minimum, maximum, exclusiveMinimum, exclusiveMaximum, not multipleOf
326     *   * boolean
327     *   * null
328     *   * object - properties, additionalProperties, not propertyNames, not dependencies, not patternProperties, not required
329     *   * array: only lists, not tuples - items, not minItems, not maxItems, not uniqueItems, not contains
330     * * enum
331     * * format
332     *   * date-time
333     *   * email
334     *   * ip
335     *   * uri
336     * As of WordPress 5.0, Core does not support:
337     * * Multiple type: `type: [ 'string', 'integer' ]`
338     * * $ref, allOf, anyOf, oneOf, not, const
339     *
340     * @return array
341     */
342    public function get_schema() {
343        _doing_it_wrong(
344            'WPCOM_REST_API_V2_Field_Controller::get_schema',
345            sprintf(
346                /* translators: %s: method name. */
347                esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
348                __METHOD__
349            ),
350            'jetpack-6.8'
351        );
352        return null;
353    }
354
355    /**
356     * Ensure that our request matches its expected context.
357     *
358     * @param array  $schema  Schema to validate against.
359     * @param string $context REST API Request context.
360     * @return bool
361     */
362    private function is_valid_for_context( $schema, $context ) {
363        return empty( $schema['context'] ) || in_array( $context, $schema['context'], true );
364    }
365
366    /**
367     * Removes properties that should not appear in the current
368     * request's context
369     *
370     * $context is a Core REST API Framework request attribute that is
371     * always one of:
372     * * view (what you see on the blog)
373     * * edit (what you see in an editor)
374     * * embed (what you see in, e.g., an oembed)
375     *
376     * Fields (and sub-fields, and sub-sub-...) can be flagged for a
377     * set of specific contexts via the field's schema.
378     *
379     * The Core API will filter out top-level fields with the wrong
380     * context, but will not recurse deeply enough into arrays/objects
381     * to remove all levels of sub-fields with the wrong context.
382     *
383     * This function handles that recursion.
384     *
385     * @param mixed  $value   Value passed to API request.
386     * @param array  $schema  Schema to validate against.
387     * @param string $context REST API Request context.
388     *
389     * @return mixed Filtered $value
390     */
391    final public function filter_response_by_context( $value, $schema, $context ) {
392        if ( ! $this->is_valid_for_context( $schema, $context ) ) {
393            // We use this intentionally odd looking WP_Error object
394            // internally only in this recursive function (see below
395            // in the `object` case). It will never be output by the REST API.
396            // If we return this for the top level object, Core
397            // correctly remove the top level object from the response
398            // for us.
399            return new WP_Error( '__wrong-context__' );
400        }
401
402        switch ( $schema['type'] ) {
403            case 'array':
404                if ( ! isset( $schema['items'] ) ) {
405                    return $value;
406                }
407
408                // Shortcircuit if we know none of the items are valid for this context.
409                // This would only happen in a strangely written schema.
410                if ( ! $this->is_valid_for_context( $schema['items'], $context ) ) {
411                    return array();
412                }
413
414                // Recurse to prune sub-properties of each item.
415                foreach ( $value as $key => $item ) {
416                    $value[ $key ] = $this->filter_response_by_context( $item, $schema['items'], $context );
417                }
418
419                return $value;
420            case 'object':
421                if ( ! isset( $schema['properties'] ) ) {
422                    return $value;
423                }
424
425                foreach ( $value as $field_name => $field_value ) {
426                    if ( isset( $schema['properties'][ $field_name ] ) ) {
427                        $field_value = $this->filter_response_by_context( $field_value, $schema['properties'][ $field_name ], $context );
428                        if ( is_wp_error( $field_value ) && '__wrong-context__' === $field_value->get_error_code() ) {
429                            unset( $value[ $field_name ] );
430                        } else {
431                            // Respect recursion that pruned sub-properties of each property.
432                            $value[ $field_name ] = $field_value;
433                        }
434                    }
435                }
436
437                return (object) $value;
438        }
439
440        return $value;
441    }
442}