Issue #1850734 by klausi: Make serialization formats configurable.

8.0.x
Dries 2013-02-27 16:58:32 -05:00
parent 00dfa02242
commit 73f3973425
11 changed files with 152 additions and 85 deletions

View File

@ -54,15 +54,30 @@ class RouteSubscriber implements EventSubscriberInterface {
public function dynamicRoutes(RouteBuildEvent $event) { public function dynamicRoutes(RouteBuildEvent $event) {
$collection = $event->getRouteCollection(); $collection = $event->getRouteCollection();
$enabled_resources = $this->config->get('rest.settings')->load()->get('resources');
$resources = $this->config->get('rest.settings')->load()->get('resources'); // Iterate over all enabled resource plugins.
if ($resources && $enabled = array_intersect_key($this->manager->getDefinitions(), $resources)) { foreach ($enabled_resources as $id => $enabled_methods) {
foreach ($enabled as $key => $resource) { $plugin = $this->manager->getInstance(array('id' => $id));
$plugin = $this->manager->getInstance(array('id' => $key));
foreach ($plugin->routes() as $name => $route) { foreach ($plugin->routes() as $name => $route) {
$method = $route->getRequirement('_method');
// Only expose routes where the method is enabled in the configuration.
if ($method && isset($enabled_methods[$method])) {
$route->setRequirement('_access_rest_csrf', 'TRUE'); $route->setRequirement('_access_rest_csrf', 'TRUE');
$collection->add("rest.$name", $route);
// If the array of configured format restrictions is empty for a
// method always add the route.
if (empty($enabled_methods[$method])) {
$collection->add("rest.$name", $route);
continue;
}
// If there is no format requirement or if it matches the
// configuration also add the route.
$format_requirement = $route->getRequirement('_format');
if (!$format_requirement || isset($enabled_methods[$method][$format_requirement])) {
$collection->add("rest.$name", $route);
}
} }
} }
} }

View File

@ -26,15 +26,11 @@ abstract class ResourceBase extends PluginBase implements ResourceInterface {
public function permissions() { public function permissions() {
$permissions = array(); $permissions = array();
$definition = $this->getDefinition(); $definition = $this->getDefinition();
foreach ($this->requestMethods() as $method) { foreach ($this->availableMethods() as $method) {
$lowered_method = strtolower($method); $lowered_method = strtolower($method);
// Only expose permissions where the HTTP request method exists on the $permissions["restful $lowered_method $this->plugin_id"] = array(
// plugin. 'title' => t('Access @method on %label resource', array('@method' => $method, '%label' => $definition['label'])),
if (method_exists($this, $lowered_method)) { );
$permissions["restful $lowered_method $this->plugin_id"] = array(
'title' => t('Access @method on %label resource', array('@method' => $method, '%label' => $definition['label'])),
);
}
} }
return $permissions; return $permissions;
} }
@ -47,38 +43,44 @@ abstract class ResourceBase extends PluginBase implements ResourceInterface {
$path_prefix = strtr($this->plugin_id, ':', '/'); $path_prefix = strtr($this->plugin_id, ':', '/');
$route_name = strtr($this->plugin_id, ':', '.'); $route_name = strtr($this->plugin_id, ':', '.');
$methods = $this->requestMethods(); $methods = $this->availableMethods();
foreach ($methods as $method) { foreach ($methods as $method) {
$lower_method = strtolower($method); $lower_method = strtolower($method);
// Only expose routes where the HTTP request method exists on the plugin. $route = new Route("/$path_prefix/{id}", array(
if (method_exists($this, $lower_method)) { '_controller' => 'Drupal\rest\RequestHandler::handle',
$route = new Route("/$path_prefix/{id}", array( // Pass the resource plugin ID along as default property.
'_controller' => 'Drupal\rest\RequestHandler::handle', '_plugin' => $this->plugin_id,
// Pass the resource plugin ID along as default property. ), array(
'_plugin' => $this->plugin_id, // The HTTP method is a requirement for this route.
), array( '_method' => $method,
// The HTTP method is a requirement for this route. '_permission' => "restful $lower_method $this->plugin_id",
'_method' => $method, ));
'_permission' => "restful $lower_method $this->plugin_id",
));
switch ($method) { switch ($method) {
case 'POST': case 'POST':
// POST routes do not require an ID in the URL path. // POST routes do not require an ID in the URL path.
$route->setPattern("/$path_prefix"); $route->setPattern("/$path_prefix");
$route->addDefaults(array('id' => NULL)); $route->addDefaults(array('id' => NULL));
break; $collection->add("$route_name.$method", $route);
break;
case 'GET': case 'GET':
case 'HEAD': case 'HEAD':
// Restrict GET and HEAD requests to the media type specified in the // Restrict GET and HEAD requests to the media type specified in the
// HTTP Accept headers. // HTTP Accept headers.
// @todo Replace hard coded format here with available formats. $formats = drupal_container()->getParameter('serializer.formats');
$route->addRequirements(array('_format' => 'drupal_jsonld')); foreach ($formats as $format_name => $label) {
break; // Expose one route per available format.
} //$format_route = new Route($route->getPattern(), $route->getDefaults(), $route->getRequirements());
$format_route = clone $route;
$format_route->addRequirements(array('_format' => $format_name));
$collection->add("$route_name.$method.$format_name", $format_route);
}
break;
$collection->add("$route_name.$method", $route); default:
$collection->add("$route_name.$method", $route);
break;
} }
} }
@ -95,7 +97,7 @@ abstract class ResourceBase extends PluginBase implements ResourceInterface {
* The list of allowed HTTP request method strings. * The list of allowed HTTP request method strings.
*/ */
protected function requestMethods() { protected function requestMethods() {
return drupal_map_assoc(array( return array(
'HEAD', 'HEAD',
'GET', 'GET',
'POST', 'POST',
@ -105,6 +107,21 @@ abstract class ResourceBase extends PluginBase implements ResourceInterface {
'OPTIONS', 'OPTIONS',
'CONNECT', 'CONNECT',
'PATCH', 'PATCH',
)); );
}
/**
* Implements ResourceInterface::availableMethods().
*/
public function availableMethods() {
$methods = $this->requestMethods();
$available = array();
foreach ($methods as $method) {
// Only expose methods where the HTTP request method exists on the plugin.
if (method_exists($this, strtolower($method))) {
$available[] = $method;
}
}
return $available;
} }
} }

View File

@ -36,4 +36,12 @@ interface ResourceInterface extends PluginInspectionInterface {
*/ */
public function permissions(); public function permissions();
/**
* Returns the available HTTP request methods on this plugin.
*
* @return array
* The list of supported methods. Example: array('GET', 'POST', 'PATCH').
*/
public function availableMethods();
} }

View File

@ -50,12 +50,7 @@ class DBLogResource extends ResourceBase {
$record = db_query("SELECT * FROM {watchdog} WHERE wid = :wid", array(':wid' => $id)) $record = db_query("SELECT * FROM {watchdog} WHERE wid = :wid", array(':wid' => $id))
->fetchObject(); ->fetchObject();
if (!empty($record)) { if (!empty($record)) {
// Serialization is done here, so we indicate with NULL that there is no return new ResourceResponse((array) $record);
// subsequent serialization necessary.
$response = new ResourceResponse(NULL, 200, array('Content-Type' => 'application/vnd.drupal.ld+json'));
// @todo remove hard coded format here.
$response->setContent(drupal_json_encode($record));
return $response;
} }
} }
throw new NotFoundHttpException(t('Log entry with ID @id was not found', array('@id' => $id))); throw new NotFoundHttpException(t('Log entry with ID @id was not found', array('@id' => $id)));

