Commit 73f39734 authored by Dries's avatar Dries
Browse files

Issue #1850734 by klausi: Make serialization formats configurable.

parent 00dfa022
......@@ -54,15 +54,30 @@ public function __construct(ResourcePluginManager $manager, ConfigFactory $confi
public function dynamicRoutes(RouteBuildEvent $event) {
$collection = $event->getRouteCollection();
$enabled_resources = $this->config->get('rest.settings')->load()->get('resources');
$resources = $this->config->get('rest.settings')->load()->get('resources');
if ($resources && $enabled = array_intersect_key($this->manager->getDefinitions(), $resources)) {
foreach ($enabled as $key => $resource) {
$plugin = $this->manager->getInstance(array('id' => $key));
// Iterate over all enabled resource plugins.
foreach ($enabled_resources as $id => $enabled_methods) {
$plugin = $this->manager->getInstance(array('id' => $id));
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');
$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);
}
}
}
}
......
......@@ -26,15 +26,11 @@ abstract class ResourceBase extends PluginBase implements ResourceInterface {
public function permissions() {
$permissions = array();
$definition = $this->getDefinition();
foreach ($this->requestMethods() as $method) {
foreach ($this->availableMethods() as $method) {
$lowered_method = strtolower($method);
// Only expose permissions where the HTTP request method exists on the
// plugin.
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'])),
);
}
$permissions["restful $lowered_method $this->plugin_id"] = array(
'title' => t('Access @method on %label resource', array('@method' => $method, '%label' => $definition['label'])),
);
}
return $permissions;
}
......@@ -47,38 +43,44 @@ public function routes() {
$path_prefix = strtr($this->plugin_id, ':', '/');
$route_name = strtr($this->plugin_id, ':', '.');
$methods = $this->requestMethods();
$methods = $this->availableMethods();
foreach ($methods as $method) {
$lower_method = strtolower($method);
// Only expose routes where the HTTP request method exists on the plugin.
if (method_exists($this, $lower_method)) {
$route = new Route("/$path_prefix/{id}", array(
'_controller' => 'Drupal\rest\RequestHandler::handle',
// Pass the resource plugin ID along as default property.
'_plugin' => $this->plugin_id,
), array(
// The HTTP method is a requirement for this route.
'_method' => $method,
'_permission' => "restful $lower_method $this->plugin_id",
));
$route = new Route("/$path_prefix/{id}", array(
'_controller' => 'Drupal\rest\RequestHandler::handle',
// Pass the resource plugin ID along as default property.
'_plugin' => $this->plugin_id,
), array(
// The HTTP method is a requirement for this route.
'_method' => $method,
'_permission' => "restful $lower_method $this->plugin_id",
));
switch ($method) {
case 'POST':
// POST routes do not require an ID in the URL path.
$route->setPattern("/$path_prefix");
$route->addDefaults(array('id' => NULL));
break;
switch ($method) {
case 'POST':
// POST routes do not require an ID in the URL path.
$route->setPattern("/$path_prefix");
$route->addDefaults(array('id' => NULL));
$collection->add("$route_name.$method", $route);
break;
case 'GET':
case 'HEAD':
// Restrict GET and HEAD requests to the media type specified in the
// HTTP Accept headers.
// @todo Replace hard coded format here with available formats.
$route->addRequirements(array('_format' => 'drupal_jsonld'));
break;
}
case 'GET':
case 'HEAD':
// Restrict GET and HEAD requests to the media type specified in the
// HTTP Accept headers.
$formats = drupal_container()->getParameter('serializer.formats');
foreach ($formats as $format_name => $label) {
// 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 @@ public function routes() {
* The list of allowed HTTP request method strings.
*/
protected function requestMethods() {
return drupal_map_assoc(array(
return array(
'HEAD',
'GET',
'POST',
......@@ -105,6 +107,21 @@ protected function requestMethods() {
'OPTIONS',
'CONNECT',
'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;
}
}
......@@ -36,4 +36,12 @@ public function routes();
*/
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();
}
......@@ -50,12 +50,7 @@ public function get($id = NULL) {
$record = db_query("SELECT * FROM {watchdog} WHERE wid = :wid", array(':wid' => $id))
->fetchObject();
if (!empty($record)) {
// Serialization is done here, so we indicate with NULL that there is no
// 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;
return new ResourceResponse((array) $record);
}
}
throw new NotFoundHttpException(t('Log entry with ID @id was not found', array('@id' => $id)));
......
......@@ -12,6 +12,7 @@
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/**
......@@ -43,17 +44,28 @@ public function handle(Request $request, $id = NULL) {
$received = $request->getContent();
$unserialized = NULL;
if (!empty($received)) {
$definition = $resource->getDefinition();
$class = $definition['serialization_class'];
try {
// @todo Replace the format here with something we get from the HTTP
// Content-type header. See http://drupal.org/node/1850704
$unserialized = $serializer->deserialize($received, $class, 'drupal_jsonld');
$format = $request->getContentType();
// Only allow serialization formats that are explicitly configured. If no
// formats are configured allow all and hope that the serializer knows the
// format. If the serializer cannot handle it an exception will be thrown
// 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) {
$error['error'] = $e->getMessage();
$content = $serializer->serialize($error, 'drupal_jsonld');
return new Response($content, 400, array('Content-Type' => 'application/vnd.drupal.ld+json'));
else {
throw new UnsupportedMediaTypeHttpException();
}
}
......@@ -63,21 +75,26 @@ public function handle(Request $request, $id = NULL) {
}
catch (HttpException $e) {
$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
// 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);
}
// Serialize the outgoing data for the response, if available.
$data = $response->getResponseData();
if ($data != NULL) {
// @todo Replace the format here with something we get from the HTTP
// Accept headers. See http://drupal.org/node/1833440
$output = $serializer->serialize($data, 'drupal_jsonld');
// All REST routes are restricted to exactly one format, so instead of
// parsing it out of the Accept headers again we can simply retrieve the
// 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->headers->set('Content-Type', 'application/vnd.drupal.ld+json');
$response->headers->set('Content-Type', $request->getMimeType($format));
}
return $response;
}
......
......@@ -38,7 +38,7 @@ public function testCreate() {
// entity types here as well.
$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
// resources via the web API.
$account = $this->drupalCreateUser(array('restful post entity:' . $entity_type));
......
......@@ -36,7 +36,7 @@ public function testDelete() {
// Define the entity types we want to test.
$entity_types = array('entity_test', 'node', 'user');
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
// resources via the web API.
$account = $this->drupalCreateUser(array('restful delete entity:' . $entity_type));
......
......@@ -156,18 +156,24 @@ protected function entityValues($entity_type) {
* @param string|FALSE $resource_type
* The resource type that should get web API enabled or FALSE to disable all
* 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.
$config = config('rest.settings');
$settings = array();
if ($resource_type) {
$config->set('resources', array(
$resource_type => $resource_type,
));
}
else {
$config->set('resources', array());
if ($format) {
$settings[$resource_type][$method][$format] = 'TRUE';
}
else {
$settings[$resource_type][$method] = array();
}
}
$config->set('resources', $settings);
$config->save();
// Rebuild routing cache, so that the web API paths are available.
......
......@@ -38,7 +38,7 @@ public function testRead() {
// Define the entity types we want to test.
$entity_types = array('entity_test');
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
// resources via the web API.
$account = $this->drupalCreateUser(array('restful get entity:' . $entity_type));
......@@ -57,12 +57,8 @@ public function testRead() {
$this->assertEqual($data['uuid'][LANGUAGE_DEFAULT][0]['value'], $entity->uuid(), 'Entity UUID is correct');
// 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');
$this->assertResponse(405);
$this->assertResponse(415);
// Try to read an entity that does not exist.
$response = $this->httpRequest('entity/' . $entity_type . '/9999', 'GET', NULL, 'application/vnd.drupal.ld+json');
......
......@@ -38,7 +38,7 @@ public function testPatchUpdate() {
// entity types here as well.
$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
// resources via the web API.
$account = $this->drupalCreateUser(array('restful patch entity:' . $entity_type));
......@@ -103,7 +103,7 @@ public function testPutUpdate() {
// entity types here as well.
$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
// resources via the web API.
$account = $this->drupalCreateUser(array('restful put entity:' . $entity_type));
......
......@@ -34,7 +34,10 @@ function rest_admin_form($form, &$form_state) {
}
asort($entity_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().
$header = array(
......@@ -79,9 +82,19 @@ function rest_admin_form($form, &$form_state) {
* Form submission handler for rest_admin_form().
*/
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'])) {
$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');
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment