Make WordPress Core

Changeset 46184


Ignore:
Timestamp:
09/19/2019 02:04:51 PM (5 years ago)
Author:
kadamwhite
Message:

REST API: Support dot.nested hierarchical properties in _fields query parameter.

Enable clients to opt-in to receipt of one or more specific sub-properties within a response, and not other sub-properties.
Skip potentially expensive filtering and processing for post resources which were explicitly not requested.

Props kadamwhite, TimothyBlynJacobs, dlh.
Fixes #42094.

Location:
trunk
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/rest-api.php

    r46101 r46184  
    698698
    699699/**
     700 * Recursively computes the intersection of arrays using keys for comparison.
     701 *
     702 * @param  array $array1 The array with master keys to check.
     703 * @param  array $array2 An array to compare keys against.
     704 *
     705 * @return array An associative array containing all the entries of array1 which have keys that are present in all arguments.
     706 */
     707function _rest_array_intersect_key_recursive( $array1, $array2 ) {
     708    $array1 = array_intersect_key( $array1, $array2 );
     709    foreach ( $array1 as $key => $value ) {
     710        if ( is_array( $value ) && is_array( $array2[ $key ] ) ) {
     711            $array1[ $key ] = _rest_array_intersect_key_recursive( $value, $array2[ $key ] );
     712        }
     713    }
     714    return $array1;
     715}
     716
     717/**
    700718 * Filter the API response to include only a white-listed set of response object fields.
    701719 *
     
    724742    $fields = array_map( 'trim', $fields );
    725743
    726     $fields_as_keyed = array_combine( $fields, array_fill( 0, count( $fields ), true ) );
     744    // Create nested array of accepted field hierarchy.
     745    $fields_as_keyed = array();
     746    foreach ( $fields as $field ) {
     747        $parts = explode( '.', $field );
     748        $ref   = &$fields_as_keyed;
     749        while ( count( $parts ) > 1 ) {
     750            $next         = array_shift( $parts );
     751            $ref[ $next ] = array();
     752            $ref          = &$ref[ $next ];
     753        }
     754        $last         = array_shift( $parts );
     755        $ref[ $last ] = true;
     756    }
    727757
    728758    if ( wp_is_numeric_array( $data ) ) {
    729759        $new_data = array();
    730760        foreach ( $data as $item ) {
    731             $new_data[] = array_intersect_key( $item, $fields_as_keyed );
     761            $new_data[] = _rest_array_intersect_key_recursive( $item, $fields_as_keyed );
    732762        }
    733763    } else {
    734         $new_data = array_intersect_key( $data, $fields_as_keyed );
     764        $new_data = _rest_array_intersect_key_recursive( $data, $fields_as_keyed );
    735765    }
    736766
     
    738768
    739769    return $response;
     770}
     771
     772/**
     773 * Given an array of fields to include in a response, some of which may be
     774 * `nested.fields`, determine whether the provided field should be included
     775 * in the response body.
     776 *
     777 * If a parent field is passed in, the presence of any nested field within
     778 * that parent will cause the method to return `true`. For example "title"
     779 * will return true if any of `title`, `title.raw` or `title.rendered` is
     780 * provided.
     781 *
     782 * @since 5.3.0
     783 *
     784 * @param string $field  A field to test for inclusion in the response body.
     785 * @param array  $fields An array of string fields supported by the endpoint.
     786 * @return bool Whether to include the field or not.
     787 */
     788function rest_is_field_included( $field, $fields ) {
     789    if ( in_array( $field, $fields, true ) ) {
     790        return true;
     791    }
     792    foreach ( $fields as $accepted_field ) {
     793        // Check to see if $field is the parent of any item in $fields.
     794        // A field "parent" should be accepted if "parent.child" is accepted.
     795        if ( strpos( $accepted_field, "$field." ) === 0 ) {
     796            return true;
     797        }
     798        // Conversely, if "parent" is accepted, all "parent.child" fields should
     799        // also be accepted.
     800        if ( strpos( $field, "$accepted_field." ) === 0 ) {
     801            return true;
     802        }
     803    }
     804    return false;
    740805}
    741806
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php

    r46069 r46184  
    563563            $requested_fields[] = 'id';
    564564        }
    565         return array_intersect( $fields, $requested_fields );
     565        // Return the list of all requested fields which appear in the schema.
     566        return array_reduce(
     567            $requested_fields,
     568            function( $response_fields, $field ) use ( $fields ) {
     569                if ( in_array( $field, $fields, true ) ) {
     570                    $response_fields[] = $field;
     571                    return $response_fields;
     572                }
     573                // Check for nested fields if $field is not a direct match.
     574                $nested_fields = explode( '.', $field );
     575                // A nested field is included so long as its top-level property is
     576                // present in the schema.
     577                if ( in_array( $nested_fields[0], $fields, true ) ) {
     578                    $response_fields[] = $field;
     579                }
     580                return $response_fields;
     581            },
     582            array()
     583        );
    566584    }
    567585
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php

    r46068 r46184  
    14401440        $data = array();
    14411441
    1442         if ( in_array( 'id', $fields, true ) ) {
     1442        if ( rest_is_field_included( 'id', $fields ) ) {
    14431443            $data['id'] = $post->ID;
    14441444        }
    14451445
    1446         if ( in_array( 'date', $fields, true ) ) {
     1446        if ( rest_is_field_included( 'date', $fields ) ) {
    14471447            $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date );
    14481448        }
    14491449
    1450         if ( in_array( 'date_gmt', $fields, true ) ) {
     1450        if ( rest_is_field_included( 'date_gmt', $fields ) ) {
    14511451            // For drafts, `post_date_gmt` may not be set, indicating that the
    14521452            // date of the draft should be updated each time it is saved (see
     
    14611461        }
    14621462
    1463         if ( in_array( 'guid', $fields, true ) ) {
     1463        if ( rest_is_field_included( 'guid', $fields ) ) {
    14641464            $data['guid'] = array(
    14651465                /** This filter is documented in wp-includes/post-template.php */
     
    14691469        }
    14701470
    1471         if ( in_array( 'modified', $fields, true ) ) {
     1471        if ( rest_is_field_included( 'modified', $fields ) ) {
    14721472            $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified );
    14731473        }
    14741474
    1475         if ( in_array( 'modified_gmt', $fields, true ) ) {
     1475        if ( rest_is_field_included( 'modified_gmt', $fields ) ) {
    14761476            // For drafts, `post_modified_gmt` may not be set (see
    14771477            // `post_date_gmt` comments above).  In this case, shim the value
     
    14861486        }
    14871487
    1488         if ( in_array( 'password', $fields, true ) ) {
     1488        if ( rest_is_field_included( 'password', $fields ) ) {
    14891489            $data['password'] = $post->post_password;
    14901490        }
    14911491
    1492         if ( in_array( 'slug', $fields, true ) ) {
     1492        if ( rest_is_field_included( 'slug', $fields ) ) {
    14931493            $data['slug'] = $post->post_name;
    14941494        }
    14951495
    1496         if ( in_array( 'status', $fields, true ) ) {
     1496        if ( rest_is_field_included( 'status', $fields ) ) {
    14971497            $data['status'] = $post->post_status;
    14981498        }
    14991499
    1500         if ( in_array( 'type', $fields, true ) ) {
     1500        if ( rest_is_field_included( 'type', $fields ) ) {
    15011501            $data['type'] = $post->post_type;
    15021502        }
    15031503
    1504         if ( in_array( 'link', $fields, true ) ) {
     1504        if ( rest_is_field_included( 'link', $fields ) ) {
    15051505            $data['link'] = get_permalink( $post->ID );
    15061506        }
    15071507
    1508         if ( in_array( 'title', $fields, true ) ) {
     1508        if ( rest_is_field_included( 'title', $fields ) ) {
     1509            $data['title'] = array();
     1510        }
     1511        if ( rest_is_field_included( 'title.raw', $fields ) ) {
     1512            $data['title']['raw'] = $post->post_title;
     1513        }
     1514        if ( rest_is_field_included( 'title.rendered', $fields ) ) {
    15091515            add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
    15101516
    1511             $data['title'] = array(
    1512                 'raw'      => $post->post_title,
    1513                 'rendered' => get_the_title( $post->ID ),
    1514             );
     1517            $data['title']['rendered'] = get_the_title( $post->ID );
    15151518
    15161519            remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
     
    15261529        }
    15271530
    1528         if ( in_array( 'content', $fields, true ) ) {
    1529             $data['content'] = array(
    1530                 'raw'           => $post->post_content,
    1531                 /** This filter is documented in wp-includes/post-template.php */
    1532                 'rendered'      => post_password_required( $post ) ? '' : apply_filters( 'the_content', $post->post_content ),
    1533                 'protected'     => (bool) $post->post_password,
    1534                 'block_version' => block_version( $post->post_content ),
    1535             );
    1536         }
    1537 
    1538         if ( in_array( 'excerpt', $fields, true ) ) {
     1531        if ( rest_is_field_included( 'content', $fields ) ) {
     1532            $data['content'] = array();
     1533        }
     1534        if ( rest_is_field_included( 'content.raw', $fields ) ) {
     1535            $data['content']['raw'] = $post->post_content;
     1536        }
     1537        if ( rest_is_field_included( 'content.rendered', $fields ) ) {
     1538            /** This filter is documented in wp-includes/post-template.php */
     1539            $data['content']['rendered'] = post_password_required( $post ) ? '' : apply_filters( 'the_content', $post->post_content );
     1540        }
     1541        if ( rest_is_field_included( 'content.protected', $fields ) ) {
     1542            $data['content']['protected'] = (bool) $post->post_password;
     1543        }
     1544        if ( rest_is_field_included( 'content.block_version', $fields ) ) {
     1545            $data['content']['block_version'] = block_version( $post->post_content );
     1546        }
     1547
     1548        if ( rest_is_field_included( 'excerpt', $fields ) ) {
    15391549            /** This filter is documented in wp-includes/post-template.php */
    15401550            $excerpt         = apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ) );
     
    15511561        }
    15521562
    1553         if ( in_array( 'author', $fields, true ) ) {
     1563        if ( rest_is_field_included( 'author', $fields ) ) {
    15541564            $data['author'] = (int) $post->post_author;
    15551565        }
    15561566
    1557         if ( in_array( 'featured_media', $fields, true ) ) {
     1567        if ( rest_is_field_included( 'featured_media', $fields ) ) {
    15581568            $data['featured_media'] = (int) get_post_thumbnail_id( $post->ID );
    15591569        }
    15601570
    1561         if ( in_array( 'parent', $fields, true ) ) {
     1571        if ( rest_is_field_included( 'parent', $fields ) ) {
    15621572            $data['parent'] = (int) $post->post_parent;
    15631573        }
    15641574
    1565         if ( in_array( 'menu_order', $fields, true ) ) {
     1575        if ( rest_is_field_included( 'menu_order', $fields ) ) {
    15661576            $data['menu_order'] = (int) $post->menu_order;
    15671577        }
    15681578
    1569         if ( in_array( 'comment_status', $fields, true ) ) {
     1579        if ( rest_is_field_included( 'comment_status', $fields ) ) {
    15701580            $data['comment_status'] = $post->comment_status;
    15711581        }
    15721582
    1573         if ( in_array( 'ping_status', $fields, true ) ) {
     1583        if ( rest_is_field_included( 'ping_status', $fields ) ) {
    15741584            $data['ping_status'] = $post->ping_status;
    15751585        }
    15761586
    1577         if ( in_array( 'sticky', $fields, true ) ) {
     1587        if ( rest_is_field_included( 'sticky', $fields ) ) {
    15781588            $data['sticky'] = is_sticky( $post->ID );
    15791589        }
    15801590
    1581         if ( in_array( 'template', $fields, true ) ) {
     1591        if ( rest_is_field_included( 'template', $fields ) ) {
    15821592            $template = get_page_template_slug( $post->ID );
    15831593            if ( $template ) {
     
    15881598        }
    15891599
    1590         if ( in_array( 'format', $fields, true ) ) {
     1600        if ( rest_is_field_included( 'format', $fields ) ) {
    15911601            $data['format'] = get_post_format( $post->ID );
    15921602
     
    15971607        }
    15981608
    1599         if ( in_array( 'meta', $fields, true ) ) {
     1609        if ( rest_is_field_included( 'meta', $fields ) ) {
    16001610            $data['meta'] = $this->meta->get_value( $post->ID, $request );
    16011611        }
     
    16061616            $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
    16071617
    1608             if ( in_array( $base, $fields, true ) ) {
     1618            if ( rest_is_field_included( $base, $fields ) ) {
    16091619                $terms         = get_the_terms( $post, $taxonomy->name );
    16101620                $data[ $base ] = $terms ? array_values( wp_list_pluck( $terms, 'term_id' ) ) : array();
     
    16141624        $post_type_obj = get_post_type_object( $post->post_type );
    16151625        if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) {
    1616             $permalink_template_requested = in_array( 'permalink_template', $fields, true );
    1617             $generated_slug_requested     = in_array( 'generated_slug', $fields, true );
     1626            $permalink_template_requested = rest_is_field_included( 'permalink_template', $fields );
     1627            $generated_slug_requested     = rest_is_field_included( 'generated_slug', $fields );
    16181628
    16191629            if ( $permalink_template_requested || $generated_slug_requested ) {
  • trunk/tests/phpunit/tests/rest-api.php

    r46099 r46184  
    518518            $response->get_data()
    519519        );
     520    }
     521
     522    /**
     523     * Ensure that nested fields may be whitelisted with request['_fields'].
     524     *
     525     * @ticket 42094
     526     */
     527    public function test_rest_filter_response_fields_nested_field_filter() {
     528        $response = new WP_REST_Response();
     529
     530        $response->set_data(
     531            array(
     532                'a' => 0,
     533                'b' => array(
     534                    '1' => 1,
     535                    '2' => 2,
     536                ),
     537                'c' => 3,
     538                'd' => array(
     539                    '4' => 4,
     540                    '5' => 5,
     541                ),
     542            )
     543        );
     544        $request = array(
     545            '_fields' => 'b.1,c,d.5',
     546        );
     547
     548        $response = rest_filter_response_fields( $response, null, $request );
     549        $this->assertEquals(
     550            array(
     551                'b' => array(
     552                    '1' => 1,
     553                ),
     554                'c' => 3,
     555                'd' => array(
     556                    '5' => 5,
     557                ),
     558            ),
     559            $response->get_data()
     560        );
     561    }
     562
     563    /**
     564     * @ticket 42094
     565     */
     566    public function test_rest_is_field_included() {
     567        $fields = array(
     568            'id',
     569            'title',
     570            'content.raw',
     571            'custom.property',
     572        );
     573
     574        $this->assertTrue( rest_is_field_included( 'id', $fields ) );
     575        $this->assertTrue( rest_is_field_included( 'title', $fields ) );
     576        $this->assertTrue( rest_is_field_included( 'title.raw', $fields ) );
     577        $this->assertTrue( rest_is_field_included( 'title.rendered', $fields ) );
     578        $this->assertTrue( rest_is_field_included( 'content', $fields ) );
     579        $this->assertTrue( rest_is_field_included( 'content.raw', $fields ) );
     580        $this->assertTrue( rest_is_field_included( 'custom.property', $fields ) );
     581        $this->assertFalse( rest_is_field_included( 'content.rendered', $fields ) );
     582        $this->assertFalse( rest_is_field_included( 'type', $fields ) );
     583        $this->assertFalse( rest_is_field_included( 'meta', $fields ) );
     584        $this->assertFalse( rest_is_field_included( 'meta.value', $fields ) );
    520585    }
    521586
  • trunk/tests/phpunit/tests/rest-api/rest-controller.php

    r45706 r46184  
    233233        return array(
    234234            array(
    235                 'somestring,someinteger',
     235                'somestring,someinteger,someinvalidkey',
    236236                array(
    237237                    'somestring',
  • trunk/tests/phpunit/tests/rest-api/rest-posts-controller.php

    r45811 r46184  
    16881688            array_keys( $response->get_data() )
    16891689        );
     1690    }
     1691
     1692    /**
     1693     * @ticket 42094
     1694     */
     1695    public function test_prepare_item_filters_content_when_needed() {
     1696        $filter_count   = 0;
     1697        $filter_content = function() use ( &$filter_count ) {
     1698            $filter_count++;
     1699            return '<p>Filtered content.</p>';
     1700        };
     1701        add_filter( 'the_content', $filter_content );
     1702
     1703        wp_set_current_user( self::$editor_id );
     1704        $endpoint = new WP_REST_Posts_Controller( 'post' );
     1705        $request  = new WP_REST_REQUEST( 'GET', sprintf( '/wp/v2/posts/%d', self::$post_id ) );
     1706
     1707        $request->set_param( 'context', 'edit' );
     1708        $request->set_param( '_fields', 'content.rendered' );
     1709
     1710        $post     = get_post( self::$post_id );
     1711        $response = $endpoint->prepare_item_for_response( $post, $request );
     1712
     1713        remove_filter( 'the_content', $filter_content );
     1714
     1715        $this->assertEquals(
     1716            array(
     1717                'id'      => self::$post_id,
     1718                'content' => array(
     1719                    'rendered' => '<p>Filtered content.</p>',
     1720                ),
     1721            ),
     1722            $response->get_data()
     1723        );
     1724        $this->assertSame( 1, $filter_count );
     1725    }
     1726
     1727    /**
     1728     * @ticket 42094
     1729     */
     1730    public function test_prepare_item_skips_content_filter_if_not_needed() {
     1731        $filter_count   = 0;
     1732        $filter_content = function() use ( &$filter_count ) {
     1733            $filter_count++;
     1734            return '<p>Filtered content.</p>';
     1735        };
     1736        add_filter( 'the_content', $filter_content );
     1737
     1738        wp_set_current_user( self::$editor_id );
     1739        $endpoint = new WP_REST_Posts_Controller( 'post' );
     1740        $request  = new WP_REST_REQUEST( 'GET', sprintf( '/wp/v2/posts/%d', self::$post_id ) );
     1741
     1742        $request->set_param( 'context', 'edit' );
     1743        $request->set_param( '_fields', 'content.raw' );
     1744
     1745        $post     = get_post( self::$post_id );
     1746        $response = $endpoint->prepare_item_for_response( $post, $request );
     1747
     1748        remove_filter( 'the_content', $filter_content );
     1749
     1750        $this->assertEquals(
     1751            array(
     1752                'id'      => $post->ID,
     1753                'content' => array(
     1754                    'raw' => $post->post_content,
     1755                ),
     1756            ),
     1757            $response->get_data()
     1758        );
     1759        $this->assertSame( 0, $filter_count );
    16901760    }
    16911761
Note: See TracChangeset for help on using the changeset viewer.