diff --git a/core/core.services.yml b/core/core.services.yml index 737009b..9b64819 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -935,11 +935,22 @@ services: method_filter: class: Drupal\Core\Routing\MethodFilter tags: - - { name: route_filter, priority: 1 } + # The HTTP method route filter must run very early: it removes any routes + # whose requirements do not allow the HTTP method of the current request. + # Throws a 405 if no routes match the current request's HTTP method. + # (If it runs before content_type_header_matcher, it can ensure that that + # only receives routes which can have a Content-Type request header.) + - { name: route_filter, priority: 10 } content_type_header_matcher: class: Drupal\Core\Routing\ContentTypeHeaderMatcher tags: - - { name: route_filter } + # The Content-Type request header route filter must run early: it removes + # any routes whose requirements do not allow the Content-Type request + # header of the current request. + # Throws a 415 if no routes match the Content-Type request header of the + # current request, or if it has no Content-Type request header. + # Note it does nothing for GET requests. + - { name: route_filter, priority: 5 } paramconverter_manager: class: Drupal\Core\ParamConverter\ParamConverterManager tags: diff --git a/core/lib/Drupal/Core/Routing/BcRoute.php b/core/lib/Drupal/Core/Routing/BcRoute.php new file mode 100644 index 0000000..e886931 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/BcRoute.php @@ -0,0 +1,29 @@ +setOption('bc_route', TRUE); + } + +} diff --git a/core/modules/dblog/tests/src/Functional/DbLogResourceTest.php b/core/modules/dblog/tests/src/Functional/DbLogResourceTest.php index db05b43..0d46303 100644 --- a/core/modules/dblog/tests/src/Functional/DbLogResourceTest.php +++ b/core/modules/dblog/tests/src/Functional/DbLogResourceTest.php @@ -62,7 +62,7 @@ public function testWatchdog() { ->fetchField(); $this->initAuthentication(); - $url = Url::fromRoute('rest.dblog.GET.' . static::$format, ['id' => $id, '_format' => static::$format]); + $url = Url::fromRoute('rest.dblog.GET', ['id' => $id, '_format' => static::$format]); $request_options = $this->getAuthenticationRequestOptions('GET'); $response = $this->request('GET', $url, $request_options); diff --git a/core/modules/file/src/FileServiceProvider.php b/core/modules/file/src/FileServiceProvider.php new file mode 100644 index 0000000..a22750a --- /dev/null +++ b/core/modules/file/src/FileServiceProvider.php @@ -0,0 +1,23 @@ +has('http_middleware.negotiation') && is_a($container->getDefinition('http_middleware.negotiation')->getClass(), NegotiationMiddleware::class, TRUE)) { + $container->getDefinition('http_middleware.negotiation')->addMethodCall('registerFormat', ['bin', ['application/octet-stream']]); + } + } + +} diff --git a/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php new file mode 100644 index 0000000..12982a3 --- /dev/null +++ b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php @@ -0,0 +1,558 @@ +\*?)=\"(?.+)\"@'; + + /** + * The file system service. + * + * @var \Drupal\Core\File\FileSystem + */ + protected $fileSystem; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The entity field manager. + * + * @var \Drupal\Core\Entity\EntityFieldManagerInterface + */ + protected $entityFieldManager; + + /** + * The currently authenticated user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $currentUser; + + /** + * The MIME type guesser. + * + * @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface + */ + protected $mimeTypeGuesser; + + /** + * The token replacement instance. + * + * @var \Drupal\Core\Utility\Token + */ + protected $token; + + /** + * The lock service. + * + * @var \Drupal\Core\Lock\LockBackendInterface + */ + protected $lock; + + /** + * Constructs a FileUploadResource instance. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param array $serializer_formats + * The available serialization formats. + * @param \Psr\Log\LoggerInterface $logger + * A logger instance. + * @param \Drupal\Core\File\FileSystem $file_system + * The file system service. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager + * The entity field manager. + * @param \Drupal\Core\Session\AccountInterface $current_user + * The currently authenticated user. + * @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mime_type_guesser + * The MIME type guesser. + * @param \Drupal\Core\Utility\Token $token + * The token replacement instance. + * @param \Drupal\Core\Lock\LockBackendInterface $lock + * The lock service. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, $serializer_formats, LoggerInterface $logger, FileSystem $file_system, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, AccountInterface $current_user, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock) { + parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger); + $this->fileSystem = $file_system; + $this->entityTypeManager = $entity_type_manager; + $this->entityFieldManager = $entity_field_manager; + $this->currentUser = $current_user; + $this->mimeTypeGuesser = $mime_type_guesser; + $this->token = $token; + $this->lock = $lock; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->getParameter('serializer.formats'), + $container->get('logger.factory')->get('rest'), + $container->get('file_system'), + $container->get('entity_type.manager'), + $container->get('entity_field.manager'), + $container->get('current_user'), + $container->get('file.mime_type.guesser'), + $container->get('token'), + $container->get('lock') + ); + } + + /** + * {@inheritdoc} + */ + public function permissions() { + // Access to this resource depends on field-level access so no explicit + // permissions are required. + // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validateAndLoadFieldDefinition() + // @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions() + return []; + } + + /** + * Creates a file from endpoint. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * @param string $entity_type_id + * The entity type ID. + * @param string $bundle + * The bundle. + * @param string $field_name + * The field name. + * + * @return \Drupal\rest\ModifiedResourceResponse + * A 201 response, on success. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * Thrown when temporary files cannot be written, a lock cannot be acquired, + * or when temporary files cannot be moved to their new location. + */ + public function post(Request $request, $entity_type_id, $bundle, $field_name) { + $filename = $this->validateAndParseContentDispositionHeader($request); + + $field_definition = $this->validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name); + + $destination = $this->getUploadLocation($field_definition->getSettings()); + + // Check the destination file path is writable. + if (!file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) { + throw new HttpException(500, 'Destination file path is not writable'); + } + + $validators = $this->getUploadValidators($field_definition); + + $prepared_filename = $this->prepareFilename($filename, $validators); + + // Create the file. + $file_uri = "{$destination}/{$prepared_filename}"; + + $temp_file_path = $this->streamUploadData(); + + // This will take care of altering $file_uri if a file already exists. + file_unmanaged_prepare($temp_file_path, $file_uri); + + // Lock based on the prepared file URI. + $lock_id = $this->generateLockIdFromFileUri($file_uri); + + if (!$this->lock->acquire($lock_id)) { + throw new HttpException(503, sprintf('File "%s" is already locked for writing'), NULL, ['Retry-After' => 1]); + } + + // Begin building file entity. + $values = [ + 'uid' => $this->currentUser->id(), + 'filename' => $prepared_filename, + 'filemime' => $this->mimeTypeGuesser->guess($prepared_filename), + 'uri' => $file_uri, + // Set the size. This is done in File::preSave() but we validate the file + // before it is saved. + 'filesize' => @filesize($temp_file_path), + ]; + $file = File::create($values); + + // Validate the file entity against entity-level validation and field-level + // validators. + $this->validate($file, $field_definition, $validators); + + // Move the file to the correct location after validation. Use + // FILE_EXISTS_ERROR as the file location has already been determined above + // in file_unmanaged_prepare(). + if (!file_unmanaged_move($temp_file_path, $file_uri, FILE_EXISTS_ERROR)) { + throw new HttpException(500, 'Temporary file could not be moved to file location'); + } + + $file->save(); + + $this->lock->release($lock_id); + + // 201 Created responses return the newly created entity in the response + // body. These responses are not cacheable, so we add no cacheability + // metadata here. + return new ModifiedResourceResponse($file, 201); + } + + /** + * Streams file upload data to temporary file and moves to file destination. + * + * @return string + * The temp file path. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * Thrown when input data cannot be read, the temporary file cannot be + * opened, or the temporary file cannot be written. + */ + protected function streamUploadData() { + // 'rb' is needed so reading works correctly on Windows environments too. + $file_data = fopen('php://input', 'rb'); + + $temp_file_path = $this->fileSystem->tempnam('temporary://', 'file'); + $temp_file = fopen($temp_file_path, 'wb'); + + if ($temp_file) { + while (!feof($file_data)) { + $read = fread($file_data, 8192); + + if ($read === FALSE) { + // Close the temp file stream. + fclose($temp_file); + $this->logger->error('Input data could not be read'); + throw new HttpException(500, 'Input file data could not be read'); + } + + if (fwrite($temp_file, $read) === FALSE) { + // Close the temp file stream. + fclose($temp_file); + $this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]); + throw new HttpException(500, 'Temporary file data could not be written'); + } + } + + // Close the temp file stream. + fclose($temp_file); + } + else { + $this->logger->error('Temporary file "%path" could not be opened for file upload', ['%path' => $temp_file_path]); + throw new HttpException(500, 'Temporary file could not be opened'); + } + + // Close the input stream. + fclose($file_data); + + return $temp_file_path; + } + + /** + * Validates and extracts the filename from the Content-Disposition header. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return string + * The filename extracted from the header. + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * Thrown when the 'Content-Disposition' request header is invalid. + */ + protected function validateAndParseContentDispositionHeader(Request $request) { + // Firstly, check the header exists. + if (!$request->headers->has('content-disposition')) { + throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided'); + } + + $content_disposition = $request->headers->get('content-disposition'); + + // Parse the header value. This regex does not allow an empty filename. + // i.e. 'filename=""'. This also matches on a word boundary so other keys + // like 'not_a_filename' don't work. + if (!preg_match(static::FILENAME_REGEX, $content_disposition, $matches)) { + throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided'); + } + + // Check for the "filename*" format. This is currently unsupported. + if (!empty($matches['star'])) { + throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition header"'); + } + + // Don't validate the actual filename here, that will be done by the upload + // validators in validate(). + // @see file_validate() + $filename = $matches['filename']; + + // Make sure only the trailing filename is returned. + return basename($filename); + } + + /** + * Validates and loads a field definition instance. + * + * @param string $entity_type_id + * The entity type ID the field is attached to. + * @param string $bundle + * The bundle the field is attached to. + * @param string $field_name + * The field name. + * + * @return \Drupal\Core\Field\FieldDefinitionInterface + * The field definition. + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * Thrown when the field does not exist. + * @throws \Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException + * Thrown when the target type of the field is not a file, or the current + * user does not have 'edit' access for the field. + */ + protected function validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name) { + $field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle); + if (!isset($field_definitions[$field_name])) { + throw new BadRequestHttpException(sprintf('Field "%s" does not exist', $field_name)); + } + + /** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */ + $field_definition = $field_definitions[$field_name]; + if ($field_definition->getSetting('target_type') !== 'file') { + throw new AccessDeniedHttpException(sprintf('"%s" is not a file field', $field_name)); + } + + $entity_access_control_handler = $this->entityTypeManager->getAccessControlHandler($entity_type_id); + $bundle = $this->entityTypeManager->getDefinition($entity_type_id)->hasKey('bundle') ? $bundle : NULL; + $access_result = $entity_access_control_handler->createAccess(NULL, NULL, [], TRUE) + ->andIf($entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE)); + if (!($entity_access_control_handler->createAccess($bundle) && $entity_access_control_handler->fieldAccess('edit', $field_definition))) { + throw new AccessDeniedHttpException($access_result->getReason()); + } + + return $field_definition; + } + + /** + * Validates the file. + * + * @param \Drupal\file\FileInterface $file + * The file entity to validate. + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition to validate against. + * @param array $validators + * An array of upload validators to pass to file_validate(). + * + * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException + * Thrown when there are file validation errors. + */ + protected function validate(FileInterface $file, FieldDefinitionInterface $field_definition, array $validators) { + $this->resourceValidate($file); + + // Validate the file based on the field definition configuration. + $errors = file_validate($file, $validators); + + if (!empty($errors)) { + $message = "Unprocessable Entity: file validation failed.\n"; + $message .= implode("\n", array_map(function ($error) { + return PlainTextOutput::renderFromHtml($error); + }, $errors)); + + throw new UnprocessableEntityHttpException($message); + } + } + + /** + * Prepares the filename to strip out any malicious extensions. + * + * @param string $filename + * The file name. + * @param array $validators + * The array of upload validators. + * + * @return string + * The prepared/munged filename. + */ + protected function prepareFilename($filename, array &$validators) { + // Build the list of non-munged extensions if the caller provided them. + $extensions = $validators['file_validate_extensions'][0]; + + if (!empty($extensions)) { + // Munge the filename to protect against possible malicious extension + // hiding within an unknown file type (ie: filename.html.foo). + $filename = file_munge_filename($filename, $extensions); + } + + // Rename potentially executable files, to help prevent exploits (i.e. will + // rename filename.php.foo and filename.php to filename.php.foo.txt and + // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads' + // evaluates to TRUE. + if (!\Drupal::config('system.file')->get('allow_insecure_uploads') && preg_match('/\.(php|pl|py|cgi|asp|js)(\.|$)/i', $filename) && (substr($filename, -4) != '.txt')) { + // The destination filename will also later be used to create the URI. + $filename .= '.txt'; + + // The .txt extension may not be in the allowed list of extensions. We have + // to add it here or else the file upload will fail. + if (!empty($extensions)) { + $validators['file_validate_extensions'][0] .= ' txt'; + } + } + + return $filename; + } + + /** + * Determines the URI for a file field. + * + * @param array $settings + * The array of field settings. + * + * @return string + * An un-sanitized file directory URI with tokens replaced. The result of + * the token replacement is then converted to plain text and returned. + */ + protected function getUploadLocation(array $settings) { + $destination = trim($settings['file_directory'], '/'); + + // Replace tokens. As the tokens might contain HTML we convert it to plain + // text. + $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [])); + return $settings['uri_scheme'] . '://' . $destination; + } + + /** + * Retrieves the upload validators for a field definition. + * + * This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there + * is no entity instance available here that that a FileItem would exist for. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field definition for which to get validators. + * + * @return array + * An array suitable for passing to file_save_upload() or the file field + * element's '#upload_validators' property. + */ + protected function getUploadValidators($field_definition) { + $validators = [ + // Add in our check of the file name length. + 'file_validate_name_length' => [], + ]; + $settings = $field_definition->getSettings(); + + // Cap the upload size according to the PHP limit. + $max_filesize = Bytes::toInt(file_upload_max_size()); + if (!empty($settings['max_filesize'])) { + $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize'])); + } + + // There is always a file size limit due to the PHP server limit. + $validators['file_validate_size'] = [$max_filesize]; + + // Add the extension check if necessary. + if (!empty($settings['file_extensions'])) { + $validators['file_validate_extensions'] = [$settings['file_extensions']]; + } + + return $validators; + } + + /** + * {@inheritdoc} + */ + protected function getBaseRoute($canonical_path, $method) { + return new Route($canonical_path, [ + '_controller' => RequestHandler::class . '::handleRaw', + ], + $this->getBaseRouteRequirements($method), + [], + '', + [], + // The HTTP method is a requirement for this route. + [$method] + ); + } + + /** + * {@inheritdoc} + */ + protected function getBaseRouteRequirements($method) { + $requirements = parent::getBaseRouteRequirements($method); + + // Add the content type format access check. This will enforce that all + // incoming requests can only use the 'application/octet-stream' + // Content-Type header. + $requirements['_content_type_format'] = 'bin'; + + return $requirements; + } + + /** + * Generates a lock ID based on the file URI. + * + * @param $file_uri + * The file URI. + * + * @return string + * The generated lock ID. + */ + protected function generateLockIdFromFileUri($file_uri) { + return 'file:rest:' . Crypt::hashBase64($file_uri); + } + +} diff --git a/core/modules/file/tests/src/Functional/FileUploadJsonBasicAuthTest.php b/core/modules/file/tests/src/Functional/FileUploadJsonBasicAuthTest.php new file mode 100644 index 0000000..cef59f8 --- /dev/null +++ b/core/modules/file/tests/src/Functional/FileUploadJsonBasicAuthTest.php @@ -0,0 +1,35 @@ +httpClient = $http_client; $this->halSettings = $config_factory->get('hal.settings'); } @@ -73,16 +69,4 @@ public function normalize($entity, $format = NULL, array $context = []) { return $data; } - /** - * {@inheritdoc} - */ - public function denormalize($data, $class, $format = NULL, array $context = []) { - $file_data = (string) $this->httpClient->get($data['uri'][0]['value'])->getBody(); - - $path = 'temporary://' . drupal_basename($data['uri'][0]['value']); - $data['uri'] = file_unmanaged_save_data($file_data, $path); - - return $this->entityManager->getStorage('file')->create($data); - } - } diff --git a/core/modules/hal/tests/src/Functional/EntityResource/File/FileUploadHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/File/FileUploadHalJsonBasicAuthTest.php new file mode 100644 index 0000000..9d2348b --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/File/FileUploadHalJsonBasicAuthTest.php @@ -0,0 +1,24 @@ + [ + 'self' => [ + // @todo This can use a proper link once + // https://www.drupal.org/project/drupal/issues/2907402 is complete. + // This link matches what is generated from from File::url(), a + // resource URL is currently not available. + 'href' => file_create_url($normalization['uri'][0]['value']), + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/file/file', + ], + $this->baseUrl . '/rest/relation/file/file/uid' => [ + ['href' => $this->baseUrl . '/user/' . $this->account->id() . '?_format=hal_json'] + ], + ], + '_embedded' => [ + $this->baseUrl . '/rest/relation/file/file/uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/' . $this->account->id() . '?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + [ + 'value' => $this->account->uuid(), + ], + ], + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + * + * @see \Drupal\Tests\hal\Functional\EntityResource\EntityTest\EntityTestHalJsonAnonTest::getNormalizedPostEntity() + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/entity_test/entity_test', + ], + ], + ]; + } + +} diff --git a/core/modules/hal/tests/src/Functional/FileDenormalizeTest.php b/core/modules/hal/tests/src/Functional/FileDenormalizeTest.php deleted file mode 100644 index 9c00902..0000000 --- a/core/modules/hal/tests/src/Functional/FileDenormalizeTest.php +++ /dev/null @@ -1,89 +0,0 @@ -getEditable('hal.settings') - ->set('bc_file_uri_as_url_normalizer', TRUE) - ->save(TRUE); - } - - /** - * Tests file entity denormalization. - */ - public function testFileDenormalize() { - $file_params = [ - 'filename' => 'test_1.txt', - 'uri' => 'public://test_1.txt', - 'filemime' => 'text/plain', - 'status' => FILE_STATUS_PERMANENT, - ]; - // Create a new file entity. - $file = File::create($file_params); - file_put_contents($file->getFileUri(), 'hello world'); - $file->save(); - - $serializer = \Drupal::service('serializer'); - $normalized_data = $serializer->normalize($file, 'hal_json'); - $denormalized = $serializer->denormalize($normalized_data, 'Drupal\file\Entity\File', 'hal_json'); - - $this->assertTrue($denormalized instanceof File, 'A File instance was created.'); - - $this->assertIdentical('temporary://' . $file->getFilename(), $denormalized->getFileUri(), 'The expected file URI was found.'); - $this->assertTrue(file_exists($denormalized->getFileUri()), 'The temporary file was found.'); - - $this->assertIdentical($file->uuid(), $denormalized->uuid(), 'The expected UUID was found'); - $this->assertIdentical($file->getMimeType(), $denormalized->getMimeType(), 'The expected MIME type was found.'); - $this->assertIdentical($file->getFilename(), $denormalized->getFilename(), 'The expected filename was found.'); - $this->assertTrue($denormalized->isPermanent(), 'The file has a permanent status.'); - - // Try to denormalize with the file uri only. - $file_name = 'test_2.txt'; - $file_path = 'public://' . $file_name; - - file_put_contents($file_path, 'hello world'); - $file_uri = file_create_url($file_path); - - $data = [ - 'uri' => [ - ['value' => $file_uri], - ], - ]; - - $denormalized = $serializer->denormalize($data, 'Drupal\file\Entity\File', 'hal_json'); - - $this->assertIdentical('temporary://' . $file_name, $denormalized->getFileUri(), 'The expected file URI was found.'); - $this->assertTrue(file_exists($denormalized->getFileUri()), 'The temporary file was found.'); - - $this->assertIdentical('text/plain', $denormalized->getMimeType(), 'The expected MIME type was found.'); - $this->assertIdentical($file_name, $denormalized->getFilename(), 'The expected filename was found.'); - $this->assertFalse($denormalized->isPermanent(), 'The file has a permanent status.'); - } - -} diff --git a/core/modules/rest/rest.services.yml b/core/modules/rest/rest.services.yml index 080a681..511628e 100644 --- a/core/modules/rest/rest.services.yml +++ b/core/modules/rest/rest.services.yml @@ -43,3 +43,8 @@ services: arguments: ['@entity_type.manager'] tags: - { name: path_processor_inbound } + rest.route_processor_get_bc: + class: \Drupal\rest\RouteProcessor\RestResourceGetRouteProcessorBC + arguments: ['%serializer.formats%', '@router.route_provider'] + tags: + - { name: route_processor_outbound } diff --git a/core/modules/rest/src/Plugin/ResourceBase.php b/core/modules/rest/src/Plugin/ResourceBase.php index b40a031..743674f 100644 --- a/core/modules/rest/src/Plugin/ResourceBase.php +++ b/core/modules/rest/src/Plugin/ResourceBase.php @@ -4,6 +4,7 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Plugin\PluginBase; +use Drupal\Core\Routing\BcRoute; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Routing\Route; @@ -114,29 +115,24 @@ public function routes() { $methods = $this->availableMethods(); foreach ($methods as $method) { - $route = $this->getBaseRoute($canonical_path, $method); - - switch ($method) { - case 'POST': - $route->setPath($create_path); - $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. - foreach ($this->serializerFormats as $format_name) { - // Expose one route per available format. - $format_route = clone $route; - $format_route->addRequirements(['_format' => $format_name]); - $collection->add("$route_name.$method.$format_name", $format_route); - } - break; - - default: - $collection->add("$route_name.$method", $route); - break; + $path = $method === 'POST' + ? $create_path + : $canonical_path; + $route = $this->getBaseRoute($path, $method); + + // Note that '_format' and '_content_type_format' route requirements are + // added in ResourceRoutes::getRoutesForResourceConfig(). + $collection->add("$route_name.$method", $route); + + // BC: the REST module originally created per-format GET routes, instead + // of a single route. To minimize the surface of this BC layer, this uses + // route definitions that are as empty as possible, plus an outbound route + // processor. + // @see \Drupal\rest\RouteProcessor\RestResourceGetRouteProcessorBC + if ($method === 'GET' || $method === 'HEAD') { + foreach ($this->serializerFormats as $format_name) { + $collection->add("$route_name.$method.$format_name", (new BcRoute())->setRequirement('_format', $format_name)); + } } } diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 012942c..e61e59d 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -85,12 +85,43 @@ public static function create(ContainerInterface $container) { * The REST resource response. */ public function handle(RouteMatchInterface $route_match, Request $request) { - $resource_config_id = $route_match->getRouteObject()->getDefault('_rest_resource_config'); - /** @var \Drupal\rest\RestResourceConfigInterface $resource_config */ - $resource_config = $this->resourceStorage->load($resource_config_id); + $resource_config = $this->loadRestResourceConfigFromRouteMatch($route_match); + $resource = $resource_config->getResourcePlugin(); + $unserialized = $this->deserialize($route_match, $request, $resource); + $response = $this->delegateToRestResourcePlugin($route_match, $request, $unserialized, $resource); + return $this->prepareResponse($response, $resource_config); + } - $response = $this->delegateToRestResourcePlugin($route_match, $request, $resource_config->getResourcePlugin()); + /** + * 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. + * + * @return \Symfony\Component\HttpFoundation\Response|\Drupal\rest\ResourceResponseInterface + * The REST resource response. + */ + public function handleRaw(RouteMatchInterface $route_match, Request $request) { + $resource_config = $this->loadRestResourceConfigFromRouteMatch($route_match); + $resource = $resource_config->getResourcePlugin(); + $response = $this->delegateToRestResourcePlugin($route_match, $request, NULL, $resource); + return $this->prepareResponse($response, $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. @@ -104,6 +135,22 @@ public function handle(RouteMatchInterface $route_match, Request $request) { } /** + * Loads a REST resource config entity from the route match object. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match instance. + * + * @return \Drupal\rest\RestResourceConfigInterface + * The loaded REST resource config entity. + */ + protected function loadRestResourceConfigFromRouteMatch(RouteMatchInterface $route_match) { + $resource_config_id = $route_match->getRouteObject()->getDefault('_rest_resource_config'); + /** @var \Drupal\rest\RestResourceConfigInterface $resource_config */ + $resource_config = $this->resourceStorage->load($resource_config_id); + return $resource_config; + } + + /** * Gets the normalized HTTP request method of the matched route. * * @param \Drupal\Core\Routing\RouteMatchInterface $route_match @@ -195,14 +242,15 @@ protected function deserialize(RouteMatchInterface $route_match, Request $reques * 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, ResourceInterface $resource) { - $unserialized = $this->deserialize($route_match, $request, $resource); + 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 diff --git a/core/modules/rest/src/RouteProcessor/RestResourceGetRouteProcessorBC.php b/core/modules/rest/src/RouteProcessor/RestResourceGetRouteProcessorBC.php new file mode 100644 index 0000000..cf55326 --- /dev/null +++ b/core/modules/rest/src/RouteProcessor/RestResourceGetRouteProcessorBC.php @@ -0,0 +1,80 @@ +serializerFormats = $serializer_formats; + $this->routeProvider = $route_provider; + } + + /** + * {@inheritdoc} + */ + public function processOutbound($route_name, Route $route, array &$parameters, BubbleableMetadata $bubbleable_metadata = NULL) { + $route_name_parts = explode('.', $route_name); + // BC: the REST module originally created per-format GET routes, instead + // of a single route. To minimize the surface of this BC layer, this uses + // route definitions that are as empty as possible, plus an outbound route + // processor. + // @see \Drupal\rest\Plugin\ResourceBase::routes() + if ($route_name_parts[0] === 'rest' && $route_name_parts[count($route_name_parts) - 2] === 'GET' && in_array($route_name_parts[count($route_name_parts) - 1], $this->serializerFormats, TRUE)) { + array_pop($route_name_parts); + $redirected_route_name = implode('.', $route_name_parts); + @trigger_error(sprintf("The '%s' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the '%s' route instead.", $route_name, $redirected_route_name), E_USER_DEPRECATED); + static::overwriteRoute($route, $this->routeProvider->getRouteByName($redirected_route_name)); + } + } + + /** + * Overwrites one route's metadata with the other's. + * + * @param \Symfony\Component\Routing\Route $target_route + * The route whose metadata to overwrite. + * @param \Symfony\Component\Routing\Route $source_route + * The route whose metadata to read from. + * + * @see \Symfony\Component\Routing\Route + */ + protected static function overwriteRoute(Route $target_route, Route $source_route) { + $target_route->setPath($source_route->getPath()); + $target_route->setDefaults($source_route->getDefaults()); + $target_route->setRequirements($source_route->getRequirements()); + $target_route->setOptions($source_route->getOptions()); + $target_route->setHost($source_route->getHost()); + $target_route->setSchemes($source_route->getSchemes()); + $target_route->setMethods($source_route->getMethods()); + } + +} diff --git a/core/modules/rest/src/Routing/ResourceRoutes.php b/core/modules/rest/src/Routing/ResourceRoutes.php index 5ba4c5d..8b4f7e9 100644 --- a/core/modules/rest/src/Routing/ResourceRoutes.php +++ b/core/modules/rest/src/Routing/ResourceRoutes.php @@ -92,8 +92,11 @@ protected function getRoutesForResourceConfig(RestResourceConfigInterface $rest_ /** @var \Symfony\Component\Routing\Route $route */ // @todo: Are multiple methods possible here? $methods = $route->getMethods(); - // Only expose routes where the method is enabled in the configuration. - if ($methods && ($method = $methods[0]) && $supported_formats = $rest_resource_config->getFormats($method)) { + // Only expose routes + // - that have an explicit method and allow >=1 format for that method + // - that exist for BC + // @see \Drupal\rest\RouteProcessor\RestResourceGetRouteProcessorBC + if (($methods && ($method = $methods[0]) && $supported_formats = $rest_resource_config->getFormats($method)) || $route->hasOption('bc_route')) { $route->setRequirement('_csrf_request_header_token', 'TRUE'); // Check that authentication providers are defined. @@ -108,20 +111,24 @@ protected function getRoutesForResourceConfig(RestResourceConfigInterface $rest_ continue; } - // If the route has a format requirement, then verify that the - // resource has it. - $format_requirement = $route->getRequirement('_format'); - if ($format_requirement && !in_array($format_requirement, $rest_resource_config->getFormats($method))) { - continue; + // Remove BC routes for unsupported formats. + if ($route->getOption('bc_route') === TRUE) { + $format_requirement = $route->getRequirement('_format'); + if ($format_requirement && !in_array($format_requirement, $rest_resource_config->getFormats($method))) { + continue; + } } // The configuration has been validated, so we update the route to: + // - set the allowed response body content types/formats for methods + // that may send response bodies (unless hardcoded by the plugin) // - set the allowed request body content types/formats for methods that - // allow request bodies to be sent + // allow request bodies to be sent (unless hardcoded by the plugin) // - set the allowed authentication providers - if (in_array($method, ['POST', 'PATCH', 'PUT'], TRUE)) { - // Restrict the incoming HTTP Content-type header to the allowed - // formats. + if (in_array($method, ['GET', 'HEAD', 'POST', 'PUT', 'PATCH'], TRUE) && !$route->hasRequirement('_format')) { + $route->addRequirements(['_format' => implode('|', $rest_resource_config->getFormats($method))]); + } + if (in_array($method, ['POST', 'PATCH', 'PUT'], TRUE) && !$route->hasRequirement('_content_type_format')) { $route->addRequirements(['_content_type_format' => implode('|', $rest_resource_config->getFormats($method))]); } $route->setOption('_auth', $rest_resource_config->getAuthenticationProviders($method)); diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index 4c7947d..1ca4d52 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -21,6 +21,7 @@ use Drupal\Tests\rest\Functional\ResourceTestBase; use GuzzleHttp\RequestOptions; use Psr\Http\Message\ResponseInterface; +use Symfony\Component\Routing\Exception\RouteNotFoundException; /** * Even though there is the generic EntityResource, it's necessary for every @@ -639,15 +640,32 @@ public function testGet() { $this->assert406Response($response); $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); - $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . static::$format); + $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET'); $url->setRouteParameter(static::$entityTypeId, 987654321); $url->setOption('query', ['_format' => static::$format]); // DX: 404 when GETting non-existing entity. $response = $this->request('GET', $url, $request_options); $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString()); - $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET.' . static::$format . '")'; + $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET")'; $this->assertResourceErrorResponse(404, $message, $response); + + // BC: Format-specific GET routes are deprecated. They are available on both + // new and old sites, but trigger deprecation notices. + $bc_route = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . static::$format, $url->getRouteParameters(), $url->getOptions()); + $bc_route->setUrlGenerator($this->container->get('url_generator')); + $this->assertSame($url->toString(TRUE)->getGeneratedUrl(), $bc_route->toString(TRUE)->getGeneratedUrl()); + // Verify no format-specific GET BC routes are created for other formats. + $other_format = static::$format === 'json' ? 'xml' : 'json'; + $bc_route_other_format = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . $other_format, $url->getRouteParameters(), $url->getOptions()); + $bc_route_other_format->setUrlGenerator($this->container->get('url_generator')); + try { + $bc_route_other_format->toString(TRUE); + $this->assertTrue(FALSE); + } + catch (RouteNotFoundException $e) { + $this->assertTrue(TRUE); + } } /** @@ -675,27 +693,6 @@ protected static function castToString(array $normalization) { } /** - * Recursively sorts an array by key. - * - * @param array $array - * An array to sort. - * - * @return array - * The sorted array. - */ - protected static function recursiveKSort(array &$array) { - // First, sort the main array. - ksort($array); - - // Then check for child arrays. - foreach ($array as $key => &$value) { - if (is_array($value)) { - static::recursiveKSort($value); - } - } - } - - /** * Tests a POST request for an entity, plus edge cases to ensure good DX. */ public function testPost() { @@ -887,6 +884,7 @@ public function testPost() { if ($this->entity->getEntityType()->hasLinkTemplate('create')) { $this->entityStorage->load(static::$secondCreatedEntityId)->delete(); $old_url = Url::fromUri('base:entity/' . static::$entityTypeId); + $old_url->setOption('query', ['_format' => static::$format]); $response = $this->request('POST', $old_url, $request_options); $this->assertResourceResponse(201, FALSE, $response); } diff --git a/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php b/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php new file mode 100644 index 0000000..9fc87a3 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php @@ -0,0 +1,637 @@ +fieldStorage = FieldStorageConfig::create([ + 'entity_type' => 'entity_test', + 'field_name' => 'field_rest_file_test', + 'type' => 'file', + 'settings' => [ + 'uri_scheme' => 'public', + ], + ]) + ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + $this->fieldStorage->save(); + + $this->field = FieldConfig::create([ + 'entity_type' => 'entity_test', + 'field_name' => 'field_rest_file_test', + 'bundle' => 'entity_test', + 'settings' => [ + 'file_directory' => 'foobar', + 'file_extensions' => 'txt', + 'max_filesize' => '', + ], + ]) + ->setLabel('Test file field') + ->setTranslatable(FALSE); + $this->field->save(); + + // Create an entity that a file can be attached to. + $this->entity = EntityTest::create([ + 'name' => 'Llama', + 'type' => 'entity_test', + ]); + $this->entity->setOwnerId(isset($this->account) ? $this->account->id() : 0); + $this->entity->save(); + + // Provision entity_test resource. + $this->resourceConfigStorage->create([ + 'id' => 'entity.entity_test', + 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, + 'configuration' => [ + 'methods' => ['POST'], + 'formats' => [static::$format], + 'authentication' => [static::$auth], + ], + 'status' => TRUE, + ])->save(); + + $this->refreshTestStateAfterRestConfigChange(); + } + + /** + * Tests using the file upload POST route. + */ + public function testPostFileUpload() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $uri = Url::fromUri('base:' . static::$postUri); + + // DX: 403 when unauthorized. + $response = $this->fileRequest($uri, $this->testFileData); + $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response); + + $this->setUpAuthorization('POST'); + + // This request will have the default 'application/octet-stream' content + // type header. + $response = $this->fileRequest($uri, $this->testFileData); + $this->assertSame(201, $response->getStatusCode()); + $expected = $this->getExpectedNormalizedEntity(); + $this->assertResponseData($expected, $response); + + // Check the actual file data. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt')); + + // Test the file again but using 'filename' in the Content-Disposition + // header with no 'file' prefix. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']); + $this->assertSame(201, $response->getStatusCode()); + $expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt'); + $this->assertResponseData($expected, $response); + + // Check the actual file data. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt')); + + // Verify that we can create an entity that references the uploaded file. + $entity_test_post_url = Url::fromRoute('rest.entity.entity_test.POST') + ->setOption('query', ['_format' => static::$format]); + $request_options = []; + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST')); + + $request_options[RequestOptions::BODY] = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format); + $response = $this->request('POST', $entity_test_post_url, $request_options); + $this->assertResourceResponse(201, FALSE, $response); + // @todo Remove this early return in https://www.drupal.org/project/drupal/issues/2935738. + if (static::$format === 'hal_json') { + return; + } + $this->assertSame([ + [ + 'target_id' => '1', + 'display' => NULL, + 'description' => "The most fascinating file ever!", + ], + ], EntityTest::load(2)->get('field_rest_file_test')->getValue()); + } + + /** + * Returns the normalized POST entity referencing the uploaded file. + * + * @return array + * + * @see ::testPostFileUpload() + * @see \Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase::getNormalizedPostEntity() + */ + protected function getNormalizedPostEntity() { + return [ + 'type' => [ + [ + 'value' => 'entity_test', + ], + ], + 'name' => [ + [ + 'value' => 'Dramallama', + ], + ], + 'field_rest_file_test' => [ + [ + 'target_id' => 1, + 'description' => 'The most fascinating file ever!', + ], + ], + ]; + } + + /** + * Tests using the file upload POST route with invalid headers. + */ + public function testPostFileUploadInvalidHeaders() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // The wrong content type header should return a 415 code. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Type' => static::$mimeType]); + $this->assertResourceErrorResponse(415, sprintf('No route found that matches "Content-Type: %s"', static::$mimeType), $response); + + // An empty Content-Disposition header should return a 400. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => '']); + $this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided', $response); + + // An empty filename with a context in the Content-Disposition header should + // return a 400. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename=""']); + $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response); + + // An empty filename without a context in the Content-Disposition header + // should return a 400. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename=""']); + $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response); + + // An invalid key-value pair in the Content-Disposition header should return + // a 400. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'not_a_filename="example.txt"']); + $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response); + + // Using filename* extended format is not currently supported. + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename*="UTF-8 \' \' example.txt"']); + $this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition header"', $response); + } + + /** + * Tests using the file upload POST route with a duplicate file name. + * + * A new file should be created with a suffixed name. + */ + public function testPostFileUploadDuplicateFile() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // This request will have the default 'application/octet-stream' content + // type header. + $response = $this->fileRequest($uri, $this->testFileData); + + $this->assertSame(201, $response->getStatusCode()); + + // Make the same request again. The file should be saved as a new file + // entity that has the same file name but a suffixed file URI. + $response = $this->fileRequest($uri, $this->testFileData); + $this->assertSame(201, $response->getStatusCode()); + + // Loading expected normalized data for file 2, the duplicate file. + $expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt'); + $this->assertResponseData($expected, $response); + + // Check the actual file data. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt')); + } + + /** + * Tests using the file upload route with any path prefixes being stripped. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Directives + */ + public function testFileUploadStrippedFilePath() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="directory/example.txt"']); + $this->assertSame(201, $response->getStatusCode()); + $expected = $this->getExpectedNormalizedEntity(); + $this->assertResponseData($expected, $response); + + // Check the actual file data. It should have been written to the configured + // directory, not /foobar/directory/example.txt. + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt')); + } + + /** + * Tests using the file upload route with a unicode file name. + */ + public function testFileUploadUnicodeFilename() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="example-✓.txt"']); + $this->assertSame(201, $response->getStatusCode()); + $expected = $this->getExpectedNormalizedEntity(1, 'example-✓.txt', TRUE); + $this->assertResponseData($expected, $response); + $this->assertSame($this->testFileData, file_get_contents('public://foobar/example-✓.txt')); + } + + /** + * Tests using the file upload route with a zero byte file. + */ + public function testFileUploadZeroByteFile() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // Test with a zero byte file. + $response = $this->fileRequest($uri, NULL); + $this->assertSame(201, $response->getStatusCode()); + $expected = $this->getExpectedNormalizedEntity(); + // Modify the default expected data to account for the 0 byte file. + $expected['filesize'][0]['value'] = 0; + $this->assertResponseData($expected, $response); + + // Check the actual file data. + $this->assertSame('', file_get_contents('public://foobar/example.txt')); + } + + /** + * Tests using the file upload route with an invalid file type. + */ + public function testFileUploadInvalidFileType() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // Test with a JSON file. + $response = $this->fileRequest($uri, '{"test":123}', ['Content-Disposition' => 'filename="example.json"']); + $this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nOnly files with the following extensions are allowed: txt."), $response); + + // Make sure that no file was saved. + $this->assertEmpty(File::load(1)); + $this->assertFalse(file_exists('public://foobar/example.txt')); + } + + /** + * Tests using the file upload route with a file size larger than allowed. + */ + public function testFileUploadLargerFileSize() { + // Set a limit of 50 bytes. + $this->field->setSetting('max_filesize', 50) + ->save(); + $this->refreshTestStateAfterRestConfigChange(); + + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + // Generate a string larger than the 50 byte limit set. + $response = $this->fileRequest($uri, $this->randomString(100)); + $this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nThe file is 100 bytes exceeding the maximum file size of 50 bytes."), $response); + + // Make sure that no file was saved. + $this->assertEmpty(File::load(1)); + $this->assertFalse(file_exists('public://foobar/example.txt')); + } + + /** + * Tests using the file upload POST route with malicious extensions. + */ + public function testFileUploadMaliciousExtension() { + $this->initAuthentication(); + + $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']); + + $this->setUpAuthorization('POST'); + + $uri = Url::fromUri('base:' . static::$postUri); + + $php_string = ''; + + // Test using a masked exploit file. + $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php.txt"']); + $expected = $this->getExpectedNormalizedEntity(1, 'example.php_.txt', TRUE); + // Override the expected filesize. + $expected['filesize'][0]['value'] = strlen($php_string); + $this->assertResponseData($expected, $response); + $this->assertTrue(file_exists('public://foobar/example.php_.txt')); + + // Add php as an allowed format. Allow insecure uploads still being FALSE + // should still not allow this. So it should still have a .txt extension + // appended. + $this->field->setSetting('file_extensions', 'txt php') + ->save(); + $this->refreshTestStateAfterRestConfigChange(); + + $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']); + $expected = $this->getExpectedNormalizedEntity(2, 'example.php.txt', TRUE); + // Override the expected filesize. + $expected['filesize'][0]['value'] = strlen($php_string); + $this->assertResponseData($expected, $response); + + // Now allow insecure uploads. + \Drupal::configFactory() + ->getEditable('system.file') + ->set('allow_insecure_uploads', TRUE) + ->save(); + + $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']); + $expected = $this->getExpectedNormalizedEntity(3, 'example.php', TRUE); + // Override the expected filesize. + $expected['filesize'][0]['value'] = strlen($php_string); + // The file mime should also now be PHP. + $expected['filemime'][0]['value'] = 'application/x-httpd-php'; + $this->assertResponseData($expected, $response); + } + + /** + * {@inheritdoc} + */ + protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) { + // The file upload resource only accepts binary data, so there are no + // normalization edge cases to test, as there are no normalized entity + // representations incoming. + } + + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessMessage($method) { + return "The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create entity_test_with_bundle entities'."; + } + + /** + * Gets the expected file entity. + * + * @param int $fid + * The file ID to load and create normalized data for. + * @param string $expected_filename + * The expected filename for the stored file. + * @param bool $expected_as_filename + * Whether the expected filename should be the filename property too. + * + * @return array + * The expected normalized data array. + */ + protected function getExpectedNormalizedEntity($fid = 1, $expected_filename = 'example.txt', $expected_as_filename = FALSE) { + $author = User::load(static::$auth ? $this->account->id() : 0); + $file = File::load($fid); + + $expected_normalization = [ + 'fid' => [ + [ + 'value' => (int) $file->id(), + ], + ], + 'uuid' => [ + [ + 'value' => $file->uuid(), + ], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'uid' => [ + [ + 'target_id' => (int) $author->id(), + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => base_path() . 'user/' . $author->id(), + ], + ], + 'filename' => [ + [ + 'value' => $expected_as_filename ? $expected_filename : 'example.txt', + ], + ], + 'uri' => [ + [ + 'value' => 'public://foobar/' . $expected_filename, + 'url' => base_path() . $this->siteDirectory . '/files/foobar/' . rawurlencode($expected_filename), + ], + ], + 'filemime' => [ + [ + 'value' => 'text/plain', + ], + ], + 'filesize' => [ + [ + 'value' => strlen($this->testFileData), + ], + ], + 'status' => [ + [ + 'value' => FALSE, + ], + ], + 'created' => [ + $this->formatExpectedTimestampItemValues($file->getCreatedTime()), + ], + 'changed' => [ + $this->formatExpectedTimestampItemValues($file->getChangedTime()), + ], + ]; + + return $expected_normalization; + } + + /** + * Performs a file upload request. Wraps the Guzzle HTTP client. + * + * @see \GuzzleHttp\ClientInterface::request() + * + * @param \Drupal\Core\Url $url + * URL to request. + * @param string $file_contents + * The file contents to send as the request body. + * @param array $headers + * Additional headers to send with the request. Defaults will be added for + * Content-Type and Content-Disposition. + * + * @return \Psr\Http\Message\ResponseInterface + */ + protected function fileRequest(Url $url, $file_contents, array $headers = []) { + // Set the format for the response. + $url->setOption('query', ['_format' => static::$format]); + + $request_options = []; + $request_options[RequestOptions::HTTP_ERRORS] = FALSE; + + $request_options['headers'] = $headers + [ + // Set the required (and only accepted) content type for the request. + 'Content-Type' => 'application/octet-stream', + // Set the required Content-Disposition header for the file name. + 'Content-Disposition' => 'file; filename="example.txt"', + ]; + + $request_options['body'] = $file_contents; + + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST')); + + $session = $this->getSession(); + $this->prepareRequest(); + + $client = $session->getDriver()->getClient()->getClient(); + return $client->request('POST', $url->setAbsolute(TRUE)->toString(), $request_options); + } + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['view test entity']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities', 'access content']); + break; + } + } + + /** + * Asserts expected normalized data matches response data. + * + * @param array $expected + * The expected data. + * @param \Psr\Http\Message\ResponseInterface $response + * The file upload response. + */ + protected function assertResponseData(array $expected, ResponseInterface $response) { + static::recursiveKSort($expected); + $actual = $this->serializer->decode((string) $response->getBody(), static::$format); + static::recursiveKSort($actual); + + $this->assertSame($expected, $actual); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessCacheability() { + // There is cacheability metadata to check as file uploads only allows POST + // requests, which will not return cacheable responses. + } + +} diff --git a/core/modules/rest/tests/src/Functional/ResourceTestBase.php b/core/modules/rest/tests/src/Functional/ResourceTestBase.php index 8283bef..a23a567 100644 --- a/core/modules/rest/tests/src/Functional/ResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php @@ -144,12 +144,12 @@ public function setUp() { * @param string[] $authentication * The allowed authentication providers for this resource. */ - protected function provisionResource($formats = [], $authentication = []) { + protected function provisionResource($formats = [], $authentication = [], array $methods = ['GET', 'POST', 'PATCH', 'DELETE']) { $this->resourceConfigStorage->create([ 'id' => static::$resourceConfigId, 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, 'configuration' => [ - 'methods' => ['GET', 'POST', 'PATCH', 'DELETE'], + 'methods' => $methods, 'formats' => $formats, 'authentication' => $authentication, ], @@ -481,4 +481,25 @@ protected function decorateWithXdebugCookie(array $request_options) { return $request_options; } + /** + * Recursively sorts an array by key. + * + * @param array $array + * An array to sort. + * + * @return array + * The sorted array. + */ + protected static function recursiveKSort(array &$array) { + // First, sort the main array. + ksort($array); + + // Then check for child arrays. + foreach ($array as $key => &$value) { + if (is_array($value)) { + static::recursiveKSort($value); + } + } + } + } diff --git a/core/modules/user/src/Tests/RestRegisterUserTest.php b/core/modules/user/src/Tests/RestRegisterUserTest.php index 450c552..31bcce4 100644 --- a/core/modules/user/src/Tests/RestRegisterUserTest.php +++ b/core/modules/user/src/Tests/RestRegisterUserTest.php @@ -2,6 +2,7 @@ namespace Drupal\user\Tests; +use Drupal\Core\Url; use Drupal\rest\Tests\RESTTestBase; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; @@ -166,7 +167,7 @@ protected function registerUser($name, $include_password = TRUE) { */ protected function registerRequest($name, $include_password = TRUE) { $serialized = $this->createSerializedUser($name, $include_password); - $this->httpRequest('/user/register', 'POST', $serialized, 'application/hal+json'); + $this->httpRequest(Url::fromRoute('rest.user_registration.POST', ['_format' => 'hal_json']), 'POST', $serialized, 'application/hal+json'); } } diff --git a/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php b/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php index 2eecec9..027ead6 100644 --- a/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php +++ b/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php @@ -141,6 +141,144 @@ public static function getSkippedDeprecations() { 'Drupal\node\Plugin\Action\PublishNode is deprecated in Drupal 8.5.x, will be removed before Drupal 9.0.0. Use \Drupal\Core\Action\Plugin\Action\PublishAction instead. See https://www.drupal.org/node/2919303.', 'Drupal\node\Plugin\Action\SaveNode is deprecated in Drupal 8.5.x, will be removed before Drupal 9.0.0. Use \Drupal\Core\Action\Plugin\Action\SaveAction instead. See https://www.drupal.org/node/2919303.', 'Drupal\node\Plugin\Action\UnpublishNode is deprecated in Drupal 8.5.x, will be removed before Drupal 9.0.0. Use \Drupal\Core\Action\Plugin\Action\UnpublishAction instead. See https://www.drupal.org/node/2919303.', + "The 'rest.entity.entity_test.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test.GET' route instead.", + "The 'rest.entity.entity_test.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test.GET' route instead.", + "The 'rest.entity.entity_test.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test.GET' route instead.", + "The 'rest.entity.action.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.action.GET' route instead.", + "The 'rest.entity.action.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.action.GET' route instead.", + "The 'rest.entity.action.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.action.GET' route instead.", + "The 'rest.entity.base_field_override.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.base_field_override.GET' route instead.", + "The 'rest.entity.base_field_override.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.base_field_override.GET' route instead.", + "The 'rest.entity.base_field_override.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.base_field_override.GET' route instead.", + "The 'rest.entity.block_content_type.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block_content_type.GET' route instead.", + "The 'rest.entity.block_content_type.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block_content_type.GET' route instead.", + "The 'rest.entity.block_content_type.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block_content_type.GET' route instead.", + "The 'rest.entity.block_content.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block_content.GET' route instead.", + "The 'rest.entity.block_content.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block_content.GET' route instead.", + "The 'rest.entity.block_content.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block_content.GET' route instead.", + "The 'rest.entity.block.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block.GET' route instead.", + "The 'rest.entity.block.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block.GET' route instead.", + "The 'rest.entity.block.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.block.GET' route instead.", + "The 'rest.entity.comment_type.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.comment_type.GET' route instead.", + "The 'rest.entity.comment_type.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.comment_type.GET' route instead.", + "The 'rest.entity.comment_type.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.comment_type.GET' route instead.", + "The 'rest.entity.comment.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.comment.GET' route instead.", + "The 'rest.entity.comment.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.comment.GET' route instead.", + "The 'rest.entity.comment.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.comment.GET' route instead.", + "The 'rest.entity.config_test.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.config_test.GET' route instead.", + "The 'rest.entity.config_test.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.config_test.GET' route instead.", + "The 'rest.entity.config_test.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.config_test.GET' route instead.", + "The 'rest.entity.configurable_language.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.configurable_language.GET' route instead.", + "The 'rest.entity.configurable_language.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.configurable_language.GET' route instead.", + "The 'rest.entity.configurable_language.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.configurable_language.GET' route instead.", + "The 'rest.entity.contact_form.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.contact_form.GET' route instead.", + "The 'rest.entity.contact_form.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.contact_form.GET' route instead.", + "The 'rest.entity.contact_form.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.contact_form.GET' route instead.", + "The 'rest.entity.language_content_settings.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.language_content_settings.GET' route instead.", + "The 'rest.entity.language_content_settings.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.language_content_settings.GET' route instead.", + "The 'rest.entity.language_content_settings.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.language_content_settings.GET' route instead.", + "The 'rest.entity.date_format.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.date_format.GET' route instead.", + "The 'rest.entity.date_format.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.date_format.GET' route instead.", + "The 'rest.entity.date_format.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.date_format.GET' route instead.", + "The 'rest.entity.editor.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.editor.GET' route instead.", + "The 'rest.entity.editor.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.editor.GET' route instead.", + "The 'rest.entity.editor.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.editor.GET' route instead.", + "The 'rest.entity.entity_form_display.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_form_display.GET' route instead.", + "The 'rest.entity.entity_form_display.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_form_display.GET' route instead.", + "The 'rest.entity.entity_form_display.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_form_display.GET' route instead.", + "The 'rest.entity.entity_form_mode.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_form_mode.GET' route instead.", + "The 'rest.entity.entity_form_mode.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_form_mode.GET' route instead.", + "The 'rest.entity.entity_form_mode.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_form_mode.GET' route instead.", + "The 'rest.entity.entity_test_bundle.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test_bundle.GET' route instead.", + "The 'rest.entity.entity_test_bundle.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test_bundle.GET' route instead.", + "The 'rest.entity.entity_test_bundle.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test_bundle.GET' route instead.", + "The 'rest.entity.entity_test_label.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test_label.GET' route instead.", + "The 'rest.entity.entity_test_label.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test_label.GET' route instead.", + "The 'rest.entity.entity_test_label.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_test_label.GET' route instead.", + "The 'rest.entity.entity_view_display.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_view_display.GET' route instead.", + "The 'rest.entity.entity_view_display.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_view_display.GET' route instead.", + "The 'rest.entity.entity_view_display.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_view_display.GET' route instead.", + "The 'rest.entity.entity_view_mode.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_view_mode.GET' route instead.", + "The 'rest.entity.entity_view_mode.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_view_mode.GET' route instead.", + "The 'rest.entity.entity_view_mode.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.entity_view_mode.GET' route instead.", + "The 'rest.entity.aggregator_feed.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.aggregator_feed.GET' route instead.", + "The 'rest.entity.aggregator_feed.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.aggregator_feed.GET' route instead.", + "The 'rest.entity.aggregator_feed.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.aggregator_feed.GET' route instead.", + "The 'rest.entity.field_config.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.field_config.GET' route instead.", + "The 'rest.entity.field_config.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.field_config.GET' route instead.", + "The 'rest.entity.field_config.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.field_config.GET' route instead.", + "The 'rest.entity.field_storage_config.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.field_storage_config.GET' route instead.", + "The 'rest.entity.field_storage_config.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.field_storage_config.GET' route instead.", + "The 'rest.entity.field_storage_config.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.field_storage_config.GET' route instead.", + "The 'rest.entity.file.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.file.GET' route instead.", + "The 'rest.entity.file.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.file.GET' route instead.", + "The 'rest.entity.file.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.file.GET' route instead.", + "The 'rest.entity.filter_format.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.filter_format.GET' route instead.", + "The 'rest.entity.filter_format.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.filter_format.GET' route instead.", + "The 'rest.entity.filter_format.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.filter_format.GET' route instead.", + "The 'rest.entity.image_style.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.image_style.GET' route instead.", + "The 'rest.entity.image_style.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.image_style.GET' route instead.", + "The 'rest.entity.image_style.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.image_style.GET' route instead.", + "The 'rest.entity.aggregator_item.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.aggregator_item.GET' route instead.", + "The 'rest.entity.aggregator_item.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.aggregator_item.GET' route instead.", + "The 'rest.entity.aggregator_item.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.aggregator_item.GET' route instead.", + "The 'rest.entity.media_type.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.media_type.GET' route instead.", + "The 'rest.entity.media_type.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.media_type.GET' route instead.", + "The 'rest.entity.media_type.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.media_type.GET' route instead.", + "The 'rest.entity.media.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.media.GET' route instead.", + "The 'rest.entity.media.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.media.GET' route instead.", + "The 'rest.entity.media.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.media.GET' route instead.", + "The 'rest.entity.menu_link_content.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.menu_link_content.GET' route instead.", + "The 'rest.entity.menu_link_content.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.menu_link_content.GET' route instead.", + "The 'rest.entity.menu_link_content.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.menu_link_content.GET' route instead.", + "The 'rest.entity.menu.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.menu.GET' route instead.", + "The 'rest.entity.menu.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.menu.GET' route instead.", + "The 'rest.entity.menu.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.menu.GET' route instead.", + "The 'rest.entity.node_type.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.node_type.GET' route instead.", + "The 'rest.entity.node_type.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.node_type.GET' route instead.", + "The 'rest.entity.node_type.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.node_type.GET' route instead.", + "The 'rest.entity.node.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.node.GET' route instead.", + "The 'rest.entity.node.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.node.GET' route instead.", + "The 'rest.entity.node.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.node.GET' route instead.", + "The 'rest.entity.rdf_mapping.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.rdf_mapping.GET' route instead.", + "The 'rest.entity.rdf_mapping.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.rdf_mapping.GET' route instead.", + "The 'rest.entity.rdf_mapping.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.rdf_mapping.GET' route instead.", + "The 'rest.entity.responsive_image_style.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.responsive_image_style.GET' route instead.", + "The 'rest.entity.responsive_image_style.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.responsive_image_style.GET' route instead.", + "The 'rest.entity.responsive_image_style.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.responsive_image_style.GET' route instead.", + "The 'rest.entity.rest_resource_config.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.rest_resource_config.GET' route instead.", + "The 'rest.entity.rest_resource_config.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.rest_resource_config.GET' route instead.", + "The 'rest.entity.rest_resource_config.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.rest_resource_config.GET' route instead.", + "The 'rest.entity.user_role.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.user_role.GET' route instead.", + "The 'rest.entity.user_role.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.user_role.GET' route instead.", + "The 'rest.entity.user_role.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.user_role.GET' route instead.", + "The 'rest.entity.search_page.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.search_page.GET' route instead.", + "The 'rest.entity.search_page.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.search_page.GET' route instead.", + "The 'rest.entity.search_page.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.search_page.GET' route instead.", + "The 'rest.entity.shortcut_set.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.shortcut_set.GET' route instead.", + "The 'rest.entity.shortcut_set.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.shortcut_set.GET' route instead.", + "The 'rest.entity.shortcut_set.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.shortcut_set.GET' route instead.", + "The 'rest.entity.shortcut.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.shortcut.GET' route instead.", + "The 'rest.entity.shortcut.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.shortcut.GET' route instead.", + "The 'rest.entity.shortcut.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.shortcut.GET' route instead.", + "The 'rest.entity.taxonomy_term.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.taxonomy_term.GET' route instead.", + "The 'rest.entity.taxonomy_term.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.taxonomy_term.GET' route instead.", + "The 'rest.entity.taxonomy_term.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.taxonomy_term.GET' route instead.", + "The 'rest.entity.tour.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.tour.GET' route instead.", + "The 'rest.entity.tour.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.tour.GET' route instead.", + "The 'rest.entity.tour.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.tour.GET' route instead.", + "The 'rest.entity.user.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.user.GET' route instead.", + "The 'rest.entity.user.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.user.GET' route instead.", + "The 'rest.entity.user.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.user.GET' route instead.", + "The 'rest.entity.view.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.view.GET' route instead.", + "The 'rest.entity.view.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.view.GET' route instead.", + "The 'rest.entity.view.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.view.GET' route instead.", + "The 'rest.entity.taxonomy_vocabulary.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.taxonomy_vocabulary.GET' route instead.", + "The 'rest.entity.taxonomy_vocabulary.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.taxonomy_vocabulary.GET' route instead.", + "The 'rest.entity.taxonomy_vocabulary.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.taxonomy_vocabulary.GET' route instead.", + "The 'rest.entity.workflow.GET.json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.workflow.GET' route instead.", + "The 'rest.entity.workflow.GET.xml' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.workflow.GET' route instead.", + "The 'rest.entity.workflow.GET.hal_json' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the 'rest.entity.workflow.GET' route instead.", ]; }