Laptop met Dropsolid-strandbal

Custom REST resources - a deeper look

Nick Vanpraet

REST is becoming more and more prevalent, and Drupal 8 is on the case. Core now comes with built-in support for REST resources. This article assumes you’re already familiar with the basics of custom REST resources. If not, there are many articles you can read first as well as the overview page on drupal.org.

 

Inheritance & the hidden gems of D8 rest

Not everything has been documented (yet) when it comes to Drupal 8 - it is a massive project, after all -, and REST is no exception. This is in part because there is less need to, thanks to inheritance. Looking at the class you’re extending can immediately give you a wealth of options. All custom resources, directly or indirectly (in the case of entities), extend ResourceBase. Knowing that, let’s see what we can accomplish with some easy overrides.

 

I need my route to have special permissions or other requirements

This is usually when people switch to custom controllers, because they can add everything they need to their *.routes.yml file. But there’s no need, because ResourceBase has a routes() method and everything you can do in the *.routes.yml file, you can do in that method. But don’t be hasty, there is another method you can override first if there isn't much that needs changing. If every route in your resource needs the same requirements, or if you can split them based on only the method, you can simply extend getBaseRouteRequirements(). This method is called for each route of your resource and it expects an array of requirements as its return value. Here we can easily require multiple permissions, for example.

 

/**
* @inheritdoc
*/
protected function getBaseRouteRequirements($method) {
 // the original method adds the auto-generated permissions
 // as well as _access = TRUE
 $requirements = parent::getBaseRouteRequirements($method);

 if ($method === 'GET') {
   // we replace the GET permission with our own, illogical permissions
   // we still add multiple permissions using a comma, same as when defining routes elsewhere
   $requirements['_permission'] = 'access content, export configuration';
   // we also add a role requirement, because we can
   $requirements['_role'] = 'admin';
 }

 return $requirements;
}

But if you want to add options or just want to go even more in-depth, you can override getBaseRoute(). There you receive the canonical route and the method and you have to build a route object. Which means you can add requirements, options, change the path, etc. Note, however, that for creating (POST) the path gets changed again in the routes() method. Which is why I suggest skipping this method and going straight to overriding routes().

Either way, you can do anything you can do in *.routes.yml files here too. For example, you can automatically load an entity based on parameters. Given a path like this: "/entity/{my_entity}/list/{node}" we can make sure the custom entity and the node get loaded before it even reaches our methods.

