diff --git a/modules/jsonapi_file_upload/src/Controller/RequestHandler.php b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php index bd2603d..831f70d 100644 --- a/modules/jsonapi_file_upload/src/Controller/RequestHandler.php +++ b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php @@ -4,7 +4,10 @@ namespace Drupal\jsonapi_file_upload\Controller; use Drupal\Core\Access\AccessResultReasonInterface; use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\jsonapi\Entity\EntityValidationTrait; use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel; use Drupal\jsonapi\ResourceResponse; use Drupal\jsonapi\ResourceType\ResourceType; @@ -22,6 +25,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; */ class RequestHandler { + use EntityValidationTrait; + /** * The current user making the request. * @@ -66,29 +71,54 @@ class RequestHandler { * The HTTP request object. * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type * The JSON API resource type for the current request. + * @param string $file_field_name + * The file field for which the file is to be uploaded. + * @param \Drupal\Core\Entity\FieldableEntityInterface $entity + * The entity for which the file is to be uploaded. * * @return \Drupal\jsonapi\ResourceResponse * The response object. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * Thrown if the upload's target resource could not be saved. */ - public function handle(Request $request, ResourceType $resource_type) { + public function handleFileUploadForExistingResource(Request $request, ResourceType $resource_type, $file_field_name, FieldableEntityInterface $entity) { + $field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name); + + static::ensureFileUploadAccess($this->currentUser, $field_definition, $entity); + $filename = FileUploader::validateAndParseContentDispositionHeader($request); + $stream = FileUploader::getUploadStream(); + $file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $stream, $this->currentUser); + fclose($stream); - /** @var \Drupal\Core\Entity\EntityInterface $entity */ - $entity = $request->get($resource_type->getEntityTypeId()); + $entity->get($file_field_name)->appendItem($file); + static::validate($entity); + $entity->save(); - $field_name = $request->get('file_field_name'); + // @todo: update this response to match that of EntityResource::getRelated() + return new ResourceResponse(new JsonApiDocumentTopLevel($file), '200', []); + } - $field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $field_name); + /** + * Handles JSON API file upload requests. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The HTTP request object. + * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type + * The JSON API resource type for the current request. + * @param string $file_field_name + * The file field for which the file is to be uploaded. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response object. + */ + public function handleFileUploadForNewResource(Request $request, ResourceType $resource_type, $file_field_name) { + $field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name); - $access_result = FileUploader::checkFileUploadAccess($this->currentUser, $field_definition, $entity); - if (!$access_result->isAllowed()) { - $reason = 'Access Denied'; - if ($access_result instanceof AccessResultReasonInterface) { - $reason = $access_result->getReason() ?: $reason; - } - throw new AccessDeniedHttpException($reason); - } + static::ensureFileUploadAccess($this->currentUser, $field_definition); + $filename = FileUploader::validateAndParseContentDispositionHeader($request); $stream = FileUploader::getUploadStream(); $file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $stream, $this->currentUser); fclose($stream); @@ -96,6 +126,29 @@ class RequestHandler { return new ResourceResponse(new JsonApiDocumentTopLevel($file), '200', []); } + /** + * Ensures that the given account is allowed to upload a file. + * + * @param \Drupal\Core\Session\AccountInterface $account + * The account for which access should be checked. + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The field for which the file is to be uploaded. + * @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity + * The entity, if one exists, for which the file is to be uploaded. + */ + protected static function ensureFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, FieldableEntityInterface $entity = NULL) { + $access_result = $entity + ? FileUploader::checkFileUploadAccess($account, $field_definition, $entity) + : FileUploader::checkFileUploadAccess($account, $field_definition); + if (!$access_result->isAllowed()) { + $reason = 'The current user is not permitted to upload a file for this field.'; + if ($access_result instanceof AccessResultReasonInterface) { + $reason .= ' ' . $access_result->getReason(); + } + throw new AccessDeniedHttpException($reason); + } + } + /** * Validates and loads a field definition instance. * diff --git a/modules/jsonapi_file_upload/src/Routing/Routes.php b/modules/jsonapi_file_upload/src/Routing/Routes.php index 2594c18..16aa55b 100644 --- a/modules/jsonapi_file_upload/src/Routing/Routes.php +++ b/modules/jsonapi_file_upload/src/Routing/Routes.php @@ -10,8 +10,7 @@ use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; -use Drupal\jsonapi\Routing\Routes as JsonapiRoutes; - +use Drupal\jsonapi\Routing\Routes as JsonApiRoutes; /** * Defines dynamic routes. @@ -75,19 +74,18 @@ class Routes implements ContainerInjectionInterface { $routes = new RouteCollection(); foreach ($this->resourceTypeRepository->all() as $resource_type) { - $routes->addCollection($this->getFileUploadRoutesForResourceType($resource_type, $this->jsonApiBasePath)); + $routes->addCollection($this->getFileUploadRoutesForResourceType($resource_type)); } // Enable all available authentication providers. $routes->addOptions(['_auth' => $this->providerIds]); // Flag every route as belonging to the JSON API module. - $routes->addDefaults([JsonapiRoutes::JSON_API_ROUTE_FLAG_KEY => TRUE]); + $routes->addDefaults([JsonApiRoutes::JSON_API_ROUTE_FLAG_KEY => TRUE]); $routes->addRequirements(['_content_type_format' => 'bin']); // Resource routes all have the same controller. - $routes->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi_file_upload.request_handler:handle']); $routes->addPrefix($this->jsonApiBasePath); return $routes; @@ -105,7 +103,7 @@ class Routes implements ContainerInjectionInterface { protected function getFileUploadRoutesForResourceType(ResourceType $resource_type) { $routes = new RouteCollection(); - // Internal resources have no routes and individual routes require locations. + // Internal resources have no routes; individual routes require locations. if ($resource_type->isInternal() || !$resource_type->isLocatable()) { return $routes; } @@ -114,23 +112,25 @@ class Routes implements ContainerInjectionInterface { $path = $resource_type->getPath(); $entity_type_id = $resource_type->getEntityTypeId(); - $individual_file_upload_route = new Route("/{$path}/{file_field_name}"); - $individual_file_upload_route->setMethods(['POST']); - $individual_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE'); - $routes->add(static::getFileUploadRouteName($resource_type, 'individual.post'), $individual_file_upload_route); + $new_resource_file_upload_route = new Route("/{$path}/{file_field_name}"); + $new_resource_file_upload_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi_file_upload.request_handler:handleFileUploadForNewResource']); + $new_resource_file_upload_route->setMethods(['POST']); + $new_resource_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE'); + $routes->add(static::getFileUploadRouteName($resource_type, 'new_resource'), $new_resource_file_upload_route); - $individual_file_upload_existing_entity_route = new Route("/{$path}/{{$entity_type_id}}/{file_field_name}"); - $individual_file_upload_existing_entity_route->setMethods(['POST']); - $individual_file_upload_existing_entity_route->setRequirement('_csrf_request_header_token', 'TRUE'); - $routes->add(static::getFileUploadRouteName($resource_type, 'individual.post'), $individual_file_upload_existing_entity_route); + $existing_resource_file_upload_route = new Route("/{$path}/{entity}/{file_field_name}"); + $new_resource_file_upload_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi_file_upload.request_handler:handleFileUploadForExistingResource']); + $existing_resource_file_upload_route->setMethods(['POST']); + $existing_resource_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE'); + $routes->add(static::getFileUploadRouteName($resource_type, 'existing_resource'), $existing_resource_file_upload_route); // Add entity parameter conversion to every route. - $routes->addOptions(['parameters' => [$entity_type_id => ['type' => 'entity:' . $entity_type_id]]]); + $routes->addOptions(['parameters' => ['entity' => ['type' => 'entity:' . $entity_type_id]]]); // Add the resource type as a parameter to every resource route. foreach ($routes as $route) { - static::addRouteParameter($route, JsonapiRoutes::RESOURCE_TYPE_KEY, ['type' => ResourceTypeConverter::PARAM_TYPE_ID]); - $route->addDefaults([JsonapiRoutes::RESOURCE_TYPE_KEY => $resource_type->getTypeName()]); + static::addRouteParameter($route, JsonApiRoutes::RESOURCE_TYPE_KEY, ['type' => ResourceTypeConverter::PARAM_TYPE_ID]); + $route->addDefaults([JsonApiRoutes::RESOURCE_TYPE_KEY => $resource_type->getTypeName()]); } } diff --git a/modules/jsonapi_file_upload/src/Shim/FileUploader.php b/modules/jsonapi_file_upload/src/Shim/FileUploader.php index 354ba29..f31eb10 100644 --- a/modules/jsonapi_file_upload/src/Shim/FileUploader.php +++ b/modules/jsonapi_file_upload/src/Shim/FileUploader.php @@ -15,7 +15,7 @@ use Drupal\Core\Session\AccountInterface; use Drupal\Core\Utility\Token; use Drupal\Component\Render\PlainTextOutput; use Drupal\file\Entity\File; -use Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait; +use Drupal\jsonapi\Entity\EntityValidationTrait; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -38,7 +38,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException; */ class FileUploader implements FileUploaderInterface { - use EntityResourceValidationTrait { + use EntityValidationTrait { validate as resourceValidate; } @@ -92,6 +92,8 @@ class FileUploader implements FileUploaderInterface { protected $lock; /** + * System file configuration. + * * @var \Drupal\Core\Config\ImmutableConfig */ protected $systemFileConfig; @@ -323,7 +325,7 @@ class FileUploader implements FileUploaderInterface { * Thrown when there are file validation errors. */ protected function validate(FileInterface $file, array $validators) { - $this->resourceValidate($file); + static::resourceValidate($file); // Validate the file based on the field definition configuration. $errors = file_validate($file, $validators); @@ -435,7 +437,7 @@ class FileUploader implements FileUploaderInterface { /** * Generates a lock ID based on the file URI. * - * @param $file_uri + * @param string $file_uri * The file URI. * * @return string diff --git a/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php b/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php index 2f5b098..4854f58 100644 --- a/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php +++ b/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php @@ -2,14 +2,11 @@ namespace Drupal\jsonapi_file_upload\Shim; -use Drupal\Core\Cache\CacheableDependencyInterface; -use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Session\AccountInterface; -use Symfony\Component\HttpFoundation\Request; /** - * Interface to handle binary file data uploads and corresponding file entity creation. + * Interface to handle binary file data uploads and file entity creation. * * This is implemented at the field-level for the following reasons: * - Validation for uploaded files is tied to fields (allowed extensions, max diff --git a/src/Controller/EntityResource.php b/src/Controller/EntityResource.php index fa4bba6..f1a8da3 100644 --- a/src/Controller/EntityResource.php +++ b/src/Controller/EntityResource.php @@ -14,8 +14,8 @@ use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\EntityReferenceFieldItemListInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FieldTypePluginManagerInterface; +use Drupal\jsonapi\Entity\EntityValidationTrait; use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException; -use Drupal\jsonapi\Exception\UnprocessableHttpEntityException; use Drupal\jsonapi\LabelOnlyEntity; use Drupal\jsonapi\Query\Filter; use Drupal\jsonapi\Query\Sort; @@ -39,6 +39,8 @@ use Symfony\Component\HttpKernel\Exception\ConflictHttpException; */ class EntityResource { + use EntityValidationTrait; + /** * The JSON API resource type. * @@ -139,51 +141,6 @@ class EntityResource { return $response; } - /** - * Verifies that the whole entity does not violate any validation constraints. - * - * @param \Drupal\Core\Entity\EntityInterface $entity - * The entity object. - * @param string[] $field_names - * (optional) An array of field names. If specified, filters the violations - * list to include only this set of fields. Defaults to NULL, - * which means that all violations will be reported. - * - * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException - * Thrown when violations remain after filtering. - * - * @see \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait::validate() - */ - protected function validate(EntityInterface $entity, array $field_names = NULL) { - if (!$entity instanceof FieldableEntityInterface) { - return; - } - - $violations = $entity->validate(); - - // Remove violations of inaccessible fields as they cannot stem from our - // changes. - $violations->filterByFieldAccess(); - - // Filter violations based on the given fields. - if ($field_names !== NULL) { - $violations->filterByFields( - array_diff(array_keys($entity->getFieldDefinitions()), $field_names) - ); - } - - if (count($violations) > 0) { - // Instead of returning a generic 400 response we use the more specific - // 422 Unprocessable Entity code from RFC 4918. That way clients can - // distinguish between general syntax errors in bad serializations (code - // 400) and semantic errors in well-formed requests (code 422). - // @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer - $exception = new UnprocessableHttpEntityException(); - $exception->setViolations($violations); - throw $exception; - } - } - /** * Creates an individual entity. * @@ -197,6 +154,8 @@ class EntityResource { * * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException * Thrown when the entity already exists. + * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException + * Thrown when the entity does not pass validation. */ public function createIndividual(EntityInterface $entity, Request $request) { if ($entity instanceof FieldableEntityInterface) { @@ -227,7 +186,7 @@ class EntityResource { } } - $this->validate($entity); + static::validate($entity); // Return a 409 Conflict response in accordance with the JSON API spec. See // http://jsonapi.org/format/#crud-creating-responses-409. @@ -271,6 +230,8 @@ class EntityResource { * * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException * Thrown when the selected entity does not match the id in th payload. + * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException + * Thrown when the patched entity does not pass validation. */ public function patchIndividual(EntityInterface $entity, EntityInterface $parsed_entity, Request $request) { $body = Json::decode($request->getContent()); @@ -290,7 +251,7 @@ class EntityResource { return $destination; }, $entity); - $this->validate($entity, $field_names); + static::validate($entity, $field_names); $entity->save(); return $this->buildWrappedResponse($entity); } @@ -484,6 +445,8 @@ class EntityResource { * field(s). * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException * Thrown when POSTing to a "to-one" relationship. + * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException + * Thrown when the updated entity does not pass validation. */ public function createRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) { $related_field = $this->resourceType->getInternalName($related_field); @@ -509,7 +472,7 @@ class EntityResource { foreach ($parsed_field_list as $field_item) { $field_list->appendItem($field_item->getValue()); } - $this->validate($entity); + static::validate($entity); $entity->save(); $status = static::relationshipArityIsAffected($original_field_list, $field_list) ? 200 @@ -574,6 +537,9 @@ class EntityResource { * * @return \Drupal\jsonapi\ResourceResponse * The response. + * + * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException + * Thrown when the updated entity does not pass validation. */ public function patchRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) { $related_field = $this->resourceType->getInternalName($related_field); @@ -592,7 +558,7 @@ class EntityResource { ->isMultiple(); $method = $is_multiple ? 'doPatchMultipleRelationship' : 'doPatchIndividualRelationship'; $this->{$method}($entity, $parsed_field_list); - $this->validate($entity); + static::validate($entity); $entity->save(); return $this->getRelationship($entity, $related_field, $request, 204); } @@ -688,7 +654,7 @@ class EntityResource { ->createFieldItemList($entity, $related_field, $keep_values); // Save the entity and return the response object. - $this->validate($entity); + static::validate($entity); $entity->save(); return $this->getRelationship($entity, $related_field, $request, 204); } diff --git a/src/Entity/EntityValidationTrait.php b/src/Entity/EntityValidationTrait.php index e69de29..86799d7 100644 --- a/src/Entity/EntityValidationTrait.php +++ b/src/Entity/EntityValidationTrait.php @@ -0,0 +1,61 @@ +validate(); + + // Remove violations of inaccessible fields as they cannot stem from our + // changes. + $violations->filterByFieldAccess(); + + // Filter violations based on the given fields. + if ($field_names !== NULL) { + $violations->filterByFields( + array_diff(array_keys($entity->getFieldDefinitions()), $field_names) + ); + } + + if (count($violations) > 0) { + // Instead of returning a generic 400 response we use the more specific + // 422 Unprocessable Entity code from RFC 4918. That way clients can + // distinguish between general syntax errors in bad serializations (code + // 400) and semantic errors in well-formed requests (code 422). + // @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer + $exception = new UnprocessableHttpEntityException(); + $exception->setViolations($violations); + throw $exception; + } + } + +}