diff --git a/components/index.js b/components/index.js
index 38731f6540128..0de7d42929df4 100644
--- a/components/index.js
+++ b/components/index.js
@@ -43,6 +43,7 @@ export { default as ResponsiveWrapper } from './responsive-wrapper';
export { default as SandBox } from './sandbox';
export { default as SelectControl } from './select-control';
export { default as Spinner } from './spinner';
+export { default as ServerSideRender } from './server-side-render';
export { default as TabPanel } from './tab-panel';
export { default as TextControl } from './text-control';
export { default as TextareaControl } from './textarea-control';
diff --git a/components/server-side-render/README.md b/components/server-side-render/README.md
new file mode 100644
index 0000000000000..02c507ca3819d
--- /dev/null
+++ b/components/server-side-render/README.md
@@ -0,0 +1,30 @@
+ServerSideRender
+=======
+
+ServerSideRender is a component used for server-side rendering a preview of dynamic blocks to display in the editor. Server-side rendering in a block's `edit` function should be limited to blocks that are heavily dependent on existing PHP rendering logic that is heavily intertwined with data, particularly when there are no endpoints available.
+
+ServerSideRender may also be used when a legacy block is provided as a backwards compatibility measure, rather than needing to re-write the deprecated code that the block may depend on.
+
+ServerSideRender should be regarded as a fallback or legacy mechanism, it is not appropriate for developing new features against.
+
+New blocks should be built in conjunction with any necessary REST API endpoints, so that JavaScript can be used for rendering client-side in the `edit` function. This gives the best user experience, instead of relying on using the PHP `render_callback`. The logic necessary for rendering should be included in the endpoint, so that both the client-side JavaScript and server-side PHP logic should require a mininal amount of differences.
+
+## Usage
+
+Render core/archives preview.
+
+```jsx
+
+```
+
+## Output
+
+Output uses the block's `render_callback` function, set when defining the block.
+
+## API Endpoint
+
+The API endpoint for getting the output for ServerSideRender is `/gutenberg/v1/block-renderer/:block`. It accepts any params, which are used as `attributes` for the block's `render_callback` method.
+
diff --git a/components/server-side-render/index.js b/components/server-side-render/index.js
new file mode 100644
index 0000000000000..d0128f53c1b41
--- /dev/null
+++ b/components/server-side-render/index.js
@@ -0,0 +1,72 @@
+/**
+ * External dependencies.
+ */
+import { isEqual, isObject, map } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ Component,
+ RawHTML,
+} from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+export class ServerSideRender extends Component {
+ constructor( props ) {
+ super( props );
+ this.state = {
+ response: null,
+ };
+ }
+
+ componentDidMount() {
+ this.fetch( this.props );
+ }
+
+ componentWillReceiveProps( nextProps ) {
+ if ( ! isEqual( nextProps, this.props ) ) {
+ this.fetch( nextProps );
+ }
+ }
+
+ fetch( props ) {
+ this.setState( { response: null } );
+ const { block, attributes } = props;
+
+ const path = '/gutenberg/v1/block-renderer/' + block + '?context=edit&' + this.getQueryUrlFromObject( { attributes } );
+
+ return wp.apiRequest( { path: path } ).then( ( response ) => {
+ if ( response && response.rendered ) {
+ this.setState( { response: response.rendered } );
+ }
+ } );
+ }
+
+ getQueryUrlFromObject( obj, prefix ) {
+ return map( obj, ( paramValue, paramName ) => {
+ const key = prefix ? prefix + '[' + paramName + ']' : paramName,
+ value = obj[ paramName ];
+ return isObject( paramValue ) ? this.getQueryUrlFromObject( value, key ) :
+ encodeURIComponent( key ) + '=' + encodeURIComponent( value );
+ } ).join( '&' );
+ }
+
+ render() {
+ const response = this.state.response;
+ if ( ! response || ! response.length ) {
+ return (
+
+
+
{ __( 'Loading...' ) }
+
+ );
+ }
+
+ return (
+ { response }
+ );
+ }
+}
+
+export default ServerSideRender;
diff --git a/lib/class-wp-block-type-registry.php b/lib/class-wp-block-type-registry.php
index 2e91b53933a72..f95dec6ce4b9d 100644
--- a/lib/class-wp-block-type-registry.php
+++ b/lib/class-wp-block-type-registry.php
@@ -17,7 +17,7 @@ final class WP_Block_Type_Registry {
*
* @since 0.6.0
* @access private
- * @var array
+ * @var WP_Block_Type[]
*/
private $registered_block_types = array();
@@ -142,7 +142,7 @@ public function get_registered( $name ) {
* @since 0.6.0
* @access public
*
- * @return array Associative array of `$block_type_name => $block_type` pairs.
+ * @return WP_Block_Type[] Associative array of `$block_type_name => $block_type` pairs.
*/
public function get_all_registered() {
return $this->registered_block_types;
diff --git a/lib/class-wp-block-type.php b/lib/class-wp-block-type.php
index 39f08e54fddc2..84aa40aed4e47 100644
--- a/lib/class-wp-block-type.php
+++ b/lib/class-wp-block-type.php
@@ -137,7 +137,7 @@ public function prepare_attributes_for_render( $attributes ) {
if ( isset( $attributes[ $attribute_name ] ) ) {
$is_valid = rest_validate_value_from_schema( $attributes[ $attribute_name ], $schema );
if ( ! is_wp_error( $is_valid ) ) {
- $value = $attributes[ $attribute_name ];
+ $value = rest_sanitize_value_from_schema( $attributes[ $attribute_name ], $schema );
}
}
diff --git a/lib/class-wp-rest-block-renderer-controller.php b/lib/class-wp-rest-block-renderer-controller.php
new file mode 100644
index 0000000000000..43f381e80170a
--- /dev/null
+++ b/lib/class-wp-rest-block-renderer-controller.php
@@ -0,0 +1,161 @@
+namespace = 'gutenberg/v1';
+ $this->rest_base = 'block-renderer';
+ }
+
+ /**
+ * Registers the necessary REST API routes, one for each dynamic block.
+ *
+ * @access public
+ */
+ public function register_routes() {
+ $block_types = WP_Block_Type_Registry::get_instance()->get_all_registered();
+ foreach ( $block_types as $block_type ) {
+ if ( ! $block_type->is_dynamic() ) {
+ continue;
+ }
+
+ register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P' . $block_type->name . ')', array(
+ 'args' => array(
+ 'name' => array(
+ 'description' => __( 'Unique registered name for the block.', 'gutenberg' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ 'attributes' => array(
+ /* translators: %s is the name of the block */
+ 'description' => sprintf( __( 'Attributes for %s block', 'gutenberg' ), $block_type->name ),
+ 'type' => 'object',
+ 'additionalProperties' => false,
+ 'properties' => $block_type->attributes,
+ ),
+ 'post_id' => array(
+ 'description' => __( 'ID of the post context.', 'gutenberg' ),
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ ) );
+ }
+ }
+
+ /**
+ * Checks if a given request has access to read blocks.
+ *
+ * @since 2.8.0
+ * @access public
+ *
+ * @param WP_REST_Request $request Request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ global $post;
+
+ $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0;
+
+ if ( 0 < $post_id ) {
+ $post = get_post( $post_id );
+ if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) {
+ return new WP_Error( 'gutenberg_block_cannot_read', __( 'Sorry, you are not allowed to read Gutenberg blocks of this post', 'gutenberg' ), array(
+ 'status' => rest_authorization_required_code(),
+ ) );
+ }
+ } else {
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ return new WP_Error( 'gutenberg_block_cannot_read', __( 'Sorry, you are not allowed to read Gutenberg blocks as this user.', 'gutenberg' ), array(
+ 'status' => rest_authorization_required_code(),
+ ) );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns block output from block's registered render_callback.
+ *
+ * @since 2.8.0
+ * @access public
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ global $post;
+
+ $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0;
+
+ if ( 0 < $post_id ) {
+ $post = get_post( $post_id );
+
+ // Set up postdata since this will be needed if post_id was set.
+ setup_postdata( $post );
+ }
+ $registry = WP_Block_Type_Registry::get_instance();
+ $block = $registry->get_registered( $request['name'] );
+
+ if ( null === $block ) {
+ return new WP_Error( 'gutenberg_block_invalid', __( 'Invalid block.', 'gutenberg' ), array(
+ 'status' => 404,
+ ) );
+ }
+
+ $data = array(
+ 'rendered' => $block->render( $request->get_param( 'attributes' ) ),
+ );
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Retrieves block's output schema, conforming to JSON Schema.
+ *
+ * @since 2.8.0
+ * @access public
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ return array(
+ '$schema' => 'http://json-schema.org/schema#',
+ 'title' => 'rendered-block',
+ 'type' => 'object',
+ 'properties' => array(
+ 'rendered' => array(
+ 'description' => __( 'The rendered block.', 'gutenberg' ),
+ 'type' => 'string',
+ 'required' => true,
+ 'context' => array( 'edit' ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/load.php b/lib/load.php
index 1cafeba9472ab..e196610c848ba 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -13,6 +13,7 @@
require dirname( __FILE__ ) . '/class-wp-block-type.php';
require dirname( __FILE__ ) . '/class-wp-block-type-registry.php';
require dirname( __FILE__ ) . '/class-wp-rest-blocks-controller.php';
+require dirname( __FILE__ ) . '/class-wp-rest-block-renderer-controller.php';
require dirname( __FILE__ ) . '/blocks.php';
require dirname( __FILE__ ) . '/client-assets.php';
require dirname( __FILE__ ) . '/compat.php';
diff --git a/lib/register.php b/lib/register.php
index 4866441eb1f1a..0103fd9ff9e0c 100644
--- a/lib/register.php
+++ b/lib/register.php
@@ -431,6 +431,17 @@ function gutenberg_register_post_types() {
}
add_action( 'init', 'gutenberg_register_post_types' );
+/**
+ * Registers the REST API routes needed by the Gutenberg editor.
+ *
+ * @since 2.8.0
+ */
+function gutenberg_register_rest_routes() {
+ $controller = new WP_REST_Block_Renderer_Controller();
+ $controller->register_routes();
+}
+add_action( 'rest_api_init', 'gutenberg_register_rest_routes' );
+
/**
* Gets revisions details for the selected post.
*
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 27b7a13c08424..581632857259c 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -20,6 +20,9 @@
./phpunit
gutenberg.php
+
+ lib/class-wp-rest-block-renderer-controller.php
+
gutenberg.php
diff --git a/phpunit/class-rest-block-renderer-controller-test.php b/phpunit/class-rest-block-renderer-controller-test.php
new file mode 100644
index 0000000000000..338736c394834
--- /dev/null
+++ b/phpunit/class-rest-block-renderer-controller-test.php
@@ -0,0 +1,412 @@
+user->create(
+ array(
+ 'role' => 'editor',
+ )
+ );
+
+ self::$author_id = $factory->user->create(
+ array(
+ 'role' => 'author',
+ )
+ );
+
+ self::$post_id = $factory->post->create( array(
+ 'post_title' => 'Test Post',
+ ) );
+ }
+
+ /**
+ * Delete test data after our tests run.
+ */
+ public static function wpTearDownAfterClass() {
+ self::delete_user( self::$user_id );
+ }
+
+ /**
+ * Set up.
+ *
+ * @see gutenberg_register_rest_routes()
+ */
+ public function setUp() {
+ $this->register_test_block();
+ $this->register_post_context_test_block();
+ parent::setUp();
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown() {
+ WP_Block_Type_Registry::get_instance()->unregister( self::$block_name );
+ WP_Block_Type_Registry::get_instance()->unregister( self::$context_block_name );
+ parent::tearDown();
+ }
+
+ /**
+ * Register test block.
+ */
+ public function register_test_block() {
+ register_block_type( self::$block_name, array(
+ 'attributes' => array(
+ 'some_string' => array(
+ 'type' => 'string',
+ 'default' => 'some_default',
+ ),
+ 'some_int' => array(
+ 'type' => 'integer',
+ ),
+ 'some_array' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ 'render_callback' => array( $this, 'render_test_block' ),
+ ) );
+ }
+
+ /**
+ * Register test block with post_id as attribute for post context test.
+ */
+ public function register_post_context_test_block() {
+ register_block_type( self::$context_block_name, array(
+ 'attributes' => array(),
+ 'render_callback' => array( $this, 'render_post_context_test_block' ),
+ ) );
+ }
+
+ /**
+ * Test render callback.
+ *
+ * @param array $attributes Props.
+ * @return string Rendered attributes, which is here just JSON.
+ */
+ public function render_test_block( $attributes ) {
+ return wp_json_encode( $attributes );
+ }
+
+ /**
+ * Test render callback for testing post context.
+ *
+ * @return string
+ */
+ public function render_post_context_test_block() {
+ return get_the_title();
+ }
+
+ /**
+ * Check that the route was registered properly.
+ *
+ * @covers WP_REST_Block_Renderer_Controller::register_routes()
+ */
+ public function test_register_routes() {
+ $dynamic_block_names = get_dynamic_block_names();
+ $this->assertContains( self::$block_name, $dynamic_block_names );
+
+ $routes = $this->server->get_routes();
+ foreach ( $dynamic_block_names as $dynamic_block_name ) {
+ $this->assertArrayHasKey( "/gutenberg/v1/block-renderer/(?P$dynamic_block_name)", $routes );
+ }
+ }
+
+ /**
+ * Test getting item without permissions.
+ *
+ * @covers WP_REST_Block_Renderer_Controller::get_item()
+ */
+ public function test_get_item_without_permissions() {
+ wp_set_current_user( 0 );
+
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name );
+ $request->set_param( 'context', 'edit' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertErrorResponse( 'gutenberg_block_cannot_read', $response, rest_authorization_required_code() );
+ }
+
+ /**
+ * Test getting item without 'edit' context.
+ */
+ public function test_get_item_with_invalid_context() {
+ wp_set_current_user( self::$user_id );
+
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
+ }
+
+ /**
+ * Test getting item with invalid block name.
+ *
+ * @covers WP_REST_Block_Renderer_Controller::get_item()
+ */
+ public function test_get_item_invalid_block_name() {
+ wp_set_current_user( self::$user_id );
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/core/123' );
+
+ $request->set_param( 'context', 'edit' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertErrorResponse( 'rest_no_route', $response, 404 );
+ }
+
+ /**
+ * Check getting item with an invalid param provided.
+ *
+ * @covers WP_REST_Block_Renderer_Controller::get_item()
+ */
+ public function test_get_item_invalid_attribute() {
+ wp_set_current_user( self::$user_id );
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name );
+ $request->set_param( 'context', 'edit' );
+ $request->set_param( 'attributes', array(
+ 'some_string' => array( 'no!' ),
+ ) );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Check getting item with an invalid param provided.
+ *
+ * @covers WP_REST_Block_Renderer_Controller::get_item()
+ */
+ public function test_get_item_unrecognized_attribute() {
+ wp_set_current_user( self::$user_id );
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name );
+ $request->set_param( 'context', 'edit' );
+ $request->set_param( 'attributes', array(
+ 'unrecognized' => 'yes',
+ ) );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Check getting item with default attributes provided.
+ *
+ * @covers WP_REST_Block_Renderer_Controller::get_item()
+ */
+ public function test_get_item_default_attributes() {
+ wp_set_current_user( self::$user_id );
+
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( self::$block_name );
+ $defaults = array();
+ foreach ( $block_type->attributes as $key => $attribute ) {
+ $defaults[ $key ] = isset( $attribute['default'] ) ? $attribute['default'] : null;
+ }
+
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name );
+ $request->set_param( 'context', 'edit' );
+ $request->set_param( 'attributes', array() );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( $defaults, json_decode( $data['rendered'], true ) );
+ $this->assertEquals(
+ json_decode( $block_type->render( $defaults ) ),
+ json_decode( $data['rendered'] )
+ );
+ }
+
+ /**
+ * Check getting item with attributes provided.
+ *
+ * @covers WP_REST_Block_Renderer_Controller::get_item()
+ */
+ public function test_get_item() {
+ wp_set_current_user( self::$user_id );
+
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( self::$block_name );
+ $attributes = array(
+ 'some_int' => '123',
+ 'some_string' => 'foo',
+ 'some_array' => array( 1, '2', 3 ),
+ );
+
+ $expected_attributes = $attributes;
+ $expected_attributes['some_int'] = (int) $expected_attributes['some_int'];
+ $expected_attributes['some_array'] = array_map( 'intval', $expected_attributes['some_array'] );
+
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name );
+ $request->set_param( 'context', 'edit' );
+ $request->set_param( 'attributes', $attributes );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( $expected_attributes, json_decode( $data['rendered'], true ) );
+ $this->assertEquals(
+ json_decode( $block_type->render( $attributes ), true ),
+ json_decode( $data['rendered'], true )
+ );
+ }
+
+ /**
+ * Test getting item with post context.
+ */
+ public function test_get_item_with_post_context() {
+ wp_set_current_user( self::$user_id );
+
+ $expected_title = 'Test Post';
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$context_block_name );
+ $request->set_param( 'context', 'edit' );
+
+ // Test without post ID.
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertTrue( empty( $data['rendered'] ) );
+
+ // Now test with post ID.
+ $request->set_param( 'post_id', self::$post_id );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( $expected_title, $data['rendered'] );
+ }
+
+ /**
+ * Test getting item with invalid post ID.
+ */
+ public function test_get_item_without_permissions_invalid_post() {
+ wp_set_current_user( self::$user_id );
+
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$context_block_name );
+ $request->set_param( 'context', 'edit' );
+
+ // Test with invalid post ID.
+ $request->set_param( 'post_id', PHP_INT_MAX );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertErrorResponse( 'gutenberg_block_cannot_read', $response, 403 );
+ }
+
+ /**
+ * Test getting item without permissions to edit context post.
+ */
+ public function test_get_item_without_permissions_cannot_edit_post() {
+ wp_set_current_user( self::$author_id );
+
+ $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$context_block_name );
+ $request->set_param( 'context', 'edit' );
+
+ // Test with private post ID.
+ $request->set_param( 'post_id', self::$post_id );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertErrorResponse( 'gutenberg_block_cannot_read', $response, 403 );
+ }
+
+ /**
+ * Get item schema.
+ *
+ * @covers WP_REST_Block_Renderer_Controller::get_item_schema()
+ */
+ public function test_get_item_schema() {
+ $request = new WP_REST_Request( 'OPTIONS', '/gutenberg/v1/block-renderer/' . self::$block_name );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertEqualSets( array( 'GET' ), $data['endpoints'][0]['methods'] );
+ $this->assertEqualSets(
+ array( 'name', 'context', 'attributes', 'post_id' ),
+ array_keys( $data['endpoints'][0]['args'] )
+ );
+ $this->assertEquals( 'object', $data['endpoints'][0]['args']['attributes']['type'] );
+
+ $this->assertArrayHasKey( 'schema', $data );
+ $this->assertEquals( 'rendered-block', $data['schema']['title'] );
+ $this->assertEquals( 'object', $data['schema']['type'] );
+ $this->arrayHasKey( 'rendered', $data['schema']['properties'] );
+ $this->arrayHasKey( 'string', $data['schema']['properties']['rendered']['type'] );
+ $this->assertEquals( array( 'edit' ), $data['schema']['properties']['rendered']['context'] );
+ }
+
+ public function test_update_item() {
+ $this->markTestSkipped( 'Controller doesn\'t implement update_item().' );
+ }
+
+ public function test_create_item() {
+ $this->markTestSkipped( 'Controller doesn\'t implement create_item().' );
+ }
+
+ public function test_delete_item() {
+ $this->markTestSkipped( 'Controller doesn\'t implement delete_item().' );
+ }
+
+ public function test_get_items() {
+ $this->markTestSkipped( 'Controller doesn\'t implement get_items().' );
+ }
+
+ public function test_context_param() {
+ $this->markTestSkipped( 'Controller doesn\'t implement context_param().' );
+ }
+
+ public function test_prepare_item() {
+ $this->markTestSkipped( 'Controller doesn\'t implement prepare_item().' );
+ }
+}