View File

@ -12,6 +12,7 @@ use Symfony\Component\DependencyInjection\ContainerAware;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/** /**
@ -43,17 +44,28 @@ class RequestHandler extends ContainerAware {
$received = $request->getContent(); $received = $request->getContent();
$unserialized = NULL; $unserialized = NULL;
if (!empty($received)) { if (!empty($received)) {
$definition = $resource->getDefinition(); $format = $request->getContentType();
$class = $definition['serialization_class'];
try { // Only allow serialization formats that are explicitly configured. If no
// @todo Replace the format here with something we get from the HTTP // formats are configured allow all and hope that the serializer knows the
// Content-type header. See http://drupal.org/node/1850704 // format. If the serializer cannot handle it an exception will be thrown
$unserialized = $serializer->deserialize($received, $class, 'drupal_jsonld'); // that bubbles up to the client.
$config = $this->container->get('config.factory')->get('rest.settings')->get('resources');
$enabled_formats = $config[$plugin][$request->getMethod()];
if (empty($enabled_formats) || isset($enabled_formats[$format])) {
$definition = $resource->getDefinition();
$class = $definition['serialization_class'];
try {
$unserialized = $serializer->deserialize($received, $class, $format);
}
catch (UnexpectedValueException $e) {
$error['error'] = $e->getMessage();
$content = $serializer->serialize($error, $format);
return new Response($content, 400, array('Content-Type' => $request->getMimeType($format)));
}
} }
catch (UnexpectedValueException $e) { else {
$error['error'] = $e->getMessage(); throw new UnsupportedMediaTypeHttpException();
$content = $serializer->serialize($error, 'drupal_jsonld');
return new Response($content, 400, array('Content-Type' => 'application/vnd.drupal.ld+json'));
} }
} }
@ -63,21 +75,26 @@ class RequestHandler extends ContainerAware {
} }
catch (HttpException $e) { catch (HttpException $e) {
$error['error'] = $e->getMessage(); $error['error'] = $e->getMessage();
$content = $serializer->serialize($error, 'drupal_jsonld'); $format = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT)->getRequirement('_format') ?: 'drupal_jsonld';
$content = $serializer->serialize($error, $format);
// Add the default content type, but only if the headers from the // Add the default content type, but only if the headers from the
// exception have not specified it already. // exception have not specified it already.
$headers = $e->getHeaders() + array('Content-Type' => 'application/vnd.drupal.ld+json'); $headers = $e->getHeaders() + array('Content-Type' => $request->getMimeType($format));
return new Response($content, $e->getStatusCode(), $headers); return new Response($content, $e->getStatusCode(), $headers);
} }
// Serialize the outgoing data for the response, if available. // Serialize the outgoing data for the response, if available.
$data = $response->getResponseData(); $data = $response->getResponseData();
if ($data != NULL) { if ($data != NULL) {
// @todo Replace the format here with something we get from the HTTP // All REST routes are restricted to exactly one format, so instead of
// Accept headers. See http://drupal.org/node/1833440 // parsing it out of the Accept headers again we can simply retrieve the
$output = $serializer->serialize($data, 'drupal_jsonld'); // format requirement. If there is no format associated just pick Drupal
// JSON-LD.
$format = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT)->getRequirement('_format') ?: 'drupal_jsonld';
$output = $serializer->serialize($data, $format);
$response->setContent($output); $response->setContent($output);
$response->headers->set('Content-Type', 'application/vnd.drupal.ld+json'); $response->headers->set('Content-Type', $request->getMimeType($format));
} }
return $response; return $response;
} }

View File

@ -38,7 +38,7 @@ class CreateTest extends RESTTestBase {
// entity types here as well. // entity types here as well.
$entity_type = 'entity_test'; $entity_type = 'entity_test';
$this->enableService('entity:' . $entity_type); $this->enableService('entity:' . $entity_type, 'POST');
// Create a user account that has the required permissions to create // Create a user account that has the required permissions to create
// resources via the web API. // resources via the web API.
$account = $this->drupalCreateUser(array('restful post entity:' . $entity_type)); $account = $this->drupalCreateUser(array('restful post entity:' . $entity_type));

View File

@ -36,7 +36,7 @@ class DeleteTest extends RESTTestBase {
// Define the entity types we want to test. // Define the entity types we want to test.
$entity_types = array('entity_test', 'node', 'user'); $entity_types = array('entity_test', 'node', 'user');
foreach ($entity_types as $entity_type) { foreach ($entity_types as $entity_type) {
$this->enableService('entity:' . $entity_type); $this->enableService('entity:' . $entity_type, 'DELETE');
// Create a user account that has the required permissions to delete // Create a user account that has the required permissions to delete
// resources via the web API. // resources via the web API.
$account = $this->drupalCreateUser(array('restful delete entity:' . $entity_type)); $account = $this->drupalCreateUser(array('restful delete entity:' . $entity_type));

View File

@ -156,18 +156,24 @@ abstract class RESTTestBase extends WebTestBase {
* @param string|FALSE $resource_type * @param string|FALSE $resource_type
* The resource type that should get web API enabled or FALSE to disable all * The resource type that should get web API enabled or FALSE to disable all
* resource types. * resource types.
* @param string $method
* The HTTP method to enable, e.g. GET, POST etc.
* @param string $format
* (Optional) The serialization format, e.g. jsonld.
*/ */
protected function enableService($resource_type) { protected function enableService($resource_type, $method = 'GET', $format = NULL) {
// Enable web API for this entity type. // Enable web API for this entity type.
$config = config('rest.settings'); $config = config('rest.settings');
$settings = array();
if ($resource_type) { if ($resource_type) {
$config->set('resources', array( if ($format) {
$resource_type => $resource_type, $settings[$resource_type][$method][$format] = 'TRUE';
)); }
} else {
else { $settings[$resource_type][$method] = array();
$config->set('resources', array()); }
} }
$config->set('resources', $settings);
$config->save(); $config->save();
// Rebuild routing cache, so that the web API paths are available. // Rebuild routing cache, so that the web API paths are available.

View File

@ -38,7 +38,7 @@ class ReadTest extends RESTTestBase {
// Define the entity types we want to test. // Define the entity types we want to test.
$entity_types = array('entity_test'); $entity_types = array('entity_test');
foreach ($entity_types as $entity_type) { foreach ($entity_types as $entity_type) {
$this->enableService('entity:' . $entity_type); $this->enableService('entity:' . $entity_type, 'GET');
// Create a user account that has the required permissions to delete // Create a user account that has the required permissions to delete
// resources via the web API. // resources via the web API.
$account = $this->drupalCreateUser(array('restful get entity:' . $entity_type)); $account = $this->drupalCreateUser(array('restful get entity:' . $entity_type));
@ -57,12 +57,8 @@ class ReadTest extends RESTTestBase {
$this->assertEqual($data['uuid'][LANGUAGE_DEFAULT][0]['value'], $entity->uuid(), 'Entity UUID is correct'); $this->assertEqual($data['uuid'][LANGUAGE_DEFAULT][0]['value'], $entity->uuid(), 'Entity UUID is correct');
// Try to read the entity with an unsupported mime format. // Try to read the entity with an unsupported mime format.
// Because the matcher checks mime type first, then method, this will hit
// zero viable routes on the method. If the mime matcher wasn't working,
// we would still find an existing GET route with the wrong format. That
// means this is a valid functional test for mime-matching.
$response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, 'application/wrongformat'); $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, 'application/wrongformat');
$this->assertResponse(405); $this->assertResponse(415);
// Try to read an entity that does not exist. // Try to read an entity that does not exist.
$response = $this->httpRequest('entity/' . $entity_type . '/9999', 'GET', NULL, 'application/vnd.drupal.ld+json'); $response = $this->httpRequest('entity/' . $entity_type . '/9999', 'GET', NULL, 'application/vnd.drupal.ld+json');

View File

@ -38,7 +38,7 @@ class UpdateTest extends RESTTestBase {
// entity types here as well. // entity types here as well.
$entity_type = 'entity_test'; $entity_type = 'entity_test';
$this->enableService('entity:' . $entity_type); $this->enableService('entity:' . $entity_type, 'PATCH');
// Create a user account that has the required permissions to create // Create a user account that has the required permissions to create
// resources via the web API. // resources via the web API.
$account = $this->drupalCreateUser(array('restful patch entity:' . $entity_type)); $account = $this->drupalCreateUser(array('restful patch entity:' . $entity_type));
@ -103,7 +103,7 @@ class UpdateTest extends RESTTestBase {
// entity types here as well. // entity types here as well.
$entity_type = 'entity_test'; $entity_type = 'entity_test';
$this->enableService('entity:' . $entity_type); $this->enableService('entity:' . $entity_type, 'PUT');
// Create a user account that has the required permissions to create // Create a user account that has the required permissions to create
// resources via the web API. // resources via the web API.
$account = $this->drupalCreateUser(array('restful put entity:' . $entity_type)); $account = $this->drupalCreateUser(array('restful put entity:' . $entity_type));

View File

@ -34,7 +34,10 @@ function rest_admin_form($form, &$form_state) {
} }
asort($entity_resources); asort($entity_resources);
asort($other_resources); asort($other_resources);
$enabled_resources = config('rest.settings')->get('resources') ?: array(); $config = config('rest.settings')->get('resources') ?: array();
// Strip out the nested method configuration, we are only interested in the
// plugin IDs of the resources.
$enabled_resources = drupal_map_assoc(array_keys($config));
// Render the output using table_select(). // Render the output using table_select().
$header = array( $header = array(
@ -79,9 +82,19 @@ function rest_admin_form($form, &$form_state) {
* Form submission handler for rest_admin_form(). * Form submission handler for rest_admin_form().
*/ */
function rest_admin_form_submit($form, &$form_state) { function rest_admin_form_submit($form, &$form_state) {
$resources = array_filter($form_state['values']['entity_resources']); $enabled_resources = array_filter($form_state['values']['entity_resources']);
if (!empty($form_state['values']['other_resources'])) { if (!empty($form_state['values']['other_resources'])) {
$resources += array_filter($form_state['values']['other_resources']); $enabled_resources += array_filter($form_state['values']['other_resources']);
}
$resources = array();
$plugin_manager = drupal_container()->get('plugin.manager.rest');
// Enable all methods and all formats for each selected resource.
foreach ($enabled_resources as $resource) {
$plugin = $plugin_manager->getInstance(array('id' => $resource));
$methods = $plugin->availableMethods();
// An empty array means all formats are allowed for a method.
$resources[$resource] = array_fill_keys($methods, array());
} }
$config = config('rest.settings'); $config = config('rest.settings');