summaryrefslogtreecommitdiffstats
path: root/core/modules/rest/src/RequestHandler.php
blob: 8180b70cb80ee5cfa505bcd63cee039827841ff1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
<?php

namespace Drupal\rest;

use Drupal\Component\Utility\ArgumentsResolver;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\rest\Plugin\ResourceInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * Acts as intermediate request forwarder for resource plugins.
 *
 * @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
 */
class RequestHandler implements ContainerInjectionInterface {

  /**
   * The config factory.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * The serializer.
   *
   * @var \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Encoder\DecoderInterface
   */
  protected $serializer;

  /**
   * Creates a new RequestHandler instance.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   * @param \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Encoder\DecoderInterface $serializer
   *   The serializer.
   */
  public function __construct(ConfigFactoryInterface $config_factory, SerializerInterface $serializer) {
    $this->configFactory = $config_factory;
    $this->serializer = $serializer;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('config.factory'),
      $container->get('serializer')
    );
  }

  /**
   * Handles a REST API request.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The HTTP request object.
   * @param \Drupal\rest\RestResourceConfigInterface $_rest_resource_config
   *   The REST resource config entity.
   *
   * @return \Drupal\rest\ResourceResponseInterface|\Symfony\Component\HttpFoundation\Response
   *   The REST resource response.
   */
  public function handle(RouteMatchInterface $route_match, Request $request, RestResourceConfigInterface $_rest_resource_config) {
    $resource = $_rest_resource_config->getResourcePlugin();
    $unserialized = $this->deserialize($route_match, $request, $resource);
    $response = $this->delegateToRestResourcePlugin($route_match, $request, $unserialized, $resource);
    return $this->prepareResponse($response, $_rest_resource_config);
  }

  /**
   * Handles a REST API request without deserializing the request body.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The HTTP request object.
   * @param \Drupal\rest\RestResourceConfigInterface $_rest_resource_config
   *   The REST resource config entity.
   *
   * @return \Symfony\Component\HttpFoundation\Response|\Drupal\rest\ResourceResponseInterface
   *   The REST resource response.
   */
  public function handleRaw(RouteMatchInterface $route_match, Request $request, RestResourceConfigInterface $_rest_resource_config) {
    $resource = $_rest_resource_config->getResourcePlugin();
    $response = $this->delegateToRestResourcePlugin($route_match, $request, NULL, $resource);
    return $this->prepareResponse($response, $_rest_resource_config);
  }

  /**
   * Prepares the REST resource response.
   *
   * @param \Drupal\rest\ResourceResponseInterface $response
   *   The REST resource response.
   * @param \Drupal\rest\RestResourceConfigInterface $resource_config
   *   The REST resource config entity.
   *
   * @return \Drupal\rest\ResourceResponseInterface
   *   The prepared REST resource response.
   */
  protected function prepareResponse($response, RestResourceConfigInterface $resource_config) {
    if ($response instanceof CacheableResponseInterface) {
      $response->addCacheableDependency($resource_config);
      // Add global rest settings config's cache tag, for BC flags.
      // @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
      // @see \Drupal\rest\EventSubscriber\RestConfigSubscriber
      // @todo Remove in https://www.drupal.org/node/2893804
      $response->addCacheableDependency($this->configFactory->get('rest.settings'));
    }

    return $response;
  }

  /**
   * Gets the normalized HTTP request method of the matched route.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   *
   * @return string
   *   The normalized HTTP request method.
   */
  protected static function getNormalizedRequestMethod(RouteMatchInterface $route_match) {
    // Symfony is built to transparently map HEAD requests to a GET request. In
    // the case of the REST module's RequestHandler though, we essentially have
    // our own light-weight routing system on top of the Drupal/symfony routing
    // system. So, we have to respect the decision that the routing system made:
    // we look not at the request method, but at the route's method. All REST
    // routes are guaranteed to have _method set.
    // Response::prepare() will transform it to a HEAD response at the very last
    // moment.
    // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
    // @see \Symfony\Component\Routing\Matcher\UrlMatcher::matchCollection()
    // @see \Symfony\Component\HttpFoundation\Response::prepare()
    $method = strtolower($route_match->getRouteObject()->getMethods()[0]);
    assert(count($route_match->getRouteObject()->getMethods()) === 1);
    return $method;
  }

  /**
   * Deserializes request body, if any.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The HTTP request object.
   * @param \Drupal\rest\Plugin\ResourceInterface $resource
   *   The REST resource plugin.
   *
   * @return array|null
   *   An object normalization, ikf there is a valid request body. NULL if there
   *   is no request body.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
   *   Thrown if the request body cannot be decoded.
   * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
   *   Thrown if the request body cannot be denormalized.
   */
  protected function deserialize(RouteMatchInterface $route_match, Request $request, ResourceInterface $resource) {
    // Deserialize incoming data if available.
    $received = $request->getContent();
    $unserialized = NULL;
    if (!empty($received)) {
      $method = static::getNormalizedRequestMethod($route_match);
      $format = $request->getContentType();

      $definition = $resource->getPluginDefinition();

      // First decode the request data. We can then determine if the
      // serialized data was malformed.
      try {
        $unserialized = $this->serializer->decode($received, $format, ['request_method' => $method]);
      }
      catch (UnexpectedValueException $e) {
        // If an exception was thrown at this stage, there was a problem
        // decoding the data. Throw a 400 http exception.
        throw new BadRequestHttpException($e->getMessage());
      }

      // Then attempt to denormalize if there is a serialization class.
      if (!empty($definition['serialization_class'])) {
        try {
          $unserialized = $this->serializer->denormalize($unserialized, $definition['serialization_class'], $format, ['request_method' => $method]);
        }
        // These two serialization exception types mean there was a problem
        // with the structure of the decoded data and it's not valid.
        catch (UnexpectedValueException $e) {
          throw new UnprocessableEntityHttpException($e->getMessage());
        }
        catch (InvalidArgumentException $e) {
          throw new UnprocessableEntityHttpException($e->getMessage());
        }
      }
    }

    return $unserialized;
  }

  /**
   * Delegates an incoming request to the appropriate REST resource plugin.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The HTTP request object.
   * @param mixed|null $unserialized
   *   The unserialized request body, if any.
   * @param \Drupal\rest\Plugin\ResourceInterface $resource
   *   The REST resource plugin.
   *
   * @return \Symfony\Component\HttpFoundation\Response|\Drupal\rest\ResourceResponseInterface
   *   The REST resource response.
   */
  protected function delegateToRestResourcePlugin(RouteMatchInterface $route_match, Request $request, $unserialized, ResourceInterface $resource) {
    $method = static::getNormalizedRequestMethod($route_match);

    // Determine the request parameters that should be passed to the resource
    // plugin.
    $argument_resolver = $this->createArgumentResolver($route_match, $unserialized, $request);
    try {
      $arguments = $argument_resolver->getArguments([$resource, $method]);
    }
    catch (\RuntimeException $exception) {
      @trigger_error('Passing in arguments the legacy way is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Provide the right parameter names in the method, similar to controllers. See https://www.drupal.org/node/2894819', E_USER_DEPRECATED);
      $arguments = $this->getLegacyParameters($route_match, $unserialized, $request);
    }

    // Invoke the operation on the resource plugin.
    return call_user_func_array([$resource, $method], $arguments);
  }

  /**
   * Creates an argument resolver, containing all REST parameters.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   * @param mixed $unserialized
   *   The unserialized data.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request.
   *
   * @return \Drupal\Component\Utility\ArgumentsResolver
   *   An instance of the argument resolver containing information like the
   *   'entity' we process and the 'unserialized' content from the request body.
   */
  protected function createArgumentResolver(RouteMatchInterface $route_match, $unserialized, Request $request) {
    $route = $route_match->getRouteObject();

    // Defaults for the parameters defined on the route object need to be added
    // to the raw arguments.
    $raw_route_arguments = $route_match->getRawParameters()->all() + $route->getDefaults();

    $route_arguments = $route_match->getParameters()->all();
    $upcasted_route_arguments = $route_arguments;

    // For request methods that have request bodies, ResourceInterface plugin
    // methods historically receive the unserialized request body as the N+1th
    // method argument, where N is the number of route parameters specified on
    // the accompanying route. To be able to use the argument resolver, which is
    // not based on position but on name and typehint, specify commonly used
    // names here. Similarly, those methods receive the original stored object
    // as the first method argument.

    $route_arguments_entity = NULL;
    // Try to find a parameter which is an entity.
    foreach ($route_arguments as $value) {
      if ($value instanceof EntityInterface) {
        $route_arguments_entity = $value;
        break;
      }
    }

    if (in_array($request->getMethod(), ['PATCH', 'POST'], TRUE)) {
      $upcasted_route_arguments['entity'] = $unserialized;
      $upcasted_route_arguments['data'] = $unserialized;
      $upcasted_route_arguments['unserialized'] = $unserialized;
      $upcasted_route_arguments['original_entity'] = $route_arguments_entity;
    }
    else {
      $upcasted_route_arguments['entity'] = $route_arguments_entity;
    }

    // Parameters which are not defined on the route object, but still are
    // essential for access checking are passed as wildcards to the argument
    // resolver.
    $wildcard_arguments = [$route, $route_match];
    $wildcard_arguments[] = $request;
    if (isset($unserialized)) {
      $wildcard_arguments[] = $unserialized;
    }

    return new ArgumentsResolver($raw_route_arguments, $upcasted_route_arguments, $wildcard_arguments);
  }

  /**
   * Provides the parameter usable without an argument resolver.
   *
   * This creates an list of parameters in a statically defined order.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match
   * @param mixed $unserialized
   *   The unserialized data.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request.
   *
   * @deprecated in Drupal 8.4.0, will be removed before Drupal 9.0.0. Use the
   *   argument resolver method instead, see ::createArgumentResolver().
   *
   * @see https://www.drupal.org/node/2894819
   *
   * @return array
   *   An array of parameters.
   */
  protected function getLegacyParameters(RouteMatchInterface $route_match, $unserialized, Request $request) {
    $route_parameters = $route_match->getParameters();
    $parameters = [];
    // Filter out all internal parameters starting with "_".
    foreach ($route_parameters as $key => $parameter) {
      if ($key{0} !== '_') {
        $parameters[] = $parameter;
      }
    }

    return array_merge($parameters, [$unserialized, $request]);
  }

}