public function routes() {
[...]
foreach ($methods as $method) {
 $route = $this->getBaseRoute($canonical_path, $method);

$options['parameters']['my_entity'] = [
 'type' => 'entity:my_entity',
 'converter' => 'paramconverter.entity'
];
$options['parameters']['node'] = [
 'type' => 'entity:node',
 'converter' => 'paramconverter.entity'
];

$route->addOptions($options);
[...]
}

We can play with requirements here as well of course, and anything else routes allow. We can add _entity_access, or a _custom_access. We can also define our own permissions in the permissions() method, or if we do not need ResourceBase’s default permissions, override it and return an empty array.

But let’s remove a requirement instead.

 

I need my routes to work without “_format=FORMAT”

Even this is possible, though I only advise it when you are building a larger API, as this change is fairly substantial. If you’re only doing it for a single resource, the less impactful way would still be a custom controller. But let’s say we are building a larger API, and the paths are already defined so we have to find a way to expose it without requiring _format.

First, you will need a new BaseRoute class your other resources can inherit from. This class extends BaseRoute and is empty save for the override of routes() where we remove the _format requirement for our GET and HEAD methods. We also set a default on this route with the format we want to use. Make sure it starts with an underscore, to mark this default as internal.

/**
* {@inheritdoc}
*
* Remove the _format requirement to json GET routes
*/
public function routes() {

 $collection = parent::routes();

 foreach ($collection->all() as $route_name => $route) {
   if (in_array('GET', $route->getMethods()) || in_array('HEAD', $route->getMethods())) {
     $requirements = $route->getRequirements();
     if (isset($requirements['_format']) && $requirements['_format'] == 'json') {
       unset($requirements['_format']);
       $route->setRequirements($requirements);
       $route->setDefault('_preferred_api_format', 'json');
     }
   }
 }

 return $collection;
}

Now your custom resource can simply extend this NewResourceBase instead of the old and they will all be missing the _format requirement for their GETs. Which is less work than adding this bit of code to each of your custom resources.

But if you were to go to one of your resources now, it would balk about a missing serializer.

That’s because the service which determines what serializer to use depends on that _format requirement being present. Without it, it returns nothing and errors occur. So we have to replace the class for that service with our own. Which is easy as pie.

The service we’re looking for is rest.resource_response.subscriber and that service’s class is what we’ll be extending and setting as the class to use.This NewResourceResponseSubscriber is also empty save for an override of getResponseFormat. What we put in there is the following:

public function getResponseFormat(RouteMatchInterface $route_match, Request $request) {
 $acceptable_format = parent::getResponseFormat($route_match, $request);
 // if the acceptable format is empty, check to see if there are any defaults we can use
 // instead
 if(empty($acceptable_format) && $route_match->getRouteObject()->hasDefault('_preferred_api_format')){
   // return the preferred format
   return $route_match->getRouteObject()->getDefault('_preferred_api_format');
 }
return $acceptable_format;
}

This way, nothing changes for any other resources, only for our own custom ones extending our NewResourceBase. This works... until you get a 4xx or a different error, at which point the response will be output in HTML. So to fix that we have to override another service: the request_format_route_filter service. This service provides a route filter, filtered by the request format. It only applies when a route has a _format requirement, which ours no longer has. But we also can’t make it apply to everything, that would be overkill.

We override the applies method so it returns true when a route has a _format requirement OR when it has a default named “_preferred_api_format”. We also override the filter method so we can use our _preferred_api_format fallback.

/**
* {@inheritdoc}
*/
public function applies(Route $route) {
 return $route->hasRequirement('_format') || $route->hasDefault('_preferred_api_format');
}

/**
* {@inheritdoc}
*/
public function filter(RouteCollection $collection, Request $request) {
 // Determine the request format.
 $default_format = static::getDefaultFormat($collection);
 $format = $request->getRequestFormat($default_format);

 /** @var \Symfony\Component\Routing\Route $route */
 foreach ($collection as $name => $route) {
   // If the route has no _format specification, we move it to the end. If it
   // does, then no match means the route is removed entirely.
   if ($supported_formats = array_filter(explode('|', $route->getRequirement('_format')))) {
     if (!in_array($format, $supported_formats)) {
       $collection->remove($name);
     }
   }
   else {
     // if this route has a preferred format, set the request format to that format
     if ($route->hasDefault('_preferred_api_format')) {
       $request->setRequestFormat($route->getDefault('_preferred_api_format'));
     }
     $collection->add($name, $route);
   }
 }

 if (count($collection)) {
   return $collection;
 }

 // We do not throw a
 // \Symfony\Component\Routing\Exception\ResourceNotFoundException here
 // because we don't want to return a 404 status code, but rather a 406.
 throw new NotAcceptableHttpException("No route found for the specified format $format.");
}

And that is it - your new custom resources extending the NewResourceBase no longer require _format!

 

But what about entities?

Now, you might be thinking “that’s all well and good, but what about Entity REST resources?”. Well, all of this applies to them as well. Normally EntityResource handles everything REST-related concerning entities, thanks to its EntityDeriver, which generates a resource for each entity type. And if you want full control, you just have to define a new custom resource and make sure it has the same ID as the one that is generated automatically, namely entity:{entity_type}, and have it extend EntityResource instead of ResourceBase. You can also set the class using hook_rest_resource_alter instead.

One important note however, is that if you want to remove the _format requirement for entities the path should differ from the actual canonical path. So for nodes the canonical in your resource can’t be “node/{node}”, which is the canonical of the actual entity. Otherwise you will receive “not acceptable format: json” errors.

(If you're using REST UI, this path change won't be visible in the UI. That module duplicates the logic of the EntityDeriver and will still base the paths on the definition of the entity.)

For example, to gain control over node’s REST resource:

namespace Drupal\MY_MODULE\Plugin\rest\resource;

use Drupal\rest\Plugin\rest\resource\EntityResource;

/**
* Rest resource for nodes. By setting the id correctly, it 
* replaces the automatically generated one
*
* @RestResource(
*   id = "entity:node",
*   label = @Translation("Node"),
*   serialization_class = "Drupal\node\Entity\Node",
*   entity_type = "node",
*   uri_paths = {
*     "canonical" = "/api/node/{node}",
*     "https://www.drupal.org/link-relations/create" = "/api/node"
*   }
* )
*/
class NodeRestResource extends EntityResource {
 // have at it!
}

 

If you wish to create a collection resource on the same path as the create to show all nodes, this does require a second custom resource. A resource can only have one method once, so no two GETs. Or you can simply use Views.

 

I hope this has given you some insight into the way REST handles its resources - this article shows that there are very few cases where a custom controller is superior to a custom REST resource.

Interested in seeing all the code together? Download a small test module here.

 

More Drupal blogs      Subscribe to our newsletter

Recommended articles
Drupal 8 migration strategies
Custom REST resources
Drupal 8 config management (part 1)
Dropsolid at Drupal Europe
Multiple Drush versions on one system

Reacties

Add new comment