From 73f3973425e2d69ba523e869b4f1dcadf7af1be3 Mon Sep 17 00:00:00 2001 From: Dries <dries@buytaert.net> Date: Wed, 27 Feb 2013 16:58:32 -0500 Subject: [PATCH] Issue #1850734 by klausi: Make serialization formats configurable. --- .../rest/EventSubscriber/RouteSubscriber.php | 27 ++++-- .../lib/Drupal/rest/Plugin/ResourceBase.php | 91 +++++++++++-------- .../Drupal/rest/Plugin/ResourceInterface.php | 8 ++ .../Plugin/rest/resource/DBLogResource.php | 7 +- .../rest/lib/Drupal/rest/RequestHandler.php | 49 ++++++---- .../rest/lib/Drupal/rest/Tests/CreateTest.php | 2 +- .../rest/lib/Drupal/rest/Tests/DeleteTest.php | 2 +- .../lib/Drupal/rest/Tests/RESTTestBase.php | 20 ++-- .../rest/lib/Drupal/rest/Tests/ReadTest.php | 8 +- .../rest/lib/Drupal/rest/Tests/UpdateTest.php | 4 +- core/modules/rest/rest.admin.inc | 19 +++- 11 files changed, 152 insertions(+), 85 deletions(-) diff --git a/core/modules/rest/lib/Drupal/rest/EventSubscriber/RouteSubscriber.php b/core/modules/rest/lib/Drupal/rest/EventSubscriber/RouteSubscriber.php index d4711e00bdfa..965c149b4bda 100644 --- a/core/modules/rest/lib/Drupal/rest/EventSubscriber/RouteSubscriber.php +++ b/core/modules/rest/lib/Drupal/rest/EventSubscriber/RouteSubscriber.php @@ -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); + } } } } diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/ResourceBase.php b/core/modules/rest/lib/Drupal/rest/Plugin/ResourceBase.php index 41f19fd2a5da..747df459a261 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/ResourceBase.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/ResourceBase.php @@ -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; } } diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/ResourceInterface.php b/core/modules/rest/lib/Drupal/rest/Plugin/ResourceInterface.php index 87da39d73a6b..3f21f0d395f5 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/ResourceInterface.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/ResourceInterface.php @@ -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(); + } diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php index cc06e1a51262..cc4360317acc 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php @@ -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))); diff --git a/core/modules/rest/lib/Drupal/rest/RequestHandler.php b/core/modules/rest/lib/Drupal/rest/RequestHandler.php index 3922e9e13971..30156de693ec 100644 --- a/core/modules/rest/lib/Drupal/rest/RequestHandler.php +++ b/core/modules/rest/lib/Drupal/rest/RequestHandler.php @@ -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; } diff --git a/core/modules/rest/lib/Drupal/rest/Tests/CreateTest.php b/core/modules/rest/lib/Drupal/rest/Tests/CreateTest.php index df77ec3efaa7..a3a5c6028b22 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/CreateTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/CreateTest.php @@ -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)); diff --git a/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php b/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php index e39675e1ef6b..c29e7e65f7a6 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php @@ -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)); diff --git a/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php b/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php index f7dbdb65fdd7..c428c86973e5 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php @@ -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. diff --git a/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php b/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php index 29fb1befc4ef..299bdd49c8a5 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php @@ -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'); diff --git a/core/modules/rest/lib/Drupal/rest/Tests/UpdateTest.php b/core/modules/rest/lib/Drupal/rest/Tests/UpdateTest.php index fa65a2f30bad..94be2bf58652 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/UpdateTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/UpdateTest.php @@ -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)); diff --git a/core/modules/rest/rest.admin.inc b/core/modules/rest/rest.admin.inc index ef9d0d608518..95246ea2d7fb 100644 --- a/core/modules/rest/rest.admin.inc +++ b/core/modules/rest/rest.admin.inc @@ -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'); -- GitLab