Make WordPress Core

Changeset 49252


Ignore:
Timestamp:
10/20/2020 07:08:48 PM (4 years ago)
Author:
TimothyBlynJacobs
Message:

REST API: Introduce support for batching API requests.

A new route is introduced, batch/v1, that accepts a list of API requests to run. Each request runs in sequence, and the responses are returned in the order they've been received.

Optionally, the require-all-validate validation mode can be used to first validate each request's parameters and only proceed with processing if each request validates successfully.

By default, the batch size is limited to 25 requests. This can be controlled using the rest_get_max_batch_size filter. Clients are strongly encouraged to discover the maximum batch size supported by the server by making an OPTIONS request to the batch/v1 endpoint and inspecting the described arguments.

Additionally, the two new methods, match_request_to_handler and respond_to_request introduced in [48947] now have a protected visibility as we don't want to expose the inner workings of the WP_REST_Server::dispatch API.

Batching is not currently supported for GET requests.

Fixes #50244.
Props andraganescu, zieladam, TimothyBlynJacobs.

Location:
trunk
Files:
3 edited

Legend:

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

    r49109 r49252  
    9595        $this->endpoints = array(
    9696            // Meta endpoints.
    97             '/' => array(
     97            '/'         => array(
    9898                'callback' => array( $this, 'get_index' ),
    9999                'methods'  => 'GET',
     
    101101                    'context' => array(
    102102                        'default' => 'view',
     103                    ),
     104                ),
     105            ),
     106            '/batch/v1' => array(
     107                'callback' => array( $this, 'serve_batch_request_v1' ),
     108                'methods'  => 'POST',
     109                'args'     => array(
     110                    'validation' => array(
     111                        'type'    => 'string',
     112                        'enum'    => array( 'require-all-validate', 'normal' ),
     113                        'default' => 'normal',
     114                    ),
     115                    'requests'   => array(
     116                        'required' => true,
     117                        'type'     => 'array',
     118                        'maxItems' => $this->get_max_batch_size(),
     119                        'items'    => array(
     120                            'type'       => 'object',
     121                            'properties' => array(
     122                                'method'  => array(
     123                                    'type'    => 'string',
     124                                    'enum'    => array( 'POST', 'PUT', 'PATCH', 'DELETE' ),
     125                                    'default' => 'POST',
     126                                ),
     127                                'path'    => array(
     128                                    'type'     => 'string',
     129                                    'required' => true,
     130                                ),
     131                                'body'    => array(
     132                                    'type'                 => 'object',
     133                                    'properties'           => array(),
     134                                    'additionalProperties' => true,
     135                                ),
     136                                'headers' => array(
     137                                    'type'                 => 'object',
     138                                    'properties'           => array(),
     139                                    'additionalProperties' => array(
     140                                        'type'  => array( 'string', 'array' ),
     141                                        'items' => array(
     142                                            'type' => 'string',
     143                                        ),
     144                                    ),
     145                                ),
     146                            ),
     147                        ),
    103148                    ),
    104149                ),
     
    9721017     * @return array|WP_Error The route and request handler on success or a WP_Error instance if no handler was found.
    9731018     */
    974     public function match_request_to_handler( $request ) {
     1019    protected function match_request_to_handler( $request ) {
    9751020        $method = $request->get_method();
    9761021        $path   = $request->get_route();
     
    10591104     * @return WP_REST_Response
    10601105     */
    1061     public function respond_to_request( $request, $route, $handler, $response ) {
     1106    protected function respond_to_request( $request, $route, $handler, $response ) {
    10621107        /**
    10631108         * Filters the response before executing any REST API callbacks.
     
    13981443
    13991444    /**
     1445     * Gets the maximum number of requests that can be included in a batch.
     1446     *
     1447     * @since 5.6.0
     1448     *
     1449     * @return int The maximum requests.
     1450     */
     1451    protected function get_max_batch_size() {
     1452        /**
     1453         * Filters the maximum number of requests that can be included in a batch.
     1454         *
     1455         * @param int $max_size The maximum size.
     1456         */
     1457        return apply_filters( 'rest_get_max_batch_size', 25 );
     1458    }
     1459
     1460    /**
     1461     * Serves the batch/v1 request.
     1462     *
     1463     * @since 5.6.0
     1464     *
     1465     * @param WP_REST_Request $batch_request The batch request object.
     1466     * @return WP_REST_Response The generated response object.
     1467     */
     1468    public function serve_batch_request_v1( WP_REST_Request $batch_request ) {
     1469        $requests = array();
     1470
     1471        foreach ( $batch_request['requests'] as $args ) {
     1472            $parsed_url = wp_parse_url( $args['path'] );
     1473
     1474            if ( false === $parsed_url ) {
     1475                $requests[] = new WP_Error( 'parse_path_failed', __( 'Could not parse the path.' ), array( 'status' => 400 ) );
     1476
     1477                continue;
     1478            }
     1479
     1480            $single_request = new WP_REST_Request( isset( $args['method'] ) ? $args['method'] : 'POST', $parsed_url['path'] );
     1481
     1482            if ( ! empty( $parsed_url['query'] ) ) {
     1483                $query_args = null; // Satisfy linter.
     1484                wp_parse_str( $parsed_url['query'], $query_args );
     1485                $single_request->set_query_params( $query_args );
     1486            }
     1487
     1488            if ( ! empty( $args['body'] ) ) {
     1489                $single_request->set_body_params( $args['body'] );
     1490            }
     1491
     1492            if ( ! empty( $args['headers'] ) ) {
     1493                $single_request->set_headers( $args['headers'] );
     1494            }
     1495
     1496            $requests[] = $single_request;
     1497        }
     1498
     1499        $matches    = array();
     1500        $validation = array();
     1501        $has_error  = false;
     1502
     1503        foreach ( $requests as $single_request ) {
     1504            $match     = $this->match_request_to_handler( $single_request );
     1505            $matches[] = $match;
     1506            $error     = null;
     1507
     1508            if ( is_wp_error( $match ) ) {
     1509                $error = $match;
     1510            }
     1511
     1512            if ( ! $error ) {
     1513                list( $route, $handler ) = $match;
     1514
     1515                if ( isset( $handler['allow_batch'] ) ) {
     1516                    $allow_batch = $handler['allow_batch'];
     1517                } else {
     1518                    $route_options = $this->get_route_options( $route );
     1519                    $allow_batch   = isset( $route_options['allow_batch'] ) ? $route_options['allow_batch'] : false;
     1520                }
     1521
     1522                if ( ! is_array( $allow_batch ) || empty( $allow_batch['v1'] ) ) {
     1523                    $error = new WP_Error(
     1524                        'rest_batch_not_allowed',
     1525                        __( 'The requested route does not support batch requests.' ),
     1526                        array( 'status' => 400 )
     1527                    );
     1528                }
     1529            }
     1530
     1531            if ( ! $error ) {
     1532                $check_required = $single_request->has_valid_params();
     1533                if ( is_wp_error( $check_required ) ) {
     1534                    $error = $check_required;
     1535                }
     1536            }
     1537
     1538            if ( ! $error ) {
     1539                $check_sanitized = $single_request->sanitize_params();
     1540                if ( is_wp_error( $check_sanitized ) ) {
     1541                    $error = $check_sanitized;
     1542                }
     1543            }
     1544
     1545            if ( $error ) {
     1546                $has_error    = true;
     1547                $validation[] = $error;
     1548            } else {
     1549                $validation[] = true;
     1550            }
     1551        }
     1552
     1553        $responses = array();
     1554
     1555        if ( $has_error && 'require-all-validate' === $batch_request['validation'] ) {
     1556            foreach ( $validation as $valid ) {
     1557                if ( is_wp_error( $valid ) ) {
     1558                    $responses[] = $this->envelope_response( $this->error_to_response( $valid ), false )->get_data();
     1559                } else {
     1560                    $responses[] = null;
     1561                }
     1562            }
     1563
     1564            return new WP_REST_Response(
     1565                array(
     1566                    'failed'    => 'validation',
     1567                    'responses' => $responses,
     1568                ),
     1569                WP_Http::MULTI_STATUS
     1570            );
     1571        }
     1572
     1573        foreach ( $requests as $i => $single_request ) {
     1574            $clean_request = clone $single_request;
     1575            $clean_request->set_url_params( array() );
     1576            $clean_request->set_attributes( array() );
     1577            $clean_request->set_default_params( array() );
     1578
     1579            /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
     1580            $result = apply_filters( 'rest_pre_dispatch', null, $this, $clean_request );
     1581
     1582            if ( empty( $result ) ) {
     1583                $match = $matches[ $i ];
     1584                $error = null;
     1585
     1586                if ( is_wp_error( $validation[ $i ] ) ) {
     1587                    $error = $validation[ $i ];
     1588                }
     1589
     1590                if ( is_wp_error( $match ) ) {
     1591                    $result = $this->error_to_response( $match );
     1592                } else {
     1593                    list( $route, $handler ) = $match;
     1594
     1595                    if ( ! $error && ! is_callable( $handler['callback'] ) ) {
     1596                        $error = new WP_Error(
     1597                            'rest_invalid_handler',
     1598                            __( 'The handler for the route is invalid' ),
     1599                            array( 'status' => 500 )
     1600                        );
     1601                    }
     1602
     1603                    $result = $this->respond_to_request( $single_request, $route, $handler, $error );
     1604                }
     1605            }
     1606
     1607            /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
     1608            $result = apply_filters( 'rest_post_dispatch', rest_ensure_response( $result ), $this, $single_request );
     1609
     1610            $responses[] = $this->envelope_response( $result, false )->get_data();
     1611        }
     1612
     1613        return new WP_REST_Response( array( 'responses' => $responses ), WP_Http::MULTI_STATUS );
     1614    }
     1615
     1616    /**
    14001617     * Sends an HTTP status code.
    14011618     *
  • trunk/tests/phpunit/tests/rest-api/rest-server.php

    r48947 r49252  
    16181618    }
    16191619
     1620    /**
     1621     * @ticket       50244
     1622     * @dataProvider data_batch_v1_optin
     1623     */
     1624    public function test_batch_v1_optin( $allow_batch, $allowed ) {
     1625        $args = array(
     1626            'methods'             => 'POST',
     1627            'callback'            => static function () {
     1628                return new WP_REST_Response( 'data' );
     1629            },
     1630            'permission_callback' => '__return_true',
     1631        );
     1632
     1633        if ( null !== $allow_batch ) {
     1634            $args['allow_batch'] = $allow_batch;
     1635        }
     1636
     1637        register_rest_route(
     1638            'test-ns/v1',
     1639            '/test',
     1640            $args
     1641        );
     1642
     1643        $request = new WP_REST_Request( 'POST', '/batch/v1' );
     1644        $request->set_body_params(
     1645            array(
     1646                'requests' => array(
     1647                    array(
     1648                        'path' => '/test-ns/v1/test',
     1649                    ),
     1650                ),
     1651            )
     1652        );
     1653
     1654        $response = rest_do_request( $request );
     1655
     1656        $this->assertEquals( 207, $response->get_status() );
     1657
     1658        if ( $allowed ) {
     1659            $this->assertEquals( 'data', $response->get_data()['responses'][0]['body'] );
     1660        } else {
     1661            $this->assertEquals( 'rest_batch_not_allowed', $response->get_data()['responses'][0]['body']['code'] );
     1662        }
     1663    }
     1664
     1665    public function data_batch_v1_optin() {
     1666        return array(
     1667            'missing'             => array( null, false ),
     1668            'invalid type'        => array( true, false ),
     1669            'invalid type string' => array( 'v1', false ),
     1670            'wrong version'       => array( array( 'version1' => true ), false ),
     1671            'false version'       => array( array( 'v1' => false ), false ),
     1672            'valid'               => array( array( 'v1' => true ), true ),
     1673        );
     1674    }
     1675
     1676    /**
     1677     * @ticket 50244
     1678     */
     1679    public function test_batch_v1_pre_validation() {
     1680        register_rest_route(
     1681            'test-ns/v1',
     1682            '/test',
     1683            array(
     1684                'methods'             => 'POST',
     1685                'callback'            => static function ( $request ) {
     1686                    $project = $request['project'];
     1687                    update_option( 'test_project', $project );
     1688
     1689                    return new WP_REST_Response( $project );
     1690                },
     1691                'permission_callback' => '__return_true',
     1692                'allow_batch'         => array( 'v1' => true ),
     1693                'args'                => array(
     1694                    'project' => array(
     1695                        'type' => 'string',
     1696                        'enum' => array( 'gutenberg', 'WordPress' ),
     1697                    ),
     1698                ),
     1699            )
     1700        );
     1701
     1702        $request = new WP_REST_Request( 'POST', '/batch/v1' );
     1703        $request->set_body_params(
     1704            array(
     1705                'validation' => 'require-all-validate',
     1706                'requests'   => array(
     1707                    array(
     1708                        'path' => '/test-ns/v1/test',
     1709                        'body' => array(
     1710                            'project' => 'gutenberg',
     1711                        ),
     1712                    ),
     1713                    array(
     1714                        'path' => '/test-ns/v1/test',
     1715                        'body' => array(
     1716                            'project' => 'buddypress',
     1717                        ),
     1718                    ),
     1719                ),
     1720            )
     1721        );
     1722
     1723        $response = rest_get_server()->dispatch( $request );
     1724        $data     = $response->get_data();
     1725
     1726        $this->assertEquals( 207, $response->get_status() );
     1727        $this->assertArrayHasKey( 'failed', $data );
     1728        $this->assertEquals( 'validation', $data['failed'] );
     1729        $this->assertCount( 2, $data['responses'] );
     1730        $this->assertNull( $data['responses'][0] );
     1731        $this->assertEquals( 400, $data['responses'][1]['status'] );
     1732        $this->assertFalse( get_option( 'test_project' ) );
     1733    }
     1734
     1735    /**
     1736     * @ticket 50244
     1737     */
     1738    public function test_batch_v1_pre_validation_all_successful() {
     1739        register_rest_route(
     1740            'test-ns/v1',
     1741            '/test',
     1742            array(
     1743                'methods'             => 'POST',
     1744                'callback'            => static function ( $request ) {
     1745                    return new WP_REST_Response( $request['project'] );
     1746                },
     1747                'permission_callback' => '__return_true',
     1748                'allow_batch'         => array( 'v1' => true ),
     1749                'args'                => array(
     1750                    'project' => array(
     1751                        'type' => 'string',
     1752                        'enum' => array( 'gutenberg', 'WordPress' ),
     1753                    ),
     1754                ),
     1755            )
     1756        );
     1757
     1758        $request = new WP_REST_Request( 'POST', '/batch/v1' );
     1759        $request->set_body_params(
     1760            array(
     1761                'validation' => 'require-all-validate',
     1762                'requests'   => array(
     1763                    array(
     1764                        'path' => '/test-ns/v1/test',
     1765                        'body' => array(
     1766                            'project' => 'gutenberg',
     1767                        ),
     1768                    ),
     1769                    array(
     1770                        'path' => '/test-ns/v1/test',
     1771                        'body' => array(
     1772                            'project' => 'WordPress',
     1773                        ),
     1774                    ),
     1775                ),
     1776            )
     1777        );
     1778
     1779        $response = rest_get_server()->dispatch( $request );
     1780        $data     = $response->get_data();
     1781
     1782        $this->assertEquals( 207, $response->get_status() );
     1783        $this->assertArrayNotHasKey( 'failed', $data );
     1784        $this->assertCount( 2, $data['responses'] );
     1785        $this->assertEquals( 'gutenberg', $data['responses'][0]['body'] );
     1786        $this->assertEquals( 'WordPress', $data['responses'][1]['body'] );
     1787    }
     1788
     1789    /**
     1790     * @ticket 50244
     1791     */
     1792    public function test_batch_v1() {
     1793        register_rest_route(
     1794            'test-ns/v1',
     1795            '/test/(?P<id>[\d+])',
     1796            array(
     1797                'methods'             => array( 'POST', 'DELETE' ),
     1798                'callback'            => function ( WP_REST_Request $request ) {
     1799                    $this->assertEquals( 'DELETE', $request->get_method() );
     1800                    $this->assertEquals( '/test-ns/v1/test/5', $request->get_route() );
     1801                    $this->assertEquals( array( 'id' => '5' ), $request->get_url_params() );
     1802                    $this->assertEquals( array( 'query' => 'param' ), $request->get_query_params() );
     1803                    $this->assertEquals( array( 'project' => 'gutenberg' ), $request->get_body_params() );
     1804                    $this->assertEquals( array( 'my_header' => array( 'my-value' ) ), $request->get_headers() );
     1805
     1806                    return new WP_REST_Response( 'test' );
     1807                },
     1808                'permission_callback' => '__return_true',
     1809                'allow_batch'         => array( 'v1' => true ),
     1810            )
     1811        );
     1812
     1813        $request = new WP_REST_Request( 'POST', '/batch/v1' );
     1814        $request->set_body_params(
     1815            array(
     1816                'requests' => array(
     1817                    array(
     1818                        'method'  => 'DELETE',
     1819                        'path'    => '/test-ns/v1/test/5?query=param',
     1820                        'headers' => array(
     1821                            'My-Header' => 'my-value',
     1822                        ),
     1823                        'body'    => array(
     1824                            'project' => 'gutenberg',
     1825                        ),
     1826                    ),
     1827                ),
     1828            )
     1829        );
     1830
     1831        $response = rest_get_server()->dispatch( $request );
     1832
     1833        $this->assertEquals( 207, $response->get_status() );
     1834        $this->assertEquals( 'test', $response->get_data()['responses'][0]['body'] );
     1835    }
     1836
     1837    /**
     1838     * @ticket 50244
     1839     */
     1840    public function test_batch_v1_partial_error() {
     1841        register_rest_route(
     1842            'test-ns/v1',
     1843            '/test',
     1844            array(
     1845                'methods'             => 'POST',
     1846                'callback'            => static function ( $request ) {
     1847                    $project = $request['project'];
     1848                    update_option( 'test_project', $project );
     1849
     1850                    return new WP_REST_Response( $project );
     1851                },
     1852                'permission_callback' => '__return_true',
     1853                'allow_batch'         => array( 'v1' => true ),
     1854                'args'                => array(
     1855                    'project' => array(
     1856                        'type' => 'string',
     1857                        'enum' => array( 'gutenberg', 'WordPress' ),
     1858                    ),
     1859                ),
     1860            )
     1861        );
     1862
     1863        $request = new WP_REST_Request( 'POST', '/batch/v1' );
     1864        $request->set_body_params(
     1865            array(
     1866                'requests' => array(
     1867                    array(
     1868                        'path' => '/test-ns/v1/test',
     1869                        'body' => array(
     1870                            'project' => 'gutenberg',
     1871                        ),
     1872                    ),
     1873                    array(
     1874                        'path' => '/test-ns/v1/test',
     1875                        'body' => array(
     1876                            'project' => 'buddypress',
     1877                        ),
     1878                    ),
     1879                ),
     1880            )
     1881        );
     1882
     1883        $response = rest_get_server()->dispatch( $request );
     1884        $data     = $response->get_data();
     1885
     1886        $this->assertEquals( 207, $response->get_status() );
     1887        $this->assertArrayNotHasKey( 'failed', $data );
     1888        $this->assertCount( 2, $data['responses'] );
     1889        $this->assertEquals( 'gutenberg', $data['responses'][0]['body'] );
     1890        $this->assertEquals( 400, $data['responses'][1]['status'] );
     1891        $this->assertEquals( 'gutenberg', get_option( 'test_project' ) );
     1892    }
     1893
     1894
     1895    /**
     1896     * @ticket 50244
     1897     */
     1898    public function test_batch_v1_max_requests() {
     1899        add_filter(
     1900            'rest_get_max_batch_size',
     1901            static function() {
     1902                return 5;
     1903            }
     1904        );
     1905
     1906        $GLOBALS['wp_rest_server'] = null;
     1907        add_filter( 'wp_rest_server_class', array( $this, 'filter_wp_rest_server_class' ) );
     1908        $GLOBALS['wp_rest_server'] = rest_get_server();
     1909
     1910        register_rest_route(
     1911            'test-ns/v1',
     1912            '/test/(?P<id>[\d+])',
     1913            array(
     1914                'methods'             => array( 'POST', 'DELETE' ),
     1915                'callback'            => function ( WP_REST_Request $request ) {
     1916                    return new WP_REST_Response( 'test' );
     1917                },
     1918                'permission_callback' => '__return_true',
     1919                'allow_batch'         => array( 'v1' => true ),
     1920            )
     1921        );
     1922
     1923        $request = new WP_REST_Request( 'POST', '/batch/v1' );
     1924        $request->set_body_params(
     1925            array(
     1926                'requests' => array_fill( 0, 6, array( 'path' => '/test-ns/v1/test/5' ) ),
     1927            )
     1928        );
     1929
     1930        $response = rest_get_server()->dispatch( $request );
     1931        $this->assertEquals( 400, $response->get_status() );
     1932    }
     1933
    16201934    public function _validate_as_integer_123( $value, $request, $key ) {
    16211935        if ( ! is_int( $value ) ) {
  • trunk/tests/qunit/fixtures/wp-api-generated.js

    r49154 r49252  
    4040            "_links": {
    4141                "self": "http://example.org/index.php?rest_route=/"
     42            }
     43        },
     44        "/batch/v1": {
     45            "namespace": "",
     46            "methods": [
     47                "POST"
     48            ],
     49            "endpoints": [
     50                {
     51                    "methods": [
     52                        "POST"
     53                    ],
     54                    "args": {
     55                        "validation": {
     56                            "required": false,
     57                            "default": "normal",
     58                            "enum": [
     59                                "require-all-validate",
     60                                "normal"
     61                            ],
     62                            "type": "string"
     63                        },
     64                        "requests": {
     65                            "required": true,
     66                            "type": "array",
     67                            "items": {
     68                                "type": "object",
     69                                "properties": {
     70                                    "method": {
     71                                        "type": "string",
     72                                        "enum": [
     73                                            "POST",
     74                                            "PUT",
     75                                            "PATCH",
     76                                            "DELETE"
     77                                        ],
     78                                        "default": "POST"
     79                                    },
     80                                    "path": {
     81                                        "type": "string",
     82                                        "required": true
     83                                    },
     84                                    "body": {
     85                                        "type": "object",
     86                                        "properties": [],
     87                                        "additionalProperties": true
     88                                    },
     89                                    "headers": {
     90                                        "type": "object",
     91                                        "properties": [],
     92                                        "additionalProperties": {
     93                                            "type": [
     94                                                "string",
     95                                                "array"
     96                                            ],
     97                                            "items": {
     98                                                "type": "string"
     99                                            }
     100                                        }
     101                                    }
     102                                }
     103                            }
     104                        }
     105                    }
     106                }
     107            ],
     108            "_links": {
     109                "self": [
     110                    {
     111                        "href": "http://example.org/index.php?rest_route=/batch/v1"
     112                    }
     113                ]
    42114            }
    43115        },
Note: See TracChangeset for help on using the changeset viewer.