diff --git a/core/modules/file/src/FileServiceProvider.php b/core/modules/file/src/FileServiceProvider.php new file mode 100644 index 0000000..35dd4a5 --- /dev/null +++ b/core/modules/file/src/FileServiceProvider.php @@ -0,0 +1,24 @@ +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..04b6712 --- /dev/null +++ b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php @@ -0,0 +1,564 @@ +\*?)=\"(?.+)\"@'; + + /** + * 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); + + $access_result = $entity_access_control_handler->createAccess($bundle, 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. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * + * @return array + * An array suitable for passing to file_save_upload() or the file field + * element's '#upload_validators' property. + * + * 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. + */ + 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'; + // Allow all serializer formats to be returned as a response format. + $requirements['_format'] = implode('|', $this->serializerFormats); + + 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(), + ], + ], + ], + ], + ], + ]; + } + + +} 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/src/EventSubscriber/ResourceResponseSubscriber.php b/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php index babb238..9fbf385 100644 --- a/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php +++ b/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php @@ -89,7 +89,7 @@ public function onResponse(FilterResponseEvent $event) { * Determines the format to respond in. * * Respects the requested format if one is specified. However, it is common to - * forget to specify a response format in case of a POST or PATCH. Rather than + * forget to specify a request format in case of a POST or PATCH. Rather than * simply throwing an error, we apply the robustness principle: when POSTing * or PATCHing using a certain format, you probably expect a response in that * same format. @@ -119,9 +119,6 @@ public function getResponseFormat(RouteMatchInterface $route_match, Request $req return $requested_format; } - // If a request body is present, then use the format corresponding to the - // request body's Content-Type for the response, if it's an acceptable - // format for the request. if (!empty($request->getContent()) && in_array($content_type_format, $acceptable_request_formats, TRUE)) { return $content_type_format; } diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 60d230e..512c651 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\rest\Plugin\ResourceInterface; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerAwareTrait; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -76,6 +77,77 @@ public static function create(ContainerInterface $container) { * The response object. */ public function handle(RouteMatchInterface $route_match, Request $request) { + $method = $this->getRequestMethod($route_match); + + $resource_config = $this->loadRestResourceConfigFromRouteMatch($route_match); + $resource = $resource_config->getResourcePlugin(); + + // Deserialize incoming data if available. + $unserialized = $this->denormalizeRequestData($request, $resource, $method); + + // 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. + $response = call_user_func_array([$resource, $method], $arguments); + + return $this->prepareResponse($response, $resource_config); + } + + /** + * Handles a web API request without deserializing the request content. + * + * @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 + * The response object. + */ + public function handleRaw(RouteMatchInterface $route_match, Request $request) { + $method = $this->getRequestMethod($route_match); + + $resource_config = $this->loadRestResourceConfigFromRouteMatch($route_match); + $resource = $resource_config->getResourcePlugin(); + + // Determine the request parameters that should be passed to the resource + // plugin. + $argument_resolver = $this->createArgumentResolver($route_match, NULL, $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, NULL, $request); + } + + // Invoke the operation on the resource plugin. + $response = call_user_func_array([$resource, $method], $arguments); + + return $this->prepareResponse($response, $resource_config); + } + + /** + * Gets the request method from the route match. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match interface. + * + * @return string + * The request method. + */ + protected function getRequestMethod(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 @@ -90,17 +162,47 @@ public function handle(RouteMatchInterface $route_match, Request $request) { $method = strtolower($route_match->getRouteObject()->getMethods()[0]); assert(count($route_match->getRouteObject()->getMethods()) === 1); + return $method; + } + + /** + * Loads a REST resource plugin from the route match object. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match instance. + * + * @return \Drupal\rest\RestResourceConfigInterface + * The loaded REST config. + */ + 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); - $resource = $resource_config->getResourcePlugin(); + return $this->resourceStorage->load($resource_config_id); + } - // Deserialize incoming data if available. - /** @var \Symfony\Component\Serializer\SerializerInterface $serializer */ - $serializer = $this->container->get('serializer'); + /** + * Deserializes and denormalizes request content. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * @param \Drupal\rest\Plugin\ResourceInterface $resource + * The REST resource plugin. + * @param string $method + * The request method. + * + * @return mixed|NULL + * The denormalized \Drupal\Core\Entity\EntityInterface object if there is a + * serialization class, an array of decoded data, or NULL if there is no + * request content. + * + */ + protected function denormalizeRequestData(Request $request, ResourceInterface $resource, $method) { $received = $request->getContent(); $unserialized = NULL; + if (!empty($received)) { + /** @var \Symfony\Component\Serializer\SerializerInterface $serializer */ + $serializer = $this->container->get('serializer'); + $format = $request->getContentType(); $definition = $resource->getPluginDefinition(); @@ -132,20 +234,20 @@ public function handle(RouteMatchInterface $route_match, Request $request) { } } - // 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. - $response = call_user_func_array([$resource, $method], $arguments); + return $unserialized; + } + /** + * Prepares the REST response. + * + * @param \Drupal\rest\ResourceResponseInterface $response + * The REST resource response. + * @param \Drupal\rest\RestResourceConfigInterface $resource_config + * The REST resource config entity. + * + * @return $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. diff --git a/core/modules/rest/src/Routing/ResourceRoutes.php b/core/modules/rest/src/Routing/ResourceRoutes.php index 5ba4c5d..c5e1fa0 100644 --- a/core/modules/rest/src/Routing/ResourceRoutes.php +++ b/core/modules/rest/src/Routing/ResourceRoutes.php @@ -108,20 +108,12 @@ 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; - } - // The configuration has been validated, so we update the route to: // - set the allowed request body content types/formats for methods that // allow request bodies to be sent // - set the allowed authentication providers - if (in_array($method, ['POST', 'PATCH', 'PUT'], TRUE)) { - // Restrict the incoming HTTP Content-type header to the allowed - // formats. + // @todo clean this up further in https://www.drupal.org/node/2858482 + 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..e15fabe 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -675,27 +675,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() { 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..88a9510 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php @@ -0,0 +1,600 @@ +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(); + + $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')); + } + + /** + * 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. + */ + 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); + + // 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 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 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']); + 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); + } + } + } + }