From 7fd4d66c466530279e6050855f71bcf646ba5301 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Fri, 31 Jan 2025 16:02:32 +0200 Subject: [PATCH 01/20] Support file uploads in the schema. --- graphql/webform.base.graphqls | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls index 2b04f26..8e78259 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 formdata maps. + """ + file: WebformSubmissionFileUpload! +} -- GitLab From b0d03909263fb4e2ba8f1bff0b334853d3d5ec09 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Mon, 3 Feb 2025 15:21:27 +0200 Subject: [PATCH 02/20] Start porting the file upload handling from the PoC. --- graphql/webform.base.graphqls | 4 +- .../GraphQL/DataProducer/WebformSubmit.php | 33 +++- tests/queries/submission_with_file_upload.gql | 18 +++ .../FormSubmissionWithFileUploadTest.php | 145 ++++++++++++++++++ 4 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/queries/submission_with_file_upload.gql create mode 100644 tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls index 8e78259..508cf59 100644 --- a/graphql/webform.base.graphqls +++ b/graphql/webform.base.graphqls @@ -1,11 +1,11 @@ -scalar WebformSubmissionFileUpload +scalar WebformSubmissionUploadedFile scalar WebformSubmissionValue type Mutation { submitWebform( id: String! elements: [WebformSubmissionElement] - files: [WebformSubmissionFile] + files: [WebformSubmissionUploadedFile] sourceEntityId: String sourceEntityType: String ): WebformSubmissionResult! diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php index d3339da..9ca6ac9 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -40,6 +40,9 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * "elements" = @ContextDefinition("any", * label = @Translation("Array of WebformSubmissionElement objects.") * ), + * "files" = @ContextDefinition("any", + * label = @Translation("Array of WebformSubmissionFile objects.") + * ), * "sourceEntityType" = @ContextDefinition("string", * label = @Translation("Source entity type"), * required = FALSE @@ -109,6 +112,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 +126,12 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl */ public function resolve( string $id, +// @todo Investigate this comment. There seems to be no suppression of coding +// standards warnings. // 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 +139,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,10 +180,24 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl $dataElements[$element['element']] = $element['value']; } + // Handle file uploads. This will add validation messages if they happen. + $filesMapped = $this->handleFileElements($webform, $files, $result); + + // Check if any errors or validations happened during file upload + // handling. + if (!$result->isValid()) { + return $result; + } + + // Build form data for file elements. +// @todo This method is missing. We have no multiple elements. +// @todo This method has been replaced with the simple foreach() loop just above. + $fileElements = $this->buildFormData($filesMapped, $elementsWithMultiple); + // Build the form data object for the form submission. $formData = [ 'webform_id' => $id, - 'data' => $dataElements, + 'data' => array_merge($dataElements, $fileElements), ]; // Only include the source entity type and ID if both are provided. @@ -276,7 +298,10 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $element */ $element = $this->elementManager->getElementInstance($configuration); + $element->prepare($configuration); + // Get file validation and upload settings. +// @todo This? Or the one below? $fileExtensions = $element->getElementProperty($configuration, 'file_extensions'); $maxFilesize = $element->getElementProperty($configuration, 'max_filesize'); $uriScheme = $element->getElementProperty($configuration, 'uri_scheme'); @@ -296,6 +321,10 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl $elementsMultiple[] = $elementKey; } +// @todo This? Or the one above? + $maxFilesize = $configuration['#upload_validators']['file_validate_size'][0]; + $fileExtensions = $configuration['#upload_validators']['file_validate_extensions'][0]; + // Perform the upload. $upload = $this->fileUpload->saveFileUpload($item['file'], [ 'file_extensions' => $fileExtensions, diff --git a/tests/queries/submission_with_file_upload.gql b/tests/queries/submission_with_file_upload.gql new file mode 100644 index 0000000..2c6166e --- /dev/null +++ b/tests/queries/submission_with_file_upload.gql @@ -0,0 +1,18 @@ +mutation submit( + $id: String!, + $elements: [WebformSubmissionElement]!, + $files: [WebformSubmissionUploadedFile]!, +) { + submitWebform( + id: $id, + elements: $elements, + files: $files, + ) { + submission { + id + webform { + id + } + } + } +} diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php new file mode 100644 index 0000000..a99d84e --- /dev/null +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -0,0 +1,145 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\graphql_webform\Kernel\Mutation; + +use Drupal\Core\File\FileSystemInterface; +use Drupal\file\FileInterface; +use Drupal\file\FileStorageInterface; +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. + * + * @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() { + // 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('submission_with_file_upload.gql'), + 'variables' => [ + // When using multipart form uploads, the variable holding the files has + // to be declared NULL. + 'files' => NULL, + 'webform_element_id' => 'file_upload', + 'id' => 'graphql_webform_test_form', + ], + // Then map the name used for the file upload to the variable. + // @todo How to handle multiple files? + 'map' => [ + 'test' => ['variables.files'], + ], + ], [], [ + '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; +} -- GitLab From 163f909aa50a14a336597963bf940fb1888bd35f Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Tue, 4 Feb 2025 14:56:31 +0200 Subject: [PATCH 03/20] Work on test. --- graphql/webform.base.graphqls | 6 +++--- tests/queries/submission_with_file_upload.gql | 2 +- .../Mutation/FormSubmissionWithFileUploadTest.php | 11 ++++++----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls index 508cf59..0241391 100644 --- a/graphql/webform.base.graphqls +++ b/graphql/webform.base.graphqls @@ -1,11 +1,11 @@ -scalar WebformSubmissionUploadedFile +scalar Upload scalar WebformSubmissionValue type Mutation { submitWebform( id: String! elements: [WebformSubmissionElement] - files: [WebformSubmissionUploadedFile] + files: [WebformSubmissionFile] sourceEntityId: String sourceEntityType: String ): WebformSubmissionResult! @@ -65,5 +65,5 @@ input WebformSubmissionFile { The file to upload. This is usually null and the file is appended via multipart formdata maps. """ - file: WebformSubmissionFileUpload! + file: Upload! } diff --git a/tests/queries/submission_with_file_upload.gql b/tests/queries/submission_with_file_upload.gql index 2c6166e..e4b326c 100644 --- a/tests/queries/submission_with_file_upload.gql +++ b/tests/queries/submission_with_file_upload.gql @@ -1,7 +1,7 @@ mutation submit( $id: String!, $elements: [WebformSubmissionElement]!, - $files: [WebformSubmissionUploadedFile]!, + $files: [WebformSubmissionFile]!, ) { submitWebform( id: $id, diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index a99d84e..30c2d2e 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -59,22 +59,23 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { touch($file); // Create a post request with file contents. - $request = Request::create('/graphql', 'POST', [ + $request = Request::create('/graphql/test', 'POST', [ 'query' => $this->getQueryFromFile('submission_with_file_upload.gql'), 'variables' => [ // When using multipart form uploads, the variable holding the files has // to be declared NULL. - 'files' => NULL, + 'files' => [], + 'elements' => [], 'webform_element_id' => 'file_upload', 'id' => 'graphql_webform_test_form', ], // Then map the name used for the file upload to the variable. // @todo How to handle multiple files? 'map' => [ - 'test' => ['variables.files'], + 'file' => ['variables.files.0'], ], - ], [], [ - 'test' => [ + ], [], files: [ + 'file' => [ 'name' => 'test.txt', 'type' => 'text/plain', 'size' => 42, -- GitLab From 05da548a49c7d9c207cff9365935f633d63e8e1b Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Wed, 5 Feb 2025 14:05:44 +0200 Subject: [PATCH 04/20] Test that an error is returned when trying to associate an uploaded file with the wrong element. --- graphql/webform.base.graphqls | 2 +- .../GraphQL/DataProducer/WebformSubmit.php | 11 ++- .../SchemaExtension/WebformExtension.php | 1 + tests/queries/submission_with_file_upload.gql | 5 ++ .../FormSubmissionWithFileUploadTest.php | 78 +++++++++++++------ 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls index 0241391..f53d89c 100644 --- a/graphql/webform.base.graphqls +++ b/graphql/webform.base.graphqls @@ -65,5 +65,5 @@ input WebformSubmissionFile { The file to upload. This is usually null and the file is appended via multipart formdata maps. """ - file: Upload! + file: Upload } diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php index 9ca6ac9..52a9ad8 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -185,6 +185,9 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // Check if any errors or validations happened during file upload // handling. + // @todo Maybe we should not exit early here but also perform the + // remaining validation so the GraphQL client can see all errors at + // once. if (!$result->isValid()) { return $result; } @@ -274,7 +277,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // Contains the mapped submission values. The original $files array // contains the actual UploadedFile object. The mapped item contains the - // FID, if upload was successfull. + // FID, if upload was successful. $filesMapped = []; $elementsMultiple = []; @@ -285,10 +288,10 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl foreach ($files as $item) { $elementKey = $item['element']; - // Check if the element is actually a managed file. + // Check if the element is actually a managed file element. 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.', $item['element']); + $result->addError($error); continue; } diff --git a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php index 1f7de8f..bb73323 100644 --- a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php +++ b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php @@ -375,6 +375,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/tests/queries/submission_with_file_upload.gql b/tests/queries/submission_with_file_upload.gql index e4b326c..3f37a8d 100644 --- a/tests/queries/submission_with_file_upload.gql +++ b/tests/queries/submission_with_file_upload.gql @@ -8,6 +8,11 @@ mutation submit( elements: $elements, files: $files, ) { + errors + validationErrors { + element + messages + } submission { id webform { diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index 30c2d2e..0074273 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -49,6 +49,31 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $this->fileSystem = $this->container->get('file_system'); } + /** + * Uploading a file using a wrong element name should return an error. + */ + public function testUploadingFileToWrongElement() { + $query = $this->getQueryFromFile('submission_with_file_upload.gql'); + $variables = [ + 'elements' => [], + 'files' => [ + // Try to upload to a non-file upload element. + (object) ['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' => [], + 'submission' => NULL, + ], + ]); + } + /** * Tests uploading a file through the webformFileUpload mutation. */ @@ -59,35 +84,44 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { touch($file); // Create a post request with file contents. - $request = Request::create('/graphql/test', 'POST', [ - 'query' => $this->getQueryFromFile('submission_with_file_upload.gql'), - 'variables' => [ - // When using multipart form uploads, the variable holding the files has - // to be declared NULL. - 'files' => [], - 'elements' => [], - 'webform_element_id' => 'file_upload', - 'id' => 'graphql_webform_test_form', - ], - // Then map the name used for the file upload to the variable. - // @todo How to handle multiple files? - 'map' => [ - 'file' => ['variables.files.0'], + $request = Request::create( + uri: '/graphql/test', + method: 'POST', + parameters: [ + 'query' => $this->getQueryFromFile('submission_with_file_upload.gql'), + 'variables' => [ + 'elements' => [], + 'files' => [ + // When using multipart form uploads, the parameter holding the file + // has to be declared NULL. + (object) ['element' => 'file_upload', 'file' => NULL], + ], + 'id' => 'graphql_webform_test_form', + ], + // The 'map' parameter is used to map uploaded files to variables. + // @see https://github.com/jaydenseric/graphql-multipart-request-spec + 'map' => [ + '0' => ['variables.files.0.file'], + ], ], - ], [], files: [ - 'file' => [ - 'name' => 'test.txt', - 'type' => 'text/plain', - 'size' => 42, - 'tmp_name' => $file, - 'error' => UPLOAD_ERR_OK, + files: [ + '0' => [ + 'name' => 'graphql_webform_upload_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()); + // The result should not have any errors. + $this->assertObjectNotHasProperty('errors', $result); + // Check that the file ID was returned. $returned_fid = $result->data->webformFileUpload->fid ?? NULL; $this->assertIsInt($returned_fid); -- GitLab From dca1bcc0fc9c485890d975daf1cf7300a9357101 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Wed, 5 Feb 2025 16:06:20 +0200 Subject: [PATCH 05/20] Update documentation. --- .../GraphQL/DataProducer/WebformSubmit.php | 18 ++++++++---------- .../FormSubmissionWithFileUploadTest.php | 3 +-- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php index 52a9ad8..0e86720 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -281,6 +281,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl $filesMapped = []; $elementsMultiple = []; + // @todo This array is unused. $elementsSingle = []; $input = []; @@ -290,27 +291,23 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // Check if the element is actually a managed file element. if (!in_array($elementKey, $managedFileElements)) { - $error = sprintf('Files cannot be uploaded to the %s element since it is not a managed file element.', $item['element']); + $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $item['element']); $result->addError($error); continue; } - // Get the configuration for the element. + // Get the initialized render element. $configuration = $webform->getElement($item['element']); - /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $element */ $element = $this->elementManager->getElementInstance($configuration); - $element->prepare($configuration); // Get file validation and upload settings. -// @todo This? Or the one below? - $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) { + // @todo This variable is unused. $elementsSingle[] = $elementKey; // Check if we already have one file for this element. @@ -324,9 +321,10 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl $elementsMultiple[] = $elementKey; } -// @todo This? Or the one above? - $maxFilesize = $configuration['#upload_validators']['file_validate_size'][0]; - $fileExtensions = $configuration['#upload_validators']['file_validate_extensions'][0]; + // The methods to retrieve the file size and allowed extensions are + // protected, so we get them from the render array instead. + // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getFileExtensions() + // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getMaxFileSize() // Perform the upload. $upload = $this->fileUpload->saveFileUpload($item['file'], [ diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index 0074273..b954659 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -66,7 +66,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $this->assertResults($query, $variables, [ 'submitWebform' => [ 'errors' => [ - 'Files cannot be uploaded to the checkboxes element since it is not a managed file element.', + 'Files cannot be uploaded to the "checkboxes" element since it is not a managed file element.', ], 'validationErrors' => [], 'submission' => NULL, @@ -132,7 +132,6 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $this->assertEquals('test.txt', $file->getFilename()); $this->assertEquals('text/plain', $file->getMimeType()); } - } namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations; -- GitLab From b2152715bda3b70fcfda571d325e3e665414b40c Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Wed, 5 Feb 2025 21:44:11 +0200 Subject: [PATCH 06/20] Work on the test for the happy path. --- composer.json | 2 +- .../GraphQL/DataProducer/WebformSubmit.php | 123 +++++++++++++++++- ...form.webform.graphql_webform_test_form.yml | 3 +- .../FormSubmissionWithFileUploadTest.php | 41 ++++-- 4 files changed, 147 insertions(+), 22 deletions(-) diff --git a/composer.json b/composer.json index 678a96f..30dbb95 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/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php index 0e86720..e33258b 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -195,7 +195,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // Build form data for file elements. // @todo This method is missing. We have no multiple elements. // @todo This method has been replaced with the simple foreach() loop just above. - $fileElements = $this->buildFormData($filesMapped, $elementsWithMultiple); + $fileElements = $this->buildFormData($filesMapped, []); // Build the form data object for the form submission. $formData = [ @@ -241,6 +241,112 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl return $renderResult; } + /** + * Build the form submission data from the WebformSubmissionElement array. + * + * To keep frontend implementations easy the submission values are provided + * as an array of elements with their input name and value as keys. + * Using this we can simulate a POST body request by combining everything to + * a query string and letting PHP do the parsing. + * + * The input array might look like this: + * [ + * [ + * "element" => "message", + * "value" => "Hello", + * ], + * [ + * "element" => "users[0][name]", + * "value" => "John", + * ], + * [ + * "element" => "users[0][email]", + * "value" => "foobar@domain.com", + * ], + * [ + * "element" => "users[1][name]", + * "value" => "Wayne", + * ], + * [ + * "element" => "users[1][email]", + * "value" => "baz@domain.com", + * ], + * ] + * + * This is first converted to a query string: + * + * message=Hello&users[0][name]=John&users[0][email]=foobar@domain.com&users[1][name]=Wayne&users[1][email]=baz@domain.com + * + * And then parsed and converted to an array: + * + * [ + * "message" => "Hello", + * "users" => [ + * [ + * "name" => "John", + * "email" => "foobar@domain.com", + * ], + * [ + * "name" => "Wayne", + * "email" => "baz@domain.com", + * ] + * ], + * ] + * + * This array can now be used to create a webform submission. + * + * @param array $items + * The WebformSubmissionElement array. + * @param array $elementsWithMultiple + * The keys of elements that support multiple values. + * + * @return array + * The webform submission data. + */ + private function buildFormData(array $items, array $elementsWithMultiple): array { + // Collect all query values. + $parts = []; + + $nameCount = []; + + foreach ($items as $item) { + $name = $item['element']; + + // Assume the key to be the name of the element by default. + $key = $name; + + // If the key is for an element that expects multiple values we have + // to make sure to provide an array at the end. The fragile webform code + // just assumes it's an array without checking. + if (in_array($name, $elementsWithMultiple)) { + if (empty($nameCount[$name])) { + $nameCount[$name] = 0; + } + + // Get the current count. + $count = $nameCount[$name]; + + // Build the key using the current count for this element. + $key = $name . "[$count]"; + + // Increment the count for the next item. + $nameCount[$name] = $count + 1; + } + + // e.g. textfield=My+Text or checkboxes_field[0]=option_1. + $parts[] = $key . '=' . urlencode($item['value']); + } + + // Create the query string. + $str = implode('&', $parts); + + // Parse the query string to an array. + $result = []; + parse_str($str, $result); + + return $result; + } + /** * Populates errors on the result as WebformSubmissionValidationError objects. * @@ -297,14 +403,15 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl } // Get the initialized render element. - $configuration = $webform->getElement($item['element']); - /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $element */ - $element = $this->elementManager->getElementInstance($configuration); - $element->prepare($configuration); + $element = $webform->getElement($item['element']); + /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $instance */ + $instance = $this->elementManager->getElementInstance($element); + $instance->prepare($element); // Get file validation and upload settings. - $uriScheme = $element->getElementProperty($configuration, 'uri_scheme'); - $multiple = $element->getElementProperty($configuration, 'multiple'); + $uriScheme = $instance->getElementProperty($element, 'uri_scheme'); + $multiple = $instance->getElementProperty($element, 'multiple'); + $multiple = $instance->hasMultipleValues($element); if ($multiple === FALSE || $multiple === NULL || $multiple === 1) { // @todo This variable is unused. @@ -325,6 +432,8 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // protected, so we get them from the render array instead. // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getFileExtensions() // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getMaxFileSize() + $maxFilesize = $element['#upload_validators']['FileSizeLimit']['fileLimit']; + $fileExtensions = $element['#upload_validators']['FileExtension']['extensions']; // Perform the upload. $upload = $this->fileUpload->saveFileUpload($item['file'], [ 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 6ebbd17..1407657 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 @@ -102,7 +102,8 @@ elements: |- audio_files: '#type': webform_audio_file '#title': 'Audio files' - '#multiple': true + '#multiple': 2 + '#max_filesize': '1' '#file_extensions': '' select_with_custom_empty_option: '#type': select diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index b954659..c09f1e9 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace Drupal\Tests\graphql_webform\Kernel\Mutation; 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; /** @@ -44,6 +46,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { 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'); @@ -90,11 +93,11 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { parameters: [ 'query' => $this->getQueryFromFile('submission_with_file_upload.gql'), 'variables' => [ - 'elements' => [], + '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. - (object) ['element' => 'file_upload', 'file' => NULL], + ['element' => 'file_upload', 'file' => NULL], ], 'id' => 'graphql_webform_test_form', ], @@ -119,22 +122,34 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $response = $this->container->get('http_kernel')->handle($request); $result = json_decode($response->getContent()); - // The result should not have any errors. - $this->assertObjectNotHasProperty('errors', $result); + $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.'); + $metadata = $result->data->submitWebform; - // Check that the file ID was returned. - $returned_fid = $result->data->webformFileUpload->fid ?? NULL; - $this->assertIsInt($returned_fid); + $this->assertEmpty($metadata->errors, 'There are no errors.'); + $this->assertEmpty($metadata->validationErrors, 'There are no validation errors.'); - // 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()); + $submissionId = $metadata->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 file was associated with the submission. + $returnedFileIds = $submission->getElementData('file_upload'); + $this->assertIsArray($returnedFileIds, 'An array of file IDs is associated with the webform submission.'); + $this->assertCount(1, $returnedFileIds, 'A single file is associated with the webform submission.'); + + $fileId = reset($returnedFileIds); + $file = File::load($fileId); + $this->assertInstanceOf(FileInterface::class, $file, 'A file entity was created.'); + $this->assertEquals('graphql_webform_upload_test.txt', $file->getFilename(), 'The file has the correct filename.'); + $this->assertEquals('text/plain', $file->getMimeType(), 'The file has the correct MIME type.'); + $this->assertEquals(0, $file->getSize(), 'The file has a size of 0 bytes.'); } } -namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations; +namespace Symfony\Component\HttpFoundation\File; /** * Mock the PHP function is_uploaded_file(). -- GitLab From 61ef710241ec43bbb13e69aa6b8b2db9174532a0 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Wed, 5 Feb 2025 21:47:41 +0200 Subject: [PATCH 07/20] Rename the Upload scalar to avoid conflicts with other extensions which might want to upload stuff. --- graphql/webform.base.graphqls | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls index f53d89c..7ced00a 100644 --- a/graphql/webform.base.graphqls +++ b/graphql/webform.base.graphqls @@ -1,4 +1,4 @@ -scalar Upload +scalar WebformSubmissionFileUpload scalar WebformSubmissionValue type Mutation { @@ -63,7 +63,7 @@ input WebformSubmissionFile { """ The file to upload. This is usually null and the file is appended via - multipart formdata maps. + multipart form data maps. """ - file: Upload + file: WebformSubmissionFileUpload } -- GitLab From e2e6969145472131b6a74fa5321739d02c23ef88 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Thu, 6 Feb 2025 12:33:34 +0200 Subject: [PATCH 08/20] Complete the test for the happy path. --- .../FormSubmissionWithFileUploadTest.php | 116 ++++++++++++++---- 1 file changed, 91 insertions(+), 25 deletions(-) diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index c09f1e9..d7d3300 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -4,6 +4,7 @@ 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; @@ -52,6 +53,33 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $this->fileSystem = $this->container->get('file_system'); } + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + $container->register('stream_wrapper.private', 'Drupal\Core\StreamWrapper\PrivateStream') + ->addTag('stream_wrapper', ['scheme' => 'private']); + } + + /** + * {@inheritdoc} + */ + protected function setUpFilesystem() { + $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. */ @@ -81,10 +109,14 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { * Tests uploading a file through the webformFileUpload mutation. */ public function testFileUpload() { - // 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 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); + $files[] = $file; + } // Create a post request with file contents. $request = Request::create( @@ -98,6 +130,8 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { // 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', ], @@ -105,14 +139,30 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { // @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' => 'graphql_webform_upload_test.txt', + 'name' => 'test.txt', 'type' => 'text/plain', - 'size' => 42, - 'tmp_name' => $file, + 'size' => 0, + 'tmp_name' => $files[0], + 'error' => UPLOAD_ERR_OK, + ], + '1' => [ + 'name' => 'audio_file.mp3', + 'type' => 'audio/mpeg', + 'size' => 0, + 'tmp_name' => $files[1], + 'error' => UPLOAD_ERR_OK, + ], + '2' => [ + 'name' => 'audio_file.ogg', + 'type' => 'audio/ogg', + 'size' => 0, + 'tmp_name' => $files[2], 'error' => UPLOAD_ERR_OK, ], ], @@ -123,29 +173,45 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $result = json_decode($response->getContent()); $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.'); - $metadata = $result->data->submitWebform; + $resultData = $result->data->submitWebform; - $this->assertEmpty($metadata->errors, 'There are no errors.'); - $this->assertEmpty($metadata->validationErrors, 'There are no validation errors.'); + $this->assertEmpty($resultData->errors, 'There are no errors.'); + $this->assertEmpty($resultData->validationErrors, 'There are no validation errors.'); - $submissionId = $metadata->submission->id; + $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 file was associated with the submission. - $returnedFileIds = $submission->getElementData('file_upload'); - $this->assertIsArray($returnedFileIds, 'An array of file IDs is associated with the webform submission.'); - $this->assertCount(1, $returnedFileIds, 'A single file is associated with the webform submission.'); - - $fileId = reset($returnedFileIds); - $file = File::load($fileId); - $this->assertInstanceOf(FileInterface::class, $file, 'A file entity was created.'); - $this->assertEquals('graphql_webform_upload_test.txt', $file->getFilename(), 'The file has the correct filename.'); - $this->assertEquals('text/plain', $file->getMimeType(), 'The file has the correct MIME type.'); - $this->assertEquals(0, $file->getSize(), 'The file has a size of 0 bytes.'); + // 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)); + } + } } } @@ -156,7 +222,7 @@ namespace Symfony\Component\HttpFoundation\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. + * TRUE for our test files. * * @param string $filename * The filename being checked. @@ -166,8 +232,8 @@ namespace Symfony\Component\HttpFoundation\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; + $prefix = $temp_dir . '/graphql_webform_upload_test.'; + return str_starts_with($filename, $prefix); } namespace Drupal\Core\File; -- GitLab From 38ff968844769e74ebffb0c299298365f80da5d9 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Thu, 6 Feb 2025 15:18:50 +0200 Subject: [PATCH 09/20] Allow to filter validation errors by element. --- src/Model/WebformSubmissionResult.php | 11 ++- .../src/Unit/WebformSubmissionResultTest.php | 71 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/src/Unit/WebformSubmissionResultTest.php diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php index 28efb43..65cdc2a 100644 --- a/src/Model/WebformSubmissionResult.php +++ b/src/Model/WebformSubmissionResult.php @@ -87,11 +87,18 @@ class WebformSubmissionResult { /** * Returns the list of validation errors. * + * @param string|null $elementId + * The ID of the Webform element the messages refer to. If omitted, all + * validation errors are returned. + * * @return \Drupal\graphql_webform\Model\WebformSubmissionValidationError[] * The validation errors. */ - public function getValidationErrors(): array { - return $this->validationErrors; + public function getValidationErrors(?string $elementId = NULL): array { + if (empty($elementId)) { + return $this->validationErrors; + } + return array_values(array_filter($this->validationErrors, static fn (WebformSubmissionValidationError $error): bool => $error->getElementId() === $elementId)); } /** diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php new file mode 100644 index 0000000..3ecf334 --- /dev/null +++ b/tests/src/Unit/WebformSubmissionResultTest.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\graphql_webform\Unit\Model; + +use Drupal\graphql_webform\Model\WebformSubmissionResult; +use Drupal\graphql_webform\Model\WebformSubmissionValidationError; +use Drupal\Tests\UnitTestCase; +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 ::getValidationErrors + */ + public function testAddValidationError() { + $result = new WebformSubmissionResult(); + $validationError = new WebformSubmissionValidationError(['Validation error'], 'element_id'); + $validationError2 = new WebformSubmissionValidationError(['Validation error 2'], 'element_id2'); + $result->addValidationError($validationError); + $this->assertEquals([$validationError], $result->getValidationErrors()); + $result->addValidationError($validationError2); + $this->assertEquals([$validationError, $validationError2], $result->getValidationErrors()); + $this->assertEquals([$validationError], $result->getValidationErrors('element_id')); + $this->assertEquals([$validationError2], $result->getValidationErrors('element_id2')); + } + + /** + * @covers ::isValid + */ + public function testIsValid() { + $result = new WebformSubmissionResult(); + $this->assertTrue($result->isValid()); + + $result->addError('Test error'); + $this->assertFalse($result->isValid()); + + $result = new WebformSubmissionResult(); + $validationError = new WebformSubmissionValidationError(['Validation error'], 'element_id'); + $result->addValidationError($validationError); + $this->assertFalse($result->isValid()); + } + +} -- GitLab From 4dd2faad01cb72a724888b805b7cb2b25b77d6f7 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Thu, 6 Feb 2025 15:47:47 +0200 Subject: [PATCH 10/20] Improve DX of adding validation errors. Do not require an object to be instantiated. --- src/Model/WebformSubmissionResult.php | 40 +++++++++----- .../GraphQL/DataProducer/WebformSubmit.php | 15 ++--- .../src/Unit/WebformSubmissionResultTest.php | 55 +++++++++++++++---- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php index 65cdc2a..bc09f35 100644 --- a/src/Model/WebformSubmissionResult.php +++ b/src/Model/WebformSubmissionResult.php @@ -87,28 +87,42 @@ class WebformSubmissionResult { /** * Returns the list of validation errors. * - * @param string|null $elementId - * The ID of the Webform element the messages refer to. If omitted, all - * validation errors are returned. - * * @return \Drupal\graphql_webform\Model\WebformSubmissionValidationError[] * The validation errors. */ - public function getValidationErrors(?string $elementId = NULL): array { - if (empty($elementId)) { - return $this->validationErrors; - } - return array_values(array_filter($this->validationErrors, static fn (WebformSubmissionValidationError $error): bool => $error->getElementId() === $elementId)); + public function getValidationErrors(): array { + 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 getValidationError(?string $elementId): WebformSubmissionValidationError { + return $this->validationErrors[$elementId] ?? new WebformSubmissionValidationError([], $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->getValidationError($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 e33258b..5a35e7b 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -13,7 +13,6 @@ 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; @@ -356,11 +355,11 @@ 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); } } @@ -419,8 +418,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // 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); + $result->addValidationError('Only one file is allowed', $item['element']); continue; } } @@ -448,19 +446,16 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // Convert the upload violations to validation errors. if (!empty($violations)) { - $validation = new WebformSubmissionValidationError([], $item['element']); foreach ($violations as $violation) { - $validation->addMessage($violation['message']); + $result->addValidationError($violation['message'], $item['element']); } - $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); + $result->addValidationError('Unexpected error while uploading file.', $item['element']); continue; } diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php index 3ecf334..31b3203 100644 --- a/tests/src/Unit/WebformSubmissionResultTest.php +++ b/tests/src/Unit/WebformSubmissionResultTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace Drupal\Tests\graphql_webform\Unit\Model; -use Drupal\graphql_webform\Model\WebformSubmissionResult; -use Drupal\graphql_webform\Model\WebformSubmissionValidationError; use Drupal\Tests\UnitTestCase; +use Drupal\graphql_webform\Model\WebformSubmissionResult; use Drupal\webform\WebformSubmissionInterface; /** @@ -38,18 +37,47 @@ class WebformSubmissionResultTest extends UnitTestCase { /** * @covers ::addValidationError + * @covers ::getValidationError * @covers ::getValidationErrors */ public function testAddValidationError() { $result = new WebformSubmissionResult(); - $validationError = new WebformSubmissionValidationError(['Validation error'], 'element_id'); - $validationError2 = new WebformSubmissionValidationError(['Validation error 2'], 'element_id2'); - $result->addValidationError($validationError); - $this->assertEquals([$validationError], $result->getValidationErrors()); - $result->addValidationError($validationError2); - $this->assertEquals([$validationError, $validationError2], $result->getValidationErrors()); - $this->assertEquals([$validationError], $result->getValidationErrors('element_id')); - $this->assertEquals([$validationError2], $result->getValidationErrors('element_id2')); + $this->assertEmpty($result->getValidationErrors(), 'A new result does not have validation errors.'); + + // 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()); + + // 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()); + + // 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->getValidationError('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->getValidationError('another_element_id'); + $this->assertEquals(['Yet another validation error'], $error_element_2->getMessages()); + $this->assertEquals('another_element_id', $error_element_2->getElementId()); + + // 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->getValidationError(NULL); + $this->assertEquals(['Validation error without element ID'], $error_no_element->getMessages()); + $this->assertNull($error_no_element->getElementId()); } /** @@ -63,8 +91,11 @@ class WebformSubmissionResultTest extends UnitTestCase { $this->assertFalse($result->isValid()); $result = new WebformSubmissionResult(); - $validationError = new WebformSubmissionValidationError(['Validation error'], 'element_id'); - $result->addValidationError($validationError); + $result->addValidationError('Validation error', 'element_id'); + $this->assertFalse($result->isValid()); + + $result = new WebformSubmissionResult(); + $result->addValidationError('Validation error', NULL); $this->assertFalse($result->isValid()); } -- GitLab From 4712144e84f21e614a41620743f895b88eca7615 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Thu, 6 Feb 2025 16:35:38 +0200 Subject: [PATCH 11/20] Test that validation errors are returned for disallowed extensions and oversized files. --- .../FormSubmissionWithFileUploadTest.php | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index d7d3300..9518bf9 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -59,6 +59,8 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { public function register(ContainerBuilder $container) { 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']); } @@ -67,6 +69,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { * {@inheritdoc} */ protected function setUpFilesystem() { + // Set up the private filesystem in addition to the public filesystem. $public_file_directory = $this->siteDirectory . '/files'; $private_file_directory = $this->siteDirectory . '/private'; @@ -213,6 +216,110 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { } } } + + /** + * Test uploading a file with some validation errors. + * + * - The file_upload file is using the .webp extension which is not allowed. + * - The first of the audio_files files is larger than the 1MB limit. + * - The second of the audio_files files is using the .flac extension which is not allowed. + */ + public function testFileUploadWithValidationErrors() { + // Create some test files to upload. + $files = []; + foreach (['webp', 'mp3', 'flac'] as $extension) { + $files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension; + touch($files[$extension]); + // Create a file with a size of 1.000001MB. This is intended to trigger a + // validation error for the audio_files element which is limited to 1MB. + if ($extension === 'mp3') { + file_put_contents($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.webp', + 'type' => 'image/webp', + 'size' => 0, + 'tmp_name' => $files['webp'], + 'error' => UPLOAD_ERR_OK, + ], + '1' => [ + 'name' => 'audio_file.mp3', + 'type' => 'audio/mpeg', + 'size' => 1024 * 1024 + 1, + 'tmp_name' => $files['mp3'], + 'error' => UPLOAD_ERR_OK, + ], + '2' => [ + 'name' => 'audio_file.flac', + 'type' => 'audio/flac', + 'size' => 0, + 'tmp_name' => $files['flac'], + '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()); + + $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->assertCount(3, $resultData->validationErrors, 'There are 3 validation errors.'); + $expectedValidationErrors = [ + 'file_upload' => [ + 'Only files with the following extensions are allowed: <em class="placeholder">gif jpg png txt</em>.' + ], + 'audio_files' => [ + 'The file is <em class="placeholder">1 MB</em> exceeding the maximum file size of <em class="placeholder">1 MB</em>.', + 'Only files with the following extensions are allowed: <em class="placeholder">mp3 ogg wav</em>.', + ], + ]; + 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)); + } + } + + $this->assertEmpty($resultData->submission, 'No submission was returned.'); + $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.'); + } + } namespace Symfony\Component\HttpFoundation\File; @@ -254,6 +361,8 @@ namespace Drupal\Core\File; * Will return TRUE for our test file. */ function move_uploaded_file($filename, $destination) { + // @todo + throw new \NotImplementedException(); $temp_dir = \Drupal::service('file_system')->getTempDirectory(); $test_file = $temp_dir . '/graphql_webform_upload_test.txt'; return $filename === $test_file; -- GitLab From 7232703056849144956aaefa97a3d867549bf74c Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Thu, 6 Feb 2025 16:49:09 +0200 Subject: [PATCH 12/20] Clean up files created during tests so they don't interfere with subsequent tests. --- .../FormSubmissionWithFileUploadTest.php | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index 9518bf9..efd21d3 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -28,18 +28,19 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { /** * The file storage. - * - * @var \Drupal\file\FileStorageInterface|null */ protected ?FileStorageInterface $fileStorage; /** * The file system. - * - * @var \Drupal\Core\File\FileSystemInterface|null */ protected ?FileSystemInterface $fileSystem; + /** + * An array of test files. + */ + protected array $files = []; + /** * {@inheritdoc} */ @@ -53,6 +54,20 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $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} */ @@ -118,7 +133,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { // We are pretending to upload a file into temporary storage. Ensure the // file exists because the Symfony UploadedFile component will check that. touch($file); - $files[] = $file; + $this->files[$extension] = $file; } // Create a post request with file contents. @@ -151,21 +166,21 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { 'name' => 'test.txt', 'type' => 'text/plain', 'size' => 0, - 'tmp_name' => $files[0], + 'tmp_name' => $this->files['txt'], 'error' => UPLOAD_ERR_OK, ], '1' => [ 'name' => 'audio_file.mp3', 'type' => 'audio/mpeg', 'size' => 0, - 'tmp_name' => $files[1], + 'tmp_name' => $this->files['mp3'], 'error' => UPLOAD_ERR_OK, ], '2' => [ 'name' => 'audio_file.ogg', 'type' => 'audio/ogg', 'size' => 0, - 'tmp_name' => $files[2], + 'tmp_name' => $this->files['ogg'], 'error' => UPLOAD_ERR_OK, ], ], @@ -226,14 +241,13 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { */ public function testFileUploadWithValidationErrors() { // Create some test files to upload. - $files = []; foreach (['webp', 'mp3', 'flac'] as $extension) { - $files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension; - touch($files[$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 audio_files element which is limited to 1MB. if ($extension === 'mp3') { - file_put_contents($files[$extension], str_repeat('a', 1024 * 1024 + 1)); + file_put_contents($this->files[$extension], str_repeat('a', 1024 * 1024 + 1)); } } @@ -267,21 +281,21 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { 'name' => 'test.webp', 'type' => 'image/webp', 'size' => 0, - 'tmp_name' => $files['webp'], + 'tmp_name' => $this->files['webp'], 'error' => UPLOAD_ERR_OK, ], '1' => [ 'name' => 'audio_file.mp3', 'type' => 'audio/mpeg', 'size' => 1024 * 1024 + 1, - 'tmp_name' => $files['mp3'], + 'tmp_name' => $this->files['mp3'], 'error' => UPLOAD_ERR_OK, ], '2' => [ 'name' => 'audio_file.flac', 'type' => 'audio/flac', 'size' => 0, - 'tmp_name' => $files['flac'], + 'tmp_name' => $this->files['flac'], 'error' => UPLOAD_ERR_OK, ], ], -- GitLab From f789f828aeaa2311cced382e037f66f532739310 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Thu, 6 Feb 2025 21:50:27 +0200 Subject: [PATCH 13/20] Improve test coverage. --- ...form.webform.graphql_webform_test_form.yml | 1 - .../FormSubmissionWithFileUploadTest.php | 174 ++++++++++++++++-- 2 files changed, 154 insertions(+), 21 deletions(-) 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 1407657..46d743d 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,7 +55,6 @@ elements: |- file_upload: '#type': managed_file '#title': 'File upload' - '#multiple': true '#description': Description '#help_title': 'Help title' '#help': 'Help text' diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index efd21d3..e5d30b5 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -71,7 +71,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { /** * {@inheritdoc} */ - public function register(ContainerBuilder $container) { + public function register(ContainerBuilder $container): void { parent::register($container); // Register the private stream wrapper so we can test whether files can be @@ -83,7 +83,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { /** * {@inheritdoc} */ - protected function setUpFilesystem() { + 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'; @@ -101,7 +101,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { /** * Uploading a file using a wrong element name should return an error. */ - public function testUploadingFileToWrongElement() { + public function testUploadingFileToWrongElement(): void { $query = $this->getQueryFromFile('submission_with_file_upload.gql'); $variables = [ 'elements' => [], @@ -117,7 +117,14 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { 'errors' => [ 'Files cannot be uploaded to the "checkboxes" element since it is not a managed file element.', ], - 'validationErrors' => [], + 'validationErrors' => [ + [ + 'element' => 'required_text_field', + 'messages' => [ + 'This field is required because it is important.', + ], + ], + ], 'submission' => NULL, ], ]); @@ -126,7 +133,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { /** * Tests uploading a file through the webformFileUpload mutation. */ - public function testFileUpload() { + 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; @@ -186,9 +193,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { ], ); - $request->headers->add(['content-type' => 'multipart/form-data']); - $response = $this->container->get('http_kernel')->handle($request); - $result = json_decode($response->getContent()); + $result = $this->executeMultiPartRequest($request); $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.'); $resultData = $result->data->submitWebform; @@ -233,13 +238,9 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { } /** - * Test uploading a file with some validation errors. - * - * - The file_upload file is using the .webp extension which is not allowed. - * - The first of the audio_files files is larger than the 1MB limit. - * - The second of the audio_files files is using the .flac extension which is not allowed. + * Tests uploading files that do not meet the validation criteria. */ - public function testFileUploadWithValidationErrors() { + public function testFileUploadWithValidationErrors(): void { // Create some test files to upload. foreach (['webp', 'mp3', 'flac'] as $extension) { $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension; @@ -301,9 +302,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { ], ); - $request->headers->add(['content-type' => 'multipart/form-data']); - $response = $this->container->get('http_kernel')->handle($request); - $result = json_decode($response->getContent()); + $result = $this->executeMultiPartRequest($request); $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.'); $resultData = $result->data->submitWebform; @@ -319,6 +318,144 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { '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)); @@ -329,9 +466,6 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $this->assertEquals($expectedMessage, $elementError->messages[$i], sprintf('Validation message #%d for the "%s" element is correct.', $i, $elementName)); } } - - $this->assertEmpty($resultData->submission, 'No submission was returned.'); - $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.'); } } -- GitLab From d3b80aaaad15e171dbd3e49870a225c80bb2fe89 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Thu, 6 Feb 2025 21:52:05 +0200 Subject: [PATCH 14/20] Simplify and streamline implementation. --- .../GraphQL/DataProducer/WebformSubmit.php | 291 +++++------------- 1 file changed, 82 insertions(+), 209 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php index 5a35e7b..1bc3291 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -9,6 +9,7 @@ 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; @@ -125,10 +126,6 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl */ public function resolve( string $id, -// @todo Investigate this comment. There seems to be no suppression of coding -// standards warnings. - // 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, @@ -179,27 +176,16 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl $dataElements[$element['element']] = $element['value']; } - // Handle file uploads. This will add validation messages if they happen. - $filesMapped = $this->handleFileElements($webform, $files, $result); - - // Check if any errors or validations happened during file upload - // handling. - // @todo Maybe we should not exit early here but also perform the - // remaining validation so the GraphQL client can see all errors at - // once. - if (!$result->isValid()) { - return $result; + // 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 form data for file elements. -// @todo This method is missing. We have no multiple elements. -// @todo This method has been replaced with the simple foreach() loop just above. - $fileElements = $this->buildFormData($filesMapped, []); - // Build the form data object for the form submission. $formData = [ 'webform_id' => $id, - 'data' => array_merge($dataElements, $fileElements), + 'data' => $dataElements, ]; // Only include the source entity type and ID if both are provided. @@ -240,112 +226,6 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl return $renderResult; } - /** - * Build the form submission data from the WebformSubmissionElement array. - * - * To keep frontend implementations easy the submission values are provided - * as an array of elements with their input name and value as keys. - * Using this we can simulate a POST body request by combining everything to - * a query string and letting PHP do the parsing. - * - * The input array might look like this: - * [ - * [ - * "element" => "message", - * "value" => "Hello", - * ], - * [ - * "element" => "users[0][name]", - * "value" => "John", - * ], - * [ - * "element" => "users[0][email]", - * "value" => "foobar@domain.com", - * ], - * [ - * "element" => "users[1][name]", - * "value" => "Wayne", - * ], - * [ - * "element" => "users[1][email]", - * "value" => "baz@domain.com", - * ], - * ] - * - * This is first converted to a query string: - * - * message=Hello&users[0][name]=John&users[0][email]=foobar@domain.com&users[1][name]=Wayne&users[1][email]=baz@domain.com - * - * And then parsed and converted to an array: - * - * [ - * "message" => "Hello", - * "users" => [ - * [ - * "name" => "John", - * "email" => "foobar@domain.com", - * ], - * [ - * "name" => "Wayne", - * "email" => "baz@domain.com", - * ] - * ], - * ] - * - * This array can now be used to create a webform submission. - * - * @param array $items - * The WebformSubmissionElement array. - * @param array $elementsWithMultiple - * The keys of elements that support multiple values. - * - * @return array - * The webform submission data. - */ - private function buildFormData(array $items, array $elementsWithMultiple): array { - // Collect all query values. - $parts = []; - - $nameCount = []; - - foreach ($items as $item) { - $name = $item['element']; - - // Assume the key to be the name of the element by default. - $key = $name; - - // If the key is for an element that expects multiple values we have - // to make sure to provide an array at the end. The fragile webform code - // just assumes it's an array without checking. - if (in_array($name, $elementsWithMultiple)) { - if (empty($nameCount[$name])) { - $nameCount[$name] = 0; - } - - // Get the current count. - $count = $nameCount[$name]; - - // Build the key using the current count for this element. - $key = $name . "[$count]"; - - // Increment the count for the next item. - $nameCount[$name] = $count + 1; - } - - // e.g. textfield=My+Text or checkboxes_field[0]=option_1. - $parts[] = $key . '=' . urlencode($item['value']); - } - - // Create the query string. - $str = implode('&', $parts); - - // Parse the query string to an array. - $result = []; - parse_str($str, $result); - - return $result; - } - /** * Populates errors on the result as WebformSubmissionValidationError objects. * @@ -364,125 +244,118 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl } /** - * 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 successful. - $filesMapped = []; - - $elementsMultiple = []; - // @todo This array is unused. - $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']; + // Track the File entities that were created by valid uploads. + $createdFiles = []; - // Check if the element is actually a managed file element. + 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 fault but rather a logic error in the code + // of the GraphQL client. if (!in_array($elementKey, $managedFileElements)) { - $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $item['element']); + $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 initialized render element. - $element = $webform->getElement($item['element']); + // Get the initialized render element and the plugin instance that + // represents an OOP interface to the element. + $element = $webform->getElement($elementKey); /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $instance */ $instance = $this->elementManager->getElementInstance($element); $instance->prepare($element); - // Get file validation and upload settings. - $uriScheme = $instance->getElementProperty($element, 'uri_scheme'); - $multiple = $instance->getElementProperty($element, 'multiple'); - $multiple = $instance->hasMultipleValues($element); - - if ($multiple === FALSE || $multiple === NULL || $multiple === 1) { - // @todo This variable is unused. - $elementsSingle[] = $elementKey; - - // Check if we already have one file for this element. - if (!empty($filesMapped[$elementKey])) { - $result->addValidationError('Only one file is allowed', $item['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; - } - // The methods to retrieve the file size and allowed extensions are - // protected, so we get them from the render array instead. + // 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']; - // 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)) { - foreach ($violations as $violation) { - $result->addValidationError($violation['message'], $item['element']); + // Attempt to save the uploaded files, leveraging the validation checks of + // the built-in 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 validation errors. + $violations = $uploadResponse->getViolations(); + if (!empty($violations)) { + foreach ($violations as $violation) { + $result->addValidationError($violation['message'], $elementKey); + } + continue; } - continue; - } - $file = $upload->getFileEntity(); - // If there is no file and no validation errors, add an unexpected error. - if (empty($file) && $result->isValid()) { - $result->addValidationError('Unexpected error while uploading file.', $item['element']); - 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 elements that support multiple values to + // have their values passed as an array, and single value elements to be + // passed as a scalar. + // @see \Drupal\webform\WebformSubmissionStorage::saveData() + if ($allowedNumberOfFiles === 1) { + $createdFiles[$elementKey] = $file->id(); + } + else { + $createdFiles[$elementKey][] = $file->id(); } - } - else { - $input[] = [ - 'element' => $elementKey, - 'value' => $fileIds[0], - ]; } } - return $input; + return $createdFiles; } /** -- GitLab From 7701495eb7c303bc29963b6b54ec96ed9dd9eaf2 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Thu, 6 Feb 2025 22:00:26 +0200 Subject: [PATCH 15/20] Clean up. --- .../WebformFileUploadOutputWrapper.php | 49 ---- .../File/WebformFileUploadOutputFid.php | 34 --- .../GraphQL/Mutations/WebformFileUpload.php | 258 ------------------ .../GraphQL/Types/WebformFileUploadOutput.php | 30 -- tests/queries/create_file.gql | 7 - tests/src/Kernel/Element/CreateFileTest.php | 145 ---------- .../FormSubmissionWithFileUploadTest.php | 25 -- 7 files changed, 548 deletions(-) delete mode 100644 src/GraphQL/WebformFileUploadOutputWrapper.php delete mode 100644 src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php delete mode 100644 src/Plugin/GraphQL/Mutations/WebformFileUpload.php delete mode 100644 src/Plugin/GraphQL/Types/WebformFileUploadOutput.php delete mode 100644 tests/queries/create_file.gql delete mode 100644 tests/src/Kernel/Element/CreateFileTest.php diff --git a/src/GraphQL/WebformFileUploadOutputWrapper.php b/src/GraphQL/WebformFileUploadOutputWrapper.php deleted file mode 100644 index e69c327..0000000 --- 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/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php b/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php deleted file mode 100644 index 15a4a86..0000000 --- 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 fbb8794..0000000 --- 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/Types/WebformFileUploadOutput.php b/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php deleted file mode 100644 index 7f2dff2..0000000 --- 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/queries/create_file.gql b/tests/queries/create_file.gql deleted file mode 100644 index b8bba21..0000000 --- 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/src/Kernel/Element/CreateFileTest.php b/tests/src/Kernel/Element/CreateFileTest.php deleted file mode 100644 index 153b34e..0000000 --- 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/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index e5d30b5..73bb79e 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -490,28 +490,3 @@ function is_uploaded_file($filename) { $prefix = $temp_dir . '/graphql_webform_upload_test.'; return str_starts_with($filename, $prefix); } - -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) { - // @todo - throw new \NotImplementedException(); - $temp_dir = \Drupal::service('file_system')->getTempDirectory(); - $test_file = $temp_dir . '/graphql_webform_upload_test.txt'; - return $filename === $test_file; -} -- GitLab From ff1c893c7836487f67e1ec825e438ad314c33a9a Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Fri, 7 Feb 2025 07:18:23 +0200 Subject: [PATCH 16/20] Address test failures. --- .cspell-project-words.txt | 1 + src/Model/WebformSubmissionResult.php | 4 +-- .../GraphQL/DataProducer/WebformSubmit.php | 6 ++-- .../SchemaExtension/WebformExtension.php | 10 ++----- ...form.webform.graphql_webform_test_form.yml | 3 +- tests/src/Kernel/Element/ManagedFileTest.php | 23 +++++++-------- tests/src/Kernel/Element/MarkupTest.php | 2 +- .../src/Kernel/Element/MultipleValuesTest.php | 23 ++------------- .../FormSubmissionWithFileUploadTest.php | 28 +++++++++---------- 9 files changed, 36 insertions(+), 64 deletions(-) diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt index 4b16590..524c6fe 100644 --- a/.cspell-project-words.txt +++ b/.cspell-project-words.txt @@ -1,3 +1,4 @@ +flac ~flexbox ~flexboxes graphqls diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php index bc09f35..44c13bb 100644 --- a/src/Model/WebformSubmissionResult.php +++ b/src/Model/WebformSubmissionResult.php @@ -97,7 +97,7 @@ class WebformSubmissionResult { /** * Returns the validation error that corresponds to the given element. * - * @param string|NULL $elementId + * @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. * @@ -113,7 +113,7 @@ class WebformSubmissionResult { * Adds a validation error. * * @param string $errorMessage - * * The error message to add. + * 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. diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php index 1bc3291..5e2731a 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -38,10 +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.") + * label = @Translation("Array of WebformSubmissionFile objects."), + * multiple = TRUE * ), * "sourceEntityType" = @ContextDefinition("string", * label = @Translation("Source entity type"), diff --git a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php index bb73323..0f02c59 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(), ]); 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 46d743d..b1ea531 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 @@ -59,7 +59,7 @@ elements: |- '#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 +102,6 @@ elements: |- '#type': webform_audio_file '#title': 'Audio files' '#multiple': 2 - '#max_filesize': '1' '#file_extensions': '' select_with_custom_empty_option: '#type': select diff --git a/tests/src/Kernel/Element/ManagedFileTest.php b/tests/src/Kernel/Element/ManagedFileTest.php index 3d970b8..7eab946 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 77ec7ac..ae5e72f 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 1e550b8..2e55da6 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 index 73bb79e..f62952d 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -242,12 +242,12 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { */ public function testFileUploadWithValidationErrors(): void { // Create some test files to upload. - foreach (['webp', 'mp3', 'flac'] as $extension) { + 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 audio_files element which is limited to 1MB. - if ($extension === 'mp3') { + // 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)); } } @@ -261,8 +261,8 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { 'elements' => [ (object) [ 'element' => 'required_text_field', - 'value' => 'A value.' - ] + 'value' => 'A value.', + ], ], 'files' => [ ['element' => 'file_upload', 'file' => NULL], @@ -279,16 +279,16 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { ], files: [ '0' => [ - 'name' => 'test.webp', - 'type' => 'image/webp', - 'size' => 0, - 'tmp_name' => $this->files['webp'], + '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' => 1024 * 1024 + 1, + 'size' => 0, 'tmp_name' => $this->files['mp3'], 'error' => UPLOAD_ERR_OK, ], @@ -308,13 +308,11 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $resultData = $result->data->submitWebform; $this->assertEmpty($resultData->errors, 'There are no errors.'); - // $this->assertCount(3, $resultData->validationErrors, 'There are 3 validation errors.'); $expectedValidationErrors = [ 'file_upload' => [ - 'Only files with the following extensions are allowed: <em class="placeholder">gif jpg png txt</em>.' + 'The file is <em class="placeholder">1 MB</em> exceeding the maximum file size of <em class="placeholder">1 MB</em>.', ], 'audio_files' => [ - 'The file is <em class="placeholder">1 MB</em> exceeding the maximum file size of <em class="placeholder">1 MB</em>.', 'Only files with the following extensions are allowed: <em class="placeholder">mp3 ogg wav</em>.', ], ]; @@ -346,8 +344,8 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { 'elements' => [ (object) [ 'element' => 'required_text_field', - 'value' => 'A value.' - ] + 'value' => 'A value.', + ], ], 'files' => [ ['element' => 'file_upload', 'file' => NULL], -- GitLab From d41ba2aff735ac006bae1c9c02cfefd85356a787 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Fri, 7 Feb 2025 08:07:11 +0200 Subject: [PATCH 17/20] Review code and improve where possible. --- .../GraphQL/DataProducer/WebformSubmit.php | 28 ++++++++++--------- .../FormSubmissionWithFileUploadTest.php | 12 ++++---- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php index 5e2731a..e97b2bd 100644 --- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php +++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php @@ -269,14 +269,15 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl // files are correctly associated with a managed file element. $managedFileElements = array_keys($webform->getElementsManagedFiles()); - // Track the File entities that were created by valid uploads. - $createdFiles = []; + // Keep track of File entity IDs that were created by valid uploads. This + // will be returned at the end of the method. + $fileIds = []; 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 fault but rather a logic error in the code - // of the GraphQL client. + // probably not the end user's mistake but rather a bug in the GraphQL + // client code. if (!in_array($elementKey, $managedFileElements)) { $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $elementKey); $result->addError($error); @@ -284,7 +285,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl } // Get the initialized render element and the plugin instance that - // represents an OOP interface to the element. + // represents an OOP interface to the element configuration. $element = $webform->getElement($elementKey); /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $instance */ $instance = $this->elementManager->getElementInstance($element); @@ -317,7 +318,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl $fileExtensions = $element['#upload_validators']['FileExtension']['extensions']; // Attempt to save the uploaded files, leveraging the validation checks of - // the built-in file upload service from the GraphQL module. + // the file upload service from the GraphQL module. foreach ($files as $file) { $uploadResponse = $this->fileUpload->saveFileUpload($file, [ 'file_extensions' => $fileExtensions, @@ -326,7 +327,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl 'file_directory' => 'webform', ]); - // If there are any violations, add them as validation errors. + // If there are any violations, add them as form validation errors. $violations = $uploadResponse->getViolations(); if (!empty($violations)) { foreach ($violations as $violation) { @@ -344,20 +345,21 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl continue; } - // The webform module expects elements that support multiple values to - // have their values passed as an array, and single value elements to be - // passed as a scalar. + // 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) { - $createdFiles[$elementKey] = $file->id(); + $fileIds[$elementKey] = $file->id(); } else { - $createdFiles[$elementKey][] = $file->id(); + $fileIds[$elementKey][] = $file->id(); } } } - return $createdFiles; + return $fileIds; } /** diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php index f62952d..0e6b533 100644 --- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php +++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php @@ -13,7 +13,7 @@ use Drupal\webform\Entity\WebformSubmission; use Symfony\Component\HttpFoundation\Request; /** - * Test file uploads with graphql. + * Test file uploads with GraphQL. * * @group graphql_webform */ @@ -107,7 +107,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { 'elements' => [], 'files' => [ // Try to upload to a non-file upload element. - (object) ['element' => 'checkboxes', 'file' => NULL], + ['element' => 'checkboxes', 'file' => NULL], ], 'id' => 'graphql_webform_test_form', ]; @@ -131,7 +131,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { } /** - * Tests uploading a file through the webformFileUpload mutation. + * Tests uploading a file as part of a webform submission. */ public function testFileUpload(): void { // Create some test files to upload. @@ -143,7 +143,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { $this->files[$extension] = $file; } - // Create a post request with file contents. + // Create a POST request with the files attached as multipart form data. $request = Request::create( uri: '/graphql/test', method: 'POST', @@ -160,7 +160,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase { ], 'id' => 'graphql_webform_test_form', ], - // The 'map' parameter is used to map uploaded files to variables. + // 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'], @@ -481,7 +481,7 @@ namespace Symfony\Component\HttpFoundation\File; * The filename being checked. * * @return bool - * Will return TRUE for our test file. + * Will return TRUE for our test files. */ function is_uploaded_file($filename) { $temp_dir = \Drupal::service('file_system')->getTempDirectory(); -- GitLab From e5e9b117e9d2186e8134a8641cfced1689c5ac36 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Fri, 7 Feb 2025 08:28:57 +0200 Subject: [PATCH 18/20] Update documentation. --- README.md | 67 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 950ea5b..d1bc70e 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 -- GitLab From 4244f61e4466311aa59c1e9a8454de95aa9f499b Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Fri, 7 Feb 2025 15:23:32 +0200 Subject: [PATCH 19/20] Rename the method to get or create a validation error so that it is clear what is returned. --- src/Model/WebformSubmissionResult.php | 4 ++-- tests/src/Unit/WebformSubmissionResultTest.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php index 44c13bb..7b2ab2d 100644 --- a/src/Model/WebformSubmissionResult.php +++ b/src/Model/WebformSubmissionResult.php @@ -105,7 +105,7 @@ class WebformSubmissionResult { * The validation error. If no error exists for the given element ID yet, an * empty error object is returned. */ - public function getValidationError(?string $elementId): WebformSubmissionValidationError { + public function getOrCreateValidationError(?string $elementId): WebformSubmissionValidationError { return $this->validationErrors[$elementId] ?? new WebformSubmissionValidationError([], $elementId); } @@ -119,7 +119,7 @@ class WebformSubmissionResult { * is not element-specific. */ public function addValidationError(string $errorMessage, ?string $elementId): static { - $validationError = $this->getValidationError($elementId); + $validationError = $this->getOrCreateValidationError($elementId); $validationError->addMessage($errorMessage); $this->validationErrors[$elementId] = $validationError; diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php index 31b3203..fede644 100644 --- a/tests/src/Unit/WebformSubmissionResultTest.php +++ b/tests/src/Unit/WebformSubmissionResultTest.php @@ -37,7 +37,7 @@ class WebformSubmissionResultTest extends UnitTestCase { /** * @covers ::addValidationError - * @covers ::getValidationError + * @covers ::getOrCreateValidationError * @covers ::getValidationErrors */ public function testAddValidationError() { @@ -64,10 +64,10 @@ class WebformSubmissionResultTest extends UnitTestCase { $result->addValidationError('Yet another validation error', 'another_element_id'); $errors = $result->getValidationErrors(); $this->assertCount(2, $errors); - $error_element_1 = $result->getValidationError('element_id'); + $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->getValidationError('another_element_id'); + $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()); @@ -75,7 +75,7 @@ class WebformSubmissionResultTest extends UnitTestCase { $result->addValidationError('Validation error without element ID', NULL); $errors = $result->getValidationErrors(); $this->assertCount(3, $errors); - $error_no_element = $result->getValidationError(NULL); + $error_no_element = $result->getOrCreateValidationError(NULL); $this->assertEquals(['Validation error without element ID'], $error_no_element->getMessages()); $this->assertNull($error_no_element->getElementId()); } -- GitLab From 9735f78765b2b1d405687549d0a7df2f0891ba96 Mon Sep 17 00:00:00 2001 From: Pieter Frenssen <pieter@frenssen.be> Date: Fri, 7 Feb 2025 15:23:55 +0200 Subject: [PATCH 20/20] Add a method to check if a validation error exists for a given element. --- src/Model/WebformSubmissionResult.php | 14 ++++++++++++++ tests/src/Unit/WebformSubmissionResultTest.php | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php index 7b2ab2d..69ac6a4 100644 --- a/src/Model/WebformSubmissionResult.php +++ b/src/Model/WebformSubmissionResult.php @@ -109,6 +109,20 @@ class WebformSubmissionResult { 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. * diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php index fede644..3a3f361 100644 --- a/tests/src/Unit/WebformSubmissionResultTest.php +++ b/tests/src/Unit/WebformSubmissionResultTest.php @@ -39,10 +39,13 @@ class WebformSubmissionResultTest extends UnitTestCase { * @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'); @@ -51,6 +54,9 @@ class WebformSubmissionResultTest extends UnitTestCase { $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'); @@ -59,6 +65,9 @@ class WebformSubmissionResultTest extends UnitTestCase { $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'); @@ -70,6 +79,9 @@ class WebformSubmissionResultTest extends UnitTestCase { $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); @@ -78,6 +90,9 @@ class WebformSubmissionResultTest extends UnitTestCase { $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.'); } /** -- GitLab