diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt index 4b16590c245543f44cb05056bb24c8c943d4d766..524c6feaa3bf04f438f0e331e7959075b8971b8f 100644 --- a/.cspell-project-words.txt +++ b/.cspell-project-words.txt @@ -1,3 +1,4 @@ +flac ~flexbox ~flexboxes graphqls diff --git a/README.md b/README.md index 950ea5b5c1cb0ac4322885be16e4767330b78e92..d1bc70ead2525b4de16383e2acc084ead46051c9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # GraphQL Webform Drupal module A module to integrate webform with the graphql module. -IMPORTANT: This is a module under active development and it does not support all -webform features and elements yet. Feel free to raise Feature Requests and +IMPORTANT: This is a module under active development and it does not support all +webform features and elements yet. Feel free to raise Feature Requests and to contribute :) ## Pre-Requisites @@ -205,34 +205,51 @@ submission failed because the form is closed. ### Create a webform submission when webform contains File elements -If the webform contains a File field, you need to submit/create the file before -creating the submission itself. There is a `webformFileUpload` mutation -available. +If the webform contains managed file fields, you can attach the uploaded files +by using a multipart form request and including a 'map' field that maps the +uploaded files to the webform elements. - mutation createFile($file: Upload!){ - webformFileUpload(file: $file, id:"contact", webform_element_id: "upload_your_file") { - errors - violations - entity { - entityId - } - ... on WebformFileUploadOutput { - fid - } - } - } +For more information on how to upload files using GraphQL, see the +[GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). +``` +mutation submitMyForm( + $id: String!, + $elements: [WebformSubmissionElement]!, + $files: [WebformSubmissionFile]!, +) { + submitWebform( + id: $id, + elements: $elements, + files: $files, + ) { + errors + validationErrors { + element + messages + } + submission { + id + } + } +} +``` -As the result you can check for errors, violations, entity and entityId. You can -query for `entity > entityId` or `fid` to get the file id that was just created. -`fid` is a necessary GraphQL field for cases where the graphql is performed by -anonymous users and the file has been uploaded to the private folder. - -When you get the fid (e.g. 1910) you then update the `$values` variable with it: +Example CURL request. Note that the value of the `file` field is `null` in the +`variables` object. It will be replaced by the file linked in the `map` object. - { - "values": "{\"id\":\"contact\",\"subject\":\"This is the subject\",\"message\":\"Hey, I have a question\",\"date_of_birth\":\"05\/01\/1991\",\"email\":\"email@example.com\",\"upload_your_file\":\"1910\"}" +``` +curl localhost:3001/graphql \ + -F operations='{ + "query": "mutation ($id: String!, $files: [WebformSubmissionFile]!) { submitWebform(id: $id, files: $files) { errors validationErrors { element messages } submission { id } } }", + "variables": { + "id": "my_webform", + "files": [{"element": "my_file_element", "file": null}] } + }' \ + -F map='{"0": ["variables.files.0.file"]}' \ + -F 0=@a.txt +``` ### Create a webform submission with a source entity If the webform supports a source entity, you can pass in the variables with the diff --git a/composer.json b/composer.json index 678a96fd158a0d1a40ab535b3362791d8225ee72..30dbb953b4f3bf318cd7e90e85705a6cbe78bae2 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.1", "drupal/graphql": "^4.6", - "drupal/webform": "^6.0", + "drupal/webform": "^6.3@beta", "symfony/string": "^6 || ^7" }, "require-dev": { diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls index 2b04f26240924db3837301d8f33af06ff6332b6d..7ced00a1e86c8c8bb43690bdaf106ce0edaea841 100644 --- a/graphql/webform.base.graphqls +++ b/graphql/webform.base.graphqls @@ -1,9 +1,11 @@ +scalar WebformSubmissionFileUpload scalar WebformSubmissionValue type Mutation { submitWebform( id: String! elements: [WebformSubmissionElement] + files: [WebformSubmissionFile] sourceEntityId: String sourceEntityType: String ): WebformSubmissionResult! @@ -52,3 +54,16 @@ input WebformSubmissionElement { """ value: WebformSubmissionValue! } + +input WebformSubmissionFile { + """ + The name of the webform element. + """ + element: String! + + """ + The file to upload. This is usually null and the file is appended via + multipart form data maps. + """ + file: WebformSubmissionFileUpload +} diff --git a/src/GraphQL/WebformFileUploadOutputWrapper.php b/src/GraphQL/WebformFileUploadOutputWrapper.php deleted file mode 100644 index e69c327a71183c23ebb71ff2bb915dc2ebedd4a5..0000000000000000000000000000000000000000 --- a/src/GraphQL/WebformFileUploadOutputWrapper.php +++ /dev/null @@ -1,49 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\graphql_webform\GraphQL; - -use Drupal\Core\Entity\EntityInterface; -use Drupal\graphql_core\GraphQL\EntityCrudOutputWrapper; -use Symfony\Component\Validator\ConstraintViolationListInterface; - -/** - * Extends EntityCrudOutputWrapper to add fid (File id) to the helper class. - */ -class WebformFileUploadOutputWrapper extends EntityCrudOutputWrapper { - - /** - * Constructs a new WebformFileUploadOutputWrapper object. - * - * @param \Drupal\Core\Entity\EntityInterface|null $entity - * The entity object that has been created or NULL if creation failed. - * @param int|null $fid - * The file ID, or NULL if the file was not created. - * @param \Symfony\Component\Validator\ConstraintViolationListInterface|null $violations - * The validation errors that occurred during creation or NULL if validation - * succeeded. - * @param array|null $errors - * An array of non validation error messages. Can be used to provide - * additional error messages e.g. for access restrictions. - */ - public function __construct( - ?EntityInterface $entity = NULL, - protected ?int $fid = NULL, - ?ConstraintViolationListInterface $violations = NULL, - ?array $errors = NULL - ) { - parent::__construct($entity, $violations, $errors); - } - - /** - * Returns the fid. - * - * @return int|null - * The file id or NULL if creation failed. - */ - public function getFid(): ?int { - return $this->fid; - } - -} diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php index 28efb43c9ac2653d5b5908cf4db1564b051c45b0..69ac6a4c72c3e170e217583bab03e505c58a4aef 100644 --- a/src/Model/WebformSubmissionResult.php +++ b/src/Model/WebformSubmissionResult.php @@ -94,14 +94,49 @@ class WebformSubmissionResult { return $this->validationErrors; } + /** + * Returns the validation error that corresponds to the given element. + * + * @param string|null $elementId + * The ID of the Webform element for which to return the validation error, + * or NULL to return the general validation error. + * + * @return \Drupal\graphql_webform\Model\WebformSubmissionValidationError + * The validation error. If no error exists for the given element ID yet, an + * empty error object is returned. + */ + public function getOrCreateValidationError(?string $elementId): WebformSubmissionValidationError { + return $this->validationErrors[$elementId] ?? new WebformSubmissionValidationError([], $elementId); + } + + /** + * Returns whether a validation error exists for the given element. + * + * @param string|null $elementId + * The ID of the Webform element to check for a validation error, or NULL to + * check for a general validation error. + * + * @return bool + * TRUE if a validation error exists for the given element. + */ + public function hasValidationError(?string $elementId): bool { + return isset($this->validationErrors[$elementId]); + } + /** * Adds a validation error. * - * @param \Drupal\graphql_webform\Model\WebformSubmissionValidationError $validation_error - * The validation error to add. + * @param string $errorMessage + * The error message to add. + * @param string|null $elementId + * The ID of the Webform element the error refers to, or NULL if the error + * is not element-specific. */ - public function addValidationError(WebformSubmissionValidationError $validation_error): static { - $this->validationErrors[] = $validation_error; + public function addValidationError(string $errorMessage, ?string $elementId): static { + $validationError = $this->getOrCreateValidationError($elementId); + $validationError->addMessage($errorMessage); + $this->validationErrors[$elementId] = $validationError; + return $this; } diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php index d3339daf0aee876677afa03b533603977290cd95..e97b2bd08e2e221ef6a70b2356213c12da9eb1ec 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -9,11 +9,11 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; +use Drupal\file\FileInterface; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\GraphQL\Utility\FileUpload; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Drupal\graphql_webform\Model\WebformSubmissionResult; -use Drupal\graphql_webform\Model\WebformSubmissionValidationError; use Drupal\webform\Element\Webform; use Drupal\webform\Plugin\WebformElementManagerInterface; use Drupal\webform\WebformEntityStorageInterface; @@ -38,7 +38,12 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * label = @Translation("Webform ID") * ), * "elements" = @ContextDefinition("any", - * label = @Translation("Array of WebformSubmissionElement objects.") + * label = @Translation("Array of WebformSubmissionElement objects."), + * multiple = TRUE + * ), + * "files" = @ContextDefinition("any", + * label = @Translation("Array of WebformSubmissionFile objects."), + * multiple = TRUE * ), * "sourceEntityType" = @ContextDefinition("string", * label = @Translation("Source entity type"), @@ -109,6 +114,8 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl * The Webform ID for which to create a submission. * @param array $elements * Submitted values for Webform elements. + * @param array $files + * Submitted file uploads. * @param string|null $sourceEntityType * The entity type of the entity that is the source of the submission. * @param string|null $sourceEntityId @@ -121,9 +128,8 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl */ public function resolve( string $id, - // The GraphQL field context is only populated if it is at the end of the - // argument list. Suppress the coding standards warning for this. array $elements, + array $files, ?string $sourceEntityType, ?string $sourceEntityId, FieldContext $field, @@ -131,7 +137,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // Create context and execute validation and submission in render context to // capture caching metadata. $renderContext = new RenderContext(); - $renderResult = $this->renderer->executeInRenderContext($renderContext, function () use ($id, $elements, $field, $sourceEntityId, $sourceEntityType) { + $renderResult = $this->renderer->executeInRenderContext($renderContext, function () use ($id, $elements, $files, $field, $sourceEntityId, $sourceEntityType) { // Create the result object. $result = new WebformSubmissionResult(); @@ -172,6 +178,12 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl $dataElements[$element['element']] = $element['value']; } + // Handle file uploads. This will add validation errors if necessary. + $createdFileIds = $this->handleFileElements($webform, $files, $result); + foreach ($createdFileIds as $elementKey => $fileIds) { + $dataElements[$elementKey] = $fileIds; + } + // Build the form data object for the form submission. $formData = [ 'webform_id' => $id, @@ -225,130 +237,129 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl * The result object to populate. */ protected static function handleFormErrors(array $errors, WebformSubmissionResult $result): void { - foreach ($errors as $element => $message) { + foreach ($errors as $elementId => $message) { // The Webform module returns error messages intended to be shown in a web // page. Strip HTML tags to make them suitable for general use. $message = strip_tags((string) $message); - $result->addValidationError(new WebformSubmissionValidationError([$message], $element)); + $result->addValidationError($message, $elementId); } } /** - * Handle possible file uploads. + * Handles possible file uploads. * * @param \Drupal\webform\WebformInterface $webform * The Webform entity. - * @param array $files - * The array of WebformSubmissionFile items. + * @param array $uploadedFiles + * The array of files that were uploaded in the form submission. * @param \Drupal\graphql_webform\Model\WebformSubmissionResult $result * The result object to populate with validation errors. * * @return array - * The mapped array of WebformSubmissionElement items. + * An array of successfully uploaded file IDs, grouped by element key. */ - protected function handleFileElements(WebformInterface $webform, array $files, WebformSubmissionResult $result): array { - // Array of all the managed file element keys. - $managedFileElements = array_keys($webform->getElementsManagedFiles()); - - // Contains the mapped submission values. The original $files array - // contains the actual UploadedFile object. The mapped item contains the - // FID, if upload was successfull. - $filesMapped = []; - - $elementsMultiple = []; - $elementsSingle = []; + protected function handleFileElements(WebformInterface $webform, array $uploadedFiles, WebformSubmissionResult $result): array { + // Group the files by element so we can process each element separately. + $filesByElement = []; + foreach ($uploadedFiles as $file) { + $filesByElement[$file['element']][] = $file['file']; + } - $input = []; + // Retrieve all the managed file elements so we can check if the uploaded + // files are correctly associated with a managed file element. + $managedFileElements = array_keys($webform->getElementsManagedFiles()); - foreach ($files as $item) { - $elementKey = $item['element']; + // Keep track of File entity IDs that were created by valid uploads. This + // will be returned at the end of the method. + $fileIds = []; - // Check if the element is actually a managed file. + foreach ($filesByElement as $elementKey => $files) { + // Check if the element is actually a managed file element. If not, report + // this as a generic error rather than a validation error. This is + // probably not the end user's mistake but rather a bug in the GraphQL + // client code. if (!in_array($elementKey, $managedFileElements)) { - $validation = new WebformSubmissionValidationError(['Given element ID is not a managed file element.'], $item['element']); - $result->addValidationError($validation); + $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $elementKey); + $result->addError($error); continue; } - // Get the configuration for the element. - $configuration = $webform->getElement($item['element']); - - /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $element */ - $element = $this->elementManager->getElementInstance($configuration); - - // Get file validation and upload settings. - $fileExtensions = $element->getElementProperty($configuration, 'file_extensions'); - $maxFilesize = $element->getElementProperty($configuration, 'max_filesize'); - $uriScheme = $element->getElementProperty($configuration, 'uri_scheme'); - $multiple = $element->getElementProperty($configuration, 'multiple'); - - if ($multiple === FALSE || $multiple === NULL || $multiple === 1) { - $elementsSingle[] = $elementKey; - - // Check if we already have one file for this element. - if (!empty($filesMapped[$elementKey])) { - $validation = new WebformSubmissionValidationError(['Only one file is allowed.'], $item['element']); - $result->addValidationError($validation); + // Get the initialized render element and the plugin instance that + // represents an OOP interface to the element configuration. + $element = $webform->getElement($elementKey); + /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $instance */ + $instance = $this->elementManager->getElementInstance($element); + $instance->prepare($element); + + // Get the number of allowed files for this element. + $allowedNumberOfFiles = $instance->hasMultipleValues($element); + + // Don't check the number of uploaded files if an unlimited number of + // files is allowed. + if ($allowedNumberOfFiles !== TRUE) { + $allowedNumberOfFiles = is_numeric($allowedNumberOfFiles) ? (int) $allowedNumberOfFiles : 1; + if (count($files) > $allowedNumberOfFiles) { + $message = match ($allowedNumberOfFiles) { + 1 => 'Only one file can be uploaded.', + default => sprintf('The number of files uploaded exceeds the maximum of %d.', $allowedNumberOfFiles), + }; + $result->addValidationError($message, $elementKey); continue; } } - else { - $elementsMultiple[] = $elementKey; - } - // Perform the upload. - $upload = $this->fileUpload->saveFileUpload($item['file'], [ - 'file_extensions' => $fileExtensions, - 'max_filesize' => $maxFilesize, - 'uri_scheme' => $uriScheme, - 'file_directory' => 'webform', - ]); - - // Get the upload violations. - $violations = $upload->getViolations(); - - // Convert the upload violations to validation errors. - if (!empty($violations)) { - $validation = new WebformSubmissionValidationError([], $item['element']); - foreach ($violations as $violation) { - $validation->addMessage($violation['message']); + // Retrieve the validation criteria for the file upload element. Note that + // the methods to retrieve the file size and allowed extensions are + // protected, so we need to get them from the render array instead. + // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getFileExtensions() + // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getMaxFileSize() + $uriScheme = $instance->getElementProperty($element, 'uri_scheme'); + $maxFilesize = $element['#upload_validators']['FileSizeLimit']['fileLimit']; + $fileExtensions = $element['#upload_validators']['FileExtension']['extensions']; + + // Attempt to save the uploaded files, leveraging the validation checks of + // the file upload service from the GraphQL module. + foreach ($files as $file) { + $uploadResponse = $this->fileUpload->saveFileUpload($file, [ + 'file_extensions' => $fileExtensions, + 'max_filesize' => $maxFilesize, + 'uri_scheme' => $uriScheme, + 'file_directory' => 'webform', + ]); + + // If there are any violations, add them as form validation errors. + $violations = $uploadResponse->getViolations(); + if (!empty($violations)) { + foreach ($violations as $violation) { + $result->addValidationError($violation['message'], $elementKey); + } + continue; } - $result->addValidationError($validation); - continue; - } - $file = $upload->getFileEntity(); - // If there is no file and no validation errors, add an unexpected error. - if (empty($file) && $result->isValid()) { - $validation = new WebformSubmissionValidationError(['Unexpected error while uploading file.'], $item['element']); - $result->addValidationError($validation); - continue; - } - - // Upload and saving was successful. Add the mapped form item, with the - // FID as the value. - $filesMapped[$elementKey][] = $file->id(); - } + $file = $uploadResponse->getFileEntity(); + // At this point we should have a file. If we don't, inform the client + // that the file was not uploaded. + // @todo If this ever happens in practice, we should probably log this. + if (!$file instanceof FileInterface) { + $result->addValidationError('Unexpected error occurred while uploading file. Please try again later.', $elementKey); + continue; + } - foreach ($filesMapped as $elementKey => $fileIds) { - // Element supports multiple uploads. - if (in_array($elementKey, $elementsMultiple)) { - foreach ($fileIds as $index => $id) { - $input[] = [ - 'element' => $elementKey . "[$index]", - 'value' => $id, - ]; + // The webform module expects that values submitted for multi-value + // elements are passed as arrays, while values for single value elements + // are passed as a scalar. Avoid array to string conversion errors by + // casting the file ID accordingly. + // @see \Drupal\webform\WebformSubmissionStorage::saveData() + if ($allowedNumberOfFiles === 1) { + $fileIds[$elementKey] = $file->id(); + } + else { + $fileIds[$elementKey][] = $file->id(); } - } - else { - $input[] = [ - 'element' => $elementKey, - 'value' => $fileIds[0], - ]; } } - return $input; + return $fileIds; } /** diff --git a/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php b/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php deleted file mode 100644 index 15a4a86bdd619c60cf956c1b2d030b10f7c66bc8..0000000000000000000000000000000000000000 --- a/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php +++ /dev/null @@ -1,34 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\graphql_webform\Plugin\GraphQL\Fields\File; - -use Drupal\graphql\GraphQL\Execution\ResolveContext; -use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase; -use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper; -use GraphQL\Type\Definition\ResolveInfo; - -/** - * Retrieve date max property from Date form element. - * - * @GraphQLField( - * secure = true, - * parents = {"WebformFileUploadOutput"}, - * id = "webform_file_upload_fid", - * name = "fid", - * type = "Int", - * ) - */ -class WebformFileUploadOutputFid extends FieldPluginBase { - - /** - * {@inheritdoc} - */ - public function resolveValues($value, array $args, ResolveContext $context, ResolveInfo $info) { - if ($value instanceof WebformFileUploadOutputWrapper) { - yield $value->getFid(); - } - } - -} diff --git a/src/Plugin/GraphQL/Mutations/WebformFileUpload.php b/src/Plugin/GraphQL/Mutations/WebformFileUpload.php deleted file mode 100644 index fbb87942ca395d727853f70b9a7033c7f03afc3c..0000000000000000000000000000000000000000 --- a/src/Plugin/GraphQL/Mutations/WebformFileUpload.php +++ /dev/null @@ -1,258 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations; - -use Drupal\Core\DependencyInjection\DependencySerializationTrait; -use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\File\FileSystemInterface; -use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Session\AccountProxyInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; -use Drupal\graphql\GraphQL\Execution\ResolveContext; -use Drupal\graphql\Plugin\GraphQL\Mutations\MutationPluginBase; -use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper; -use Drupal\webform\Entity\Webform; -use GraphQL\Type\Definition\ResolveInfo; -use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\HttpFoundation\File\UploadedFile; -use Symfony\Component\Mime\MimeTypeGuesserInterface; - -/** - * Uploads a file to a webform element. - * - * @todo Add the whole range of file upload validations from file_save_upload(). - * - * @GraphQLMutation( - * id = "webform_file_upload", - * secure = "false", - * name = "webformFileUpload", - * type = "WebformFileUploadOutput", - * arguments = { - * "file" = "Upload!", - * "id" = "String!", - * "webform_element_id" = "String!" - * } - * ) - */ -class WebformFileUpload extends MutationPluginBase implements ContainerFactoryPluginInterface { - use DependencySerializationTrait; - use StringTranslationTrait; - - /** - * The entity type manager. - * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface - */ - protected $entityTypeManager; - - /** - * The current user. - * - * @var \Drupal\Core\Session\AccountProxyInterface - */ - protected $currentUser; - - /** - * The mime type guesser service. - * - * @var \Symfony\Component\Mime\MimeTypeGuesserInterface - */ - protected $mimeTypeGuesser; - - /** - * The file system service. - * - * @var \Drupal\Core\File\FileSystemInterface - */ - protected $fileSystem; - - /** - * {@inheritdoc} - */ - public function __construct( - array $configuration, - $pluginId, - $pluginDefinition, - EntityTypeManagerInterface $entityTypeManager, - AccountProxyInterface $currentUser, - MimeTypeGuesserInterface $mimeTypeGuesser, - FileSystemInterface $fileSystem - ) { - parent::__construct($configuration, $pluginId, $pluginDefinition); - $this->entityTypeManager = $entityTypeManager; - $this->currentUser = $currentUser; - $this->mimeTypeGuesser = $mimeTypeGuesser; - $this->fileSystem = $fileSystem; - } - - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition) { - return new static( - $configuration, - $pluginId, - $pluginDefinition, - $container->get('entity_type.manager'), - $container->get('current_user'), - $container->get('file.mime_type.guesser'), - $container->get('file_system') - ); - } - - /** - * {@inheritdoc} - */ - public function resolve($value, array $args, ResolveContext $context, ResolveInfo $info) { - /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */ - $file = $args['file']; - - // Do not proceed if file argument is invalid. - if (!($file instanceof UploadedFile)) { - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [ - 'File argument is invalid. Expected \Symfony\Component\HttpFoundation\File\UploadedFile.', - ]); - } - - // Check for file upload errors and return FALSE for this file if a lower - // level system error occurred. - // - // @see http://php.net/manual/features.file-upload.errors.php. - switch ($file->getError()) { - case UPLOAD_ERR_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - $max_filesize = \Drupal::config('webform.settings')->get('file.default_max_filesize') ?: Environment::getUploadMaxSize(); - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [ - $this->t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', [ - '%file' => $file->getFilename(), - '%maxsize' => format_size($max_filesize), - ]), - ]); - - case UPLOAD_ERR_PARTIAL: - case UPLOAD_ERR_NO_FILE: - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [ - $this->t('The file %file could not be saved because the upload did not complete.', [ - '%file' => $file->getFilename(), - ]), - ]); - - case UPLOAD_ERR_OK: - // Final check that this is a valid upload, if it isn't, use the - // default error handler. - if (is_uploaded_file($file->getRealPath())) { - break; - } - - default: - // Unknown error. - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [ - $this->t('The file %file could not be saved. An unknown error has occurred.', [ - '%file' => $file->getFilename(), - ]), - ]); - } - - // Load the webform. - $webform = Webform::load($args['id']); - - // Validate the if id argument is valid. - if (!$webform) { - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [ - $this->t('The webform %webform_id does not exist.', [ - '%webform_id' => $args['id'], - ]), - ]); - } - - // Get the webform element. - $file_element = $webform->getElement($args['webform_element_id']); - if (!$file_element) { - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [ - $this->t('The webform_element_id %webform_element_id does not exist.', [ - '%webform_element_id' => $args['webform_element_id'], - ]), - ]); - } - - $filename = $file->getClientOriginalName(); - $mime = $this->mimeTypeGuesser->guessMimeType($filename); - $scheme = $file_element['#uri_scheme'] ?? 'private'; - - $upload_location = $scheme . '://webform/' . $webform->id() . '/_sid_'; - - // Make sure the upload location exists and is writable. - $this->fileSystem->prepareDirectory($upload_location, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); - - $destination = $this->fileSystem->getDestinationFilename("{$upload_location}/{$filename}", FileSystemInterface::EXISTS_RENAME); - - // Begin building file entity. - $values = [ - 'uid' => $this->currentUser->id(), - 'status' => 0, - 'filename' => $filename, - 'uri' => $destination, - 'filesize' => $file->getSize(), - 'filemime' => $mime, - ]; - - $storage = $this->entityTypeManager->getStorage('file'); - /** @var \Drupal\file\FileInterface $entity */ - $entity = $storage->create($values); - - // Validate the file name length. - if ($errors = file_validate($entity, ['file_validate_name_length' => []])) { - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [ - $this->t('The specified file %name could not be uploaded.', [ - '%file' => $filename, - ]), - ]); - } - - // Validate allowed extensions. - if ($file_element['#file_extensions']) { - $allowed_extensions = $file_element['#file_extensions']; - } - else { - $file_type = str_replace('webform_', '', $file_element['#type']); - $allowed_extensions = \Drupal::config('webform.settings')->get("file.default_{$file_type}_extensions"); - } - $errors = file_validate_extensions($entity, $allowed_extensions); - if ($errors) { - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, $errors); - } - - // Move uploaded files from PHP's upload_tmp_dir to Drupal's temporary - // directory. This overcomes open_basedir restrictions for future file - // operations. - if (!$this->fileSystem->moveUploadedFile($file->getRealPath(), $entity->getFileUri())) { - return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [ - $this->t('Could not move uploaded file %name.', [ - '%file' => $file->getFilename(), - ]), - ]); - } - - // Set the permissions on the new file. - $this->fileSystem->chmod($entity->getFileUri()); - - // Update the filename with any changes as a result of security or renaming - // due to an existing file. - $entity->setFilename(\Drupal::service('file_system')->basename($destination)); - - // Validate the entity values. - if (($violations = $entity->validate()) && $violations->count()) { - return new WebformFileUploadOutputWrapper(NULL, NULL, $violations); - } - - // If we reached this point, we can save the file. - if (($status = $entity->save()) && $status === SAVED_NEW) { - return new WebformFileUploadOutputWrapper($entity, $entity->id(), NULL, []); - } - - return NULL; - } - -} diff --git a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php index 1f7de8ff5e41c33e6e9dfcd6cab02970e26646b8..0f02c594e70d65c7915cbaf2628eaf6acdb26a84 100644 --- a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php +++ b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php @@ -298,16 +298,10 @@ class WebformExtension extends SdlSchemaExtensionPluginBase { } // Consider the other possible sources for the max filesize: the PHP - // limit and the default webform setting. + // limit and the (optional) default webform setting. $sizes = array_filter([ $size, - // Ignore the PHP limit if a default max filesize is set on the form. - // This follows the functionality of the Webform module, but in case - // the PHP limit is lower than the default max filesize, the reported - // value will be incorrect. - // @todo Change this behavior when the bug is fixed in the Webform - // module. - // @see https://www.drupal.org/project/webform/issues/3482402 + Environment::getUploadMaxSize(), $this->getWebformSetting('file.default_max_filesize') ?: Environment::getUploadMaxSize(), ]); @@ -375,6 +369,7 @@ class WebformExtension extends SdlSchemaExtensionPluginBase { $builder->produce('webform_submit') ->map('id', $builder->fromArgument('id')) ->map('elements', $builder->fromArgument('elements')) + ->map('files', $builder->fromArgument('files')) ->map('sourceEntityId', $builder->fromArgument('sourceEntityId')) ->map('sourceEntityType', $builder->fromArgument('sourceEntityType')) )); diff --git a/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php b/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php deleted file mode 100644 index 7f2dff2923168c3755e51f2adb6855d93d3d7c45..0000000000000000000000000000000000000000 --- a/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php +++ /dev/null @@ -1,30 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\graphql_webform\Plugin\GraphQL\Types; - -use Drupal\graphql\GraphQL\Execution\ResolveContext; -use Drupal\graphql\Plugin\GraphQL\Types\TypePluginBase; -use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper; -use GraphQL\Type\Definition\ResolveInfo; - -/** - * A GraphQL type for a WebformFileUploadOutputWrapper object. - * - * @GraphQLType( - * id = "webform_file_upload_output", - * name = "WebformFileUploadOutput", - * interfaces = {"EntityCrudOutput"} - * ) - */ -class WebformFileUploadOutput extends TypePluginBase { - - /** - * {@inheritdoc} - */ - public function applies($object, ResolveContext $context, ResolveInfo $info) { - return ($object instanceof WebformFileUploadOutputWrapper); - } - -} diff --git a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml index 6ebbd1725e817ddb1b12ddba71ff525b746f1e49..b1ea531b203e7eaca01f8e2bae27b0094669adb5 100644 --- a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml +++ b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml @@ -55,12 +55,11 @@ elements: |- file_upload: '#type': managed_file '#title': 'File upload' - '#multiple': true '#description': Description '#help_title': 'Help title' '#help': 'Help text' '#file_placeholder': Placeholder - '#max_filesize': '2' + '#max_filesize': '1' '#file_extensions': 'gif jpg png txt' '#uri_scheme': public radios: @@ -102,7 +101,7 @@ elements: |- audio_files: '#type': webform_audio_file '#title': 'Audio files' - '#multiple': true + '#multiple': 2 '#file_extensions': '' select_with_custom_empty_option: '#type': select diff --git a/tests/queries/create_file.gql b/tests/queries/create_file.gql deleted file mode 100644 index b8bba21da162fab7a8dfd3a0d597608106b64c0d..0000000000000000000000000000000000000000 --- a/tests/queries/create_file.gql +++ /dev/null @@ -1,7 +0,0 @@ -mutation createFile($file: Upload!, $id: String!, $webform_element_id: String!){ - webformFileUpload(file: $file, id: $id, webform_element_id: $webform_element_id) { - ... on WebformFileUploadOutput { - fid - } - } -} diff --git a/tests/queries/submission_with_file_upload.gql b/tests/queries/submission_with_file_upload.gql new file mode 100644 index 0000000000000000000000000000000000000000..3f37a8d94f8d39b5090939549954b97373801c9c --- /dev/null +++ b/tests/queries/submission_with_file_upload.gql @@ -0,0 +1,23 @@ +mutation submit( + $id: String!, + $elements: [WebformSubmissionElement]!, + $files: [WebformSubmissionFile]!, +) { + submitWebform( + id: $id, + elements: $elements, + files: $files, + ) { + errors + validationErrors { + element + messages + } + submission { + id + webform { + id + } + } + } +} diff --git a/tests/src/Kernel/Element/CreateFileTest.php b/tests/src/Kernel/Element/CreateFileTest.php deleted file mode 100644 index 153b34e2cc7ebd3394df992bdd7cb42534cbdfb0..0000000000000000000000000000000000000000 --- a/tests/src/Kernel/Element/CreateFileTest.php +++ /dev/null @@ -1,145 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\graphql_webform\Kernel\Element; - -use Drupal\Core\File\FileSystemInterface; -use Drupal\Tests\graphql_webform\Kernel\GraphQLWebformKernelTestBase; -use Drupal\file\FileInterface; -use Drupal\file\FileStorageInterface; -use Symfony\Component\HttpFoundation\Request; - -/** - * Tests uploading a file for a webform. - * - * @group graphql_webform - */ -class CreateFileTest extends GraphQLWebformKernelTestBase { - - /** - * {@inheritdoc} - */ - protected static $modules = [ - 'file', - ]; - - /** - * The file storage. - * - * @var \Drupal\file\FileStorageInterface|null - */ - protected ?FileStorageInterface $fileStorage; - - /** - * The file system. - * - * @var \Drupal\Core\File\FileSystemInterface|null - */ - protected ?FileSystemInterface $fileSystem; - - /** - * {@inheritdoc} - */ - protected function setUp(): void { - parent::setUp(); - - $this->installEntitySchema('file'); - - $this->fileStorage = $this->container->get('entity_type.manager')->getStorage('file'); - $this->fileSystem = $this->container->get('file_system'); - } - - /** - * Tests uploading a file through the webformFileUpload mutation. - */ - public function testFileUpload() { - $this->markTestSkipped('To be ported to GraphQL 4.x.'); - // We are pretending to upload a file into temporary storage. Ensure a file - // is there because the Symfony UploadedFile component will check that. - $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.txt'; - touch($file); - - // Create a post request with file contents. - $request = Request::create('/graphql', 'POST', [ - 'query' => $this->getQueryFromFile('create_file.gql'), - // The variable has to be declared null. - 'variables' => [ - 'file' => NULL, - 'webform_element_id' => 'file_upload', - 'id' => 'graphql_webform_test_form', - ], - // Then map the file upload name to the variable. - 'map' => [ - 'test' => ['variables.file'], - ], - ], [], [ - 'test' => [ - 'name' => 'test.txt', - 'type' => 'text/plain', - 'size' => 42, - 'tmp_name' => $file, - 'error' => UPLOAD_ERR_OK, - ], - ]); - - $request->headers->add(['content-type' => 'multipart/form-data']); - $response = $this->container->get('http_kernel')->handle($request); - $result = json_decode($response->getContent()); - - // Check that the file ID was returned. - $returned_fid = $result->data->webformFileUpload->fid ?? NULL; - $this->assertIsInt($returned_fid); - - // Check that the file entity was created. - $file = $this->fileStorage->load($returned_fid); - $this->assertInstanceOf(FileInterface::class, $file); - $this->assertEquals('test.txt', $file->getFilename()); - $this->assertEquals('text/plain', $file->getMimeType()); - } - -} - -namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations; - -/** - * Mock the PHP function is_uploaded_file(). - * - * Since we are not *really* uploading a file through the webserver, PHP will - * not recognize the file as an uploaded file. We mock the function to return - * TRUE for our test file. - * - * @param string $filename - * The filename being checked. - * - * @return bool - * Will return TRUE for our test file. - */ -function is_uploaded_file($filename) { - $temp_dir = \Drupal::service('file_system')->getTempDirectory(); - $test_file = $temp_dir . '/graphql_webform_upload_test.txt'; - return $filename === $test_file; -} - -namespace Drupal\Core\File; - -/** - * Mock the PHP function move_uploaded_file(). - * - * Since we are not *really* uploading a file through the webserver, PHP will - * refuse to move the file and will return FALSE. We mock the function to return - * TRUE for our test file. - * - * @param string $filename - * The filename being moved. - * @param string $destination - * The destination path. - * - * @return bool - * Will return TRUE for our test file. - */ -function move_uploaded_file($filename, $destination) { - $temp_dir = \Drupal::service('file_system')->getTempDirectory(); - $test_file = $temp_dir . '/graphql_webform_upload_test.txt'; - return $filename === $test_file; -} diff --git a/tests/src/Kernel/Element/ManagedFileTest.php b/tests/src/Kernel/Element/ManagedFileTest.php index 3d970b860aa29b500b013a1d81457087f915049a..7eab946b473e747f97899d2b320f938ae3f0c55b 100644 --- a/tests/src/Kernel/Element/ManagedFileTest.php +++ b/tests/src/Kernel/Element/ManagedFileTest.php @@ -39,7 +39,7 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase { 'key' => 'file_upload', 'type' => 'managed_file', 'title' => 'File upload', - 'description' => "Description<br />Unlimited number of files can be uploaded to this field.<br />$human_readable_file_limit limit.<br />Allowed types: gif jpg png txt.\n", + 'description' => "Description<br />One file only.<br />$human_readable_file_limit limit.<br />Allowed types: gif jpg png txt.\n", ], 11 => [ '__typename' => 'WebformElementWebformAudioFile', @@ -48,7 +48,7 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase { 'key' => 'audio_files', 'type' => 'webform_audio_file', 'title' => 'Audio files', - 'description' => "Unlimited number of files can be uploaded to this field.<br />$human_readable_audio_file_limit limit.<br />Allowed types: mp3 ogg wav.\n", + 'description' => "Maximum 2 files.<br />$human_readable_audio_file_limit limit.<br />Allowed types: mp3 ogg wav.\n", ], ], ], @@ -72,27 +72,24 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase { 'global limit in bytes' => [ // Webform global file limit set to 3 MB. 3145728, - // The managed file upload is limited to 2 MB on element level. - 2097152, + // The managed file upload is limited to 1 MB on element level. + 1048576, // The audio file upload cannot exceed the global webform limit of 3 MB. 3145728, ], 'global limit exceeding PHP limit' => [ // Webform global file limit set to 8 MB. 8388608, - // The managed file upload is limited to 2 MB on element level. - 2097152, - // The audio file upload will report the global file limit (even though - // the PHP limit is lower and should be used). - // @todo Fix this once the Webform module respects the PHP limit. - // @see https://www.drupal.org/project/webform/issues/3482402 - 8388608, + // The managed file upload is limited to 1 MB on element level. + 1048576, + // The audio file upload cannot exceed the PHP limit of 4 MB. + 4194304, ], 'empty global limit' => [ // Webform global file limit not set. NULL, - // The managed file upload is limited to 2 MB on element level. - 2097152, + // The managed file upload is limited to 1 MB on element level. + 1048576, // The audio file upload cannot exceed the PHP limit of 4 MB. 4194304, ], diff --git a/tests/src/Kernel/Element/MarkupTest.php b/tests/src/Kernel/Element/MarkupTest.php index 77ec7ac012e740936b6d7e17790b94abd2d7fab4..ae5e72f3a3e897c3ab2d2b1a7a3c16cb21d9917c 100644 --- a/tests/src/Kernel/Element/MarkupTest.php +++ b/tests/src/Kernel/Element/MarkupTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Drupal\Tests\graphql_webform\Kernel\Element; -use Drupal\filter\Entity\FilterFormat; use Drupal\Tests\graphql_webform\Kernel\GraphQLWebformKernelTestBase; +use Drupal\filter\Entity\FilterFormat; /** * Tests for the WebformElementMarkup type. diff --git a/tests/src/Kernel/Element/MultipleValuesTest.php b/tests/src/Kernel/Element/MultipleValuesTest.php index 1e550b82ae40ccd5e8ea211accdb93055875147f..2e55da6937fe78ce37dce982892ffa2120fa8711 100644 --- a/tests/src/Kernel/Element/MultipleValuesTest.php +++ b/tests/src/Kernel/Element/MultipleValuesTest.php @@ -64,30 +64,11 @@ class MultipleValuesTest extends GraphQLWebformKernelTestBase { ], ], 4 => ['multipleValues' => NULL], - 6 => [ - 'multipleValues' => [ - 'limit' => -1, - 'message' => NULL, - 'headerLabel' => NULL, - 'minItems' => NULL, - 'emptyItems' => NULL, - 'addMore' => NULL, - 'addMoreItems' => NULL, - 'addMoreButtonLabel' => NULL, - 'addMoreInput' => NULL, - 'addMoreInputLabel' => NULL, - 'itemLabel' => NULL, - 'noItemsMessage' => NULL, - 'sorting' => NULL, - 'operations' => NULL, - 'add' => NULL, - 'remove' => NULL, - ], - ], + 6 => ['multipleValues' => NULL], 8 => ['multipleValues' => NULL], 11 => [ 'multipleValues' => [ - 'limit' => -1, + 'limit' => 2, 'message' => NULL, 'headerLabel' => NULL, 'minItems' => NULL, diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0e6b533add1e5bf49f177666fdf68432ca543abd --- /dev/null +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -0,0 +1,490 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\graphql_webform\Kernel\Mutation; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\File\FileSystemInterface; +use Drupal\file\Entity\File; +use Drupal\file\FileInterface; +use Drupal\file\FileStorageInterface; +use Drupal\webform\Entity\WebformSubmission; +use Symfony\Component\HttpFoundation\Request; + +/** + * Test file uploads with GraphQL. + * + * @group graphql_webform + */ +final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'file', + ]; + + /** + * The file storage. + */ + protected ?FileStorageInterface $fileStorage; + + /** + * The file system. + */ + protected ?FileSystemInterface $fileSystem; + + /** + * An array of test files. + */ + protected array $files = []; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('file'); + $this->installSchema('file', ['file_usage']); + + $this->fileStorage = $this->container->get('entity_type.manager')->getStorage('file'); + $this->fileSystem = $this->container->get('file_system'); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + // Clean up files that were created during the test. + foreach ($this->files as $file) { + if (file_exists($file)) { + unlink($file); + } + } + + parent::tearDown(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + // Register the private stream wrapper so we can test whether files can be + // uploaded to the private filesystem as part of a webform submission. + $container->register('stream_wrapper.private', 'Drupal\Core\StreamWrapper\PrivateStream') + ->addTag('stream_wrapper', ['scheme' => 'private']); + } + + /** + * {@inheritdoc} + */ + protected function setUpFilesystem(): void { + // Set up the private filesystem in addition to the public filesystem. + $public_file_directory = $this->siteDirectory . '/files'; + $private_file_directory = $this->siteDirectory . '/private'; + + mkdir($this->siteDirectory, 0775); + mkdir($this->siteDirectory . '/files', 0775); + mkdir($this->siteDirectory . '/private', 0775); + mkdir($this->siteDirectory . '/files/config/sync', 0775, TRUE); + + $this->setSetting('file_public_path', $public_file_directory); + $this->setSetting('file_private_path', $private_file_directory); + $this->setSetting('config_sync_directory', $this->siteDirectory . '/files/config/sync'); + } + + /** + * Uploading a file using a wrong element name should return an error. + */ + public function testUploadingFileToWrongElement(): void { + $query = $this->getQueryFromFile('submission_with_file_upload.gql'); + $variables = [ + 'elements' => [], + 'files' => [ + // Try to upload to a non-file upload element. + ['element' => 'checkboxes', 'file' => NULL], + ], + 'id' => 'graphql_webform_test_form', + ]; + + $this->assertResults($query, $variables, [ + 'submitWebform' => [ + 'errors' => [ + 'Files cannot be uploaded to the "checkboxes" element since it is not a managed file element.', + ], + 'validationErrors' => [ + [ + 'element' => 'required_text_field', + 'messages' => [ + 'This field is required because it is important.', + ], + ], + ], + 'submission' => NULL, + ], + ]); + } + + /** + * Tests uploading a file as part of a webform submission. + */ + public function testFileUpload(): void { + // Create some test files to upload. + foreach (['txt', 'mp3', 'ogg'] as $extension) { + $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension; + // We are pretending to upload a file into temporary storage. Ensure the + // file exists because the Symfony UploadedFile component will check that. + touch($file); + $this->files[$extension] = $file; + } + + // Create a POST request with the files attached as multipart form data. + $request = Request::create( + uri: '/graphql/test', + method: 'POST', + parameters: [ + 'query' => $this->getQueryFromFile('submission_with_file_upload.gql'), + 'variables' => [ + 'elements' => [(object) ['element' => 'required_text_field', 'value' => 'A value.']], + 'files' => [ + // When using multipart form uploads, the parameter holding the file + // has to be declared NULL. + ['element' => 'file_upload', 'file' => NULL], + ['element' => 'audio_files', 'file' => NULL], + ['element' => 'audio_files', 'file' => NULL], + ], + 'id' => 'graphql_webform_test_form', + ], + // The 'map' parameter is used to map attached files to variables. + // @see https://github.com/jaydenseric/graphql-multipart-request-spec + 'map' => [ + '0' => ['variables.files.0.file'], + '1' => ['variables.files.1.file'], + '2' => ['variables.files.2.file'], + ], + ], + files: [ + '0' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'size' => 0, + 'tmp_name' => $this->files['txt'], + 'error' => UPLOAD_ERR_OK, + ], + '1' => [ + 'name' => 'audio_file.mp3', + 'type' => 'audio/mpeg', + 'size' => 0, + 'tmp_name' => $this->files['mp3'], + 'error' => UPLOAD_ERR_OK, + ], + '2' => [ + 'name' => 'audio_file.ogg', + 'type' => 'audio/ogg', + 'size' => 0, + 'tmp_name' => $this->files['ogg'], + 'error' => UPLOAD_ERR_OK, + ], + ], + ); + + $result = $this->executeMultiPartRequest($request); + + $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.'); + $resultData = $result->data->submitWebform; + + $this->assertEmpty($resultData->errors, 'There are no errors.'); + $this->assertEmpty($resultData->validationErrors, 'There are no validation errors.'); + + $submissionId = $resultData->submission->id; + $this->assertIsInt($submissionId, 'A submission ID is returned.'); + + // Check that the submission entity was created. + $submission = WebformSubmission::load($submissionId); + $this->assertInstanceOf(WebformSubmission::class, $submission, 'A submission entity was created.'); + + // Check that the expected files are associated with the submission. + $expectedUploads = [ + 'file_upload' => [ + ['filename' => 'test.txt', 'mime' => 'text/plain'], + ], + 'audio_files' => [ + ['filename' => 'audio_file.mp3', 'mime' => 'audio/mpeg'], + ['filename' => 'audio_file.ogg', 'mime' => 'audio/ogg'], + ], + ]; + foreach ($expectedUploads as $elementName => $expectedFiles) { + $returnedFileIds = (array) $submission->getElementData($elementName); + $this->assertNotEmpty($returnedFileIds, 'One or more file IDs are associated with the webform submission.'); + $this->assertCount(count($expectedFiles), $returnedFileIds, 'The correct number of files is associated with the webform submission.'); + $expectedFilenames = array_column($expectedFiles, 'filename'); + $expectedMimeTypes = array_column($expectedFiles, 'mime'); + $expectedScheme = $elementName === 'file_upload' ? 'public' : 'private'; + foreach ($expectedFiles as $i => $expectedFile) { + $file = File::load($returnedFileIds[$i]); + $this->assertInstanceOf(FileInterface::class, $file, sprintf('A file entity was created for upload #%d of element "%s".', $i, $elementName)); + $this->assertEquals($expectedFilenames[$i], $file->getFilename(), sprintf('The file for upload #%d of element "%s" has the correct filename "%s".', $i, $elementName, $expectedFilenames[$i])); + $this->assertEquals($expectedMimeTypes[$i], $file->getMimeType(), sprintf('The file for upload #%d of element "%s" has the correct MIME type "%s".', $i, $elementName, $expectedMimeTypes[$i])); + $this->assertEquals(0, $file->getSize(), sprintf('The file for upload #%d of element "%s" has a size of 0 bytes.', $i, $elementName)); + $actualScheme = parse_url($file->getFileUri(), PHP_URL_SCHEME); + $this->assertEquals($expectedScheme, $actualScheme, sprintf('The file for upload #%d of element "%s" is saved in the %s filesystem.', $i, $elementName, $expectedScheme)); + } + } + } + + /** + * Tests uploading files that do not meet the validation criteria. + */ + public function testFileUploadWithValidationErrors(): void { + // Create some test files to upload. + foreach (['txt', 'mp3', 'flac'] as $extension) { + $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension; + touch($this->files[$extension]); + // Create a file with a size of 1.000001MB. This is intended to trigger a + // validation error for the file_upload element which is limited to 1MB. + if ($extension === 'txt') { + file_put_contents($this->files[$extension], str_repeat('a', 1024 * 1024 + 1)); + } + } + + $request = Request::create( + uri: '/graphql/test', + method: 'POST', + parameters: [ + 'query' => $this->getQueryFromFile('submission_with_file_upload.gql'), + 'variables' => [ + 'elements' => [ + (object) [ + 'element' => 'required_text_field', + 'value' => 'A value.', + ], + ], + 'files' => [ + ['element' => 'file_upload', 'file' => NULL], + ['element' => 'audio_files', 'file' => NULL], + ['element' => 'audio_files', 'file' => NULL], + ], + 'id' => 'graphql_webform_test_form', + ], + 'map' => [ + '0' => ['variables.files.0.file'], + '1' => ['variables.files.1.file'], + '2' => ['variables.files.2.file'], + ], + ], + files: [ + '0' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'size' => 1024 * 1024 + 1, + 'tmp_name' => $this->files['txt'], + 'error' => UPLOAD_ERR_OK, + ], + '1' => [ + 'name' => 'audio_file.mp3', + 'type' => 'audio/mpeg', + 'size' => 0, + 'tmp_name' => $this->files['mp3'], + 'error' => UPLOAD_ERR_OK, + ], + '2' => [ + 'name' => 'audio_file.flac', + 'type' => 'audio/flac', + 'size' => 0, + 'tmp_name' => $this->files['flac'], + 'error' => UPLOAD_ERR_OK, + ], + ], + ); + + $result = $this->executeMultiPartRequest($request); + + $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.'); + $resultData = $result->data->submitWebform; + + $this->assertEmpty($resultData->errors, 'There are no errors.'); + $expectedValidationErrors = [ + 'file_upload' => [ + 'The file is <em class="placeholder">1 MB</em> exceeding the maximum file size of <em class="placeholder">1 MB</em>.', + ], + 'audio_files' => [ + 'Only files with the following extensions are allowed: <em class="placeholder">mp3 ogg wav</em>.', + ], + ]; + $this->assertValidationErrors($result, $expectedValidationErrors); + + $this->assertEmpty($resultData->submission, 'No submission was returned.'); + $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.'); + } + + /** + * Tests uploading too many files. + * + * The 'file_upload' field only accepts a single file, and the 'audio_files' + * field accepts up to two files. We will attempt to upload more. + */ + public function testUploadTooManyFiles(): void { + // Create some test files to upload. + foreach (['txt', 'jpg', 'mp3', 'ogg', 'wav'] as $extension) { + $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension; + touch($this->files[$extension]); + } + + $request = Request::create( + uri: '/graphql/test', + method: 'POST', + parameters: [ + 'query' => $this->getQueryFromFile('submission_with_file_upload.gql'), + 'variables' => [ + 'elements' => [ + (object) [ + 'element' => 'required_text_field', + 'value' => 'A value.', + ], + ], + 'files' => [ + ['element' => 'file_upload', 'file' => NULL], + ['element' => 'file_upload', 'file' => NULL], + ['element' => 'audio_files', 'file' => NULL], + ['element' => 'audio_files', 'file' => NULL], + ['element' => 'audio_files', 'file' => NULL], + ], + 'id' => 'graphql_webform_test_form', + ], + 'map' => [ + '0' => ['variables.files.0.file'], + '1' => ['variables.files.1.file'], + '2' => ['variables.files.2.file'], + '3' => ['variables.files.3.file'], + '4' => ['variables.files.4.file'], + ], + ], + files: [ + '0' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'size' => 0, + 'tmp_name' => $this->files['txt'], + 'error' => UPLOAD_ERR_OK, + ], + '1' => [ + 'name' => 'test.jpg', + 'type' => 'image/jpeg', + 'size' => 0, + 'tmp_name' => $this->files['jpg'], + 'error' => UPLOAD_ERR_OK, + ], + '2' => [ + 'name' => 'audio_file.mp3', + 'type' => 'audio/mpeg', + 'size' => 0, + 'tmp_name' => $this->files['mp3'], + 'error' => UPLOAD_ERR_OK, + ], + '3' => [ + 'name' => 'audio_file.ogg', + 'type' => 'audio/ogg', + 'size' => 0, + 'tmp_name' => $this->files['ogg'], + 'error' => UPLOAD_ERR_OK, + ], + '4' => [ + 'name' => 'audio_file.wav', + 'type' => 'audio/wav', + 'size' => 0, + 'tmp_name' => $this->files['wav'], + 'error' => UPLOAD_ERR_OK, + ], + ], + ); + + $result = $this->executeMultiPartRequest($request); + + $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.'); + $resultData = $result->data->submitWebform; + + $this->assertEmpty($resultData->errors, 'There are no errors.'); + + $expectedValidationErrors = [ + 'file_upload' => [ + 'Only one file can be uploaded.', + ], + 'audio_files' => [ + 'The number of files uploaded exceeds the maximum of 2.', + ], + ]; + $this->assertValidationErrors($result, $expectedValidationErrors); + + $this->assertEmpty($resultData->submission, 'No submission was returned.'); + $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.'); + $this->assertEmpty($this->fileStorage->loadMultiple(), 'No file entities were created.'); + } + + /** + * Executes a request and returns the response. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request to execute. + * + * @return object + * The response object. + */ + protected function executeMultiPartRequest(Request $request): object { + $request->headers->add(['content-type' => 'multipart/form-data']); + $response = $this->container->get('http_kernel')->handle($request); + return json_decode($response->getContent()); + } + + /** + * Asserts that the validation errors in the response are as expected. + * + * @param object $result + * The response object. + * @param array $expectedValidationErrors + * An array of expected validation errors. The keys are the element names, + * and the values are arrays of expected validation messages. + */ + protected function assertValidationErrors(object $result, array $expectedValidationErrors): void { + $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.'); + $resultData = $result->data->submitWebform; + $this->assertIsArray($resultData->validationErrors, 'The response contains validation errors.'); + + foreach ($expectedValidationErrors as $elementName => $expectedMessages) { + $elementErrors = array_filter($resultData->validationErrors, static fn ($error): bool => $error->element === $elementName); + $this->assertCount(1, $elementErrors, sprintf('The validation errors for the "%s" element are grouped together.', $elementName)); + $elementError = reset($elementErrors); + $this->assertEquals($elementName, $elementError->element, sprintf('The validation error for the "%s" element has the correct element ID.', $elementName)); + $this->assertCount(count($expectedMessages), $elementError->messages, sprintf('There are %d validation messages for the "%s" element.', count($expectedMessages), $elementName)); + foreach ($expectedMessages as $i => $expectedMessage) { + $this->assertEquals($expectedMessage, $elementError->messages[$i], sprintf('Validation message #%d for the "%s" element is correct.', $i, $elementName)); + } + } + } + +} + +namespace Symfony\Component\HttpFoundation\File; + +/** + * Mock the PHP function is_uploaded_file(). + * + * Since we are not *really* uploading a file through the webserver, PHP will + * not recognize the file as an uploaded file. We mock the function to return + * TRUE for our test files. + * + * @param string $filename + * The filename being checked. + * + * @return bool + * Will return TRUE for our test files. + */ +function is_uploaded_file($filename) { + $temp_dir = \Drupal::service('file_system')->getTempDirectory(); + $prefix = $temp_dir . '/graphql_webform_upload_test.'; + return str_starts_with($filename, $prefix); +} diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3a3f3615060b537278c811b385f5252164c1c1e6 --- /dev/null +++ b/tests/src/Unit/WebformSubmissionResultTest.php @@ -0,0 +1,117 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\graphql_webform\Unit\Model; + +use Drupal\Tests\UnitTestCase; +use Drupal\graphql_webform\Model\WebformSubmissionResult; +use Drupal\webform\WebformSubmissionInterface; + +/** + * @coversDefaultClass \Drupal\graphql_webform\Model\WebformSubmissionResult + * @group graphql_webform + */ +class WebformSubmissionResultTest extends UnitTestCase { + + /** + * @covers ::setSubmission + * @covers ::getSubmission + */ + public function testSubmission() { + $submission = $this->createMock(WebformSubmissionInterface::class); + $result = new WebformSubmissionResult(); + $result->setSubmission($submission); + $this->assertSame($submission, $result->getSubmission()); + } + + /** + * @covers ::addError + * @covers ::getErrors + */ + public function testAddError() { + $result = new WebformSubmissionResult(); + $result->addError('Test error'); + $this->assertEquals(['Test error'], $result->getErrors()); + } + + /** + * @covers ::addValidationError + * @covers ::getOrCreateValidationError + * @covers ::getValidationErrors + * @covers ::hasValidationError + */ + public function testAddValidationError() { + $result = new WebformSubmissionResult(); + $this->assertEmpty($result->getValidationErrors(), 'A new result does not have validation errors.'); + $this->assertFalse($result->hasValidationError('element_id'), 'A new result does not have validation errors for an element.'); + $this->assertFalse($result->hasValidationError(NULL), 'A new result does not have a general validation error.'); + + // Add a first validation error. + $result->addValidationError('Validation error', 'element_id'); + $errors = $result->getValidationErrors(); + $this->assertCount(1, $errors); + $returnedError = reset($errors); + $this->assertEquals(['Validation error'], $returnedError->getMessages()); + $this->assertEquals('element_id', $returnedError->getElementId()); + $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the element.'); + $this->assertFalse($result->hasValidationError('another_element_id'), 'The result does not have a validation error for another element.'); + $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.'); + + // Add another validation error for the same element. + $result->addValidationError('Another validation error', 'element_id'); + $errors = $result->getValidationErrors(); + $this->assertCount(1, $errors); + $returnedError = reset($errors); + $this->assertEquals(['Validation error', 'Another validation error'], $returnedError->getMessages()); + $this->assertEquals('element_id', $returnedError->getElementId()); + $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the element.'); + $this->assertFalse($result->hasValidationError('another_element_id'), 'The result does not have a validation error for another element.'); + $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.'); + + // Add a validation error for a different element. + $result->addValidationError('Yet another validation error', 'another_element_id'); + $errors = $result->getValidationErrors(); + $this->assertCount(2, $errors); + $error_element_1 = $result->getOrCreateValidationError('element_id'); + $this->assertEquals(['Validation error', 'Another validation error'], $error_element_1->getMessages()); + $this->assertEquals('element_id', $error_element_1->getElementId()); + $error_element_2 = $result->getOrCreateValidationError('another_element_id'); + $this->assertEquals(['Yet another validation error'], $error_element_2->getMessages()); + $this->assertEquals('another_element_id', $error_element_2->getElementId()); + $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the first element.'); + $this->assertTrue($result->hasValidationError('another_element_id'), 'The result has a validation error for the second element.'); + $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.'); + + // Add a validation error without an element ID. + $result->addValidationError('Validation error without element ID', NULL); + $errors = $result->getValidationErrors(); + $this->assertCount(3, $errors); + $error_no_element = $result->getOrCreateValidationError(NULL); + $this->assertEquals(['Validation error without element ID'], $error_no_element->getMessages()); + $this->assertNull($error_no_element->getElementId()); + $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the first element.'); + $this->assertTrue($result->hasValidationError('another_element_id'), 'The result has a validation error for the second element.'); + $this->assertTrue($result->hasValidationError(NULL), 'The result has a general validation error.'); + } + + /** + * @covers ::isValid + */ + public function testIsValid() { + $result = new WebformSubmissionResult(); + $this->assertTrue($result->isValid()); + + $result->addError('Test error'); + $this->assertFalse($result->isValid()); + + $result = new WebformSubmissionResult(); + $result->addValidationError('Validation error', 'element_id'); + $this->assertFalse($result->isValid()); + + $result = new WebformSubmissionResult(); + $result->addValidationError('Validation error', NULL); + $this->assertFalse($result->isValid()); + } + +}