From 490cae920bc30dd9dac5556aaee8a27eea0a35c0 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Fri, 3 Jan 2025 16:06:38 +0000 Subject: [PATCH 01/15] Use exceptions instead of returning violation lists. --- src/ClientDataToEntityConverter.php | 9 ++-- .../ApiContentUpdateForDemoController.php | 19 +++++--- src/Controller/ApiPreviewController.php | 6 +-- src/Controller/ApiPublishAllController.php | 21 +++++---- .../ClientServerConversionTrait.php | 45 +++++++++++-------- src/Entity/PageTemplate.php | 16 ++++--- .../ClientServerConversionTraitTest.php | 15 ++++--- 7 files changed, 79 insertions(+), 52 deletions(-) diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php index 8a1ba8713d..2caccf6772 100644 --- a/src/ClientDataToEntityConverter.php +++ b/src/ClientDataToEntityConverter.php @@ -11,6 +11,7 @@ use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormState; use Drupal\experience_builder\Controller\ClientServerConversionTrait; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; use Symfony\Component\Validator\ConstraintViolation; @@ -29,9 +30,11 @@ class ClientDataToEntityConverter { // @todo Security hardening: any key besides `layout`, `model` and `entity_form_fields` should trigger an error response. ['layout' => $layout, 'model' => $model, 'entity_form_fields' => $entity_form_fields] = $client_data; - [$tree, $props, $violations] = $this->convertClientToServer($layout, $model); - if ($violations->count() > 0) { - return new EntityConstraintViolationList($entity, iterator_to_array($violations)); + try { + [$tree, $props] = $this->convertClientToServer($layout, $model); + } + catch (ConstraintViolationException $e) { + return new EntityConstraintViolationList($entity, iterator_to_array($e->getConstraintViolationList())); } $field_name = InternalXbFieldNameResolver::getXbFieldName($entity); diff --git a/src/Controller/ApiContentUpdateForDemoController.php b/src/Controller/ApiContentUpdateForDemoController.php index d502cf5376..cb88235356 100644 --- a/src/Controller/ApiContentUpdateForDemoController.php +++ b/src/Controller/ApiContentUpdateForDemoController.php @@ -8,6 +8,7 @@ use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\RevisionableInterface; use Drupal\experience_builder\AutoSave\AutoSaveManager; use Drupal\experience_builder\ClientDataToEntityConverter; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Symfony\Component\HttpFoundation\JsonResponse; /** @@ -33,13 +34,17 @@ final class ApiContentUpdateForDemoController extends ApiControllerBase { public function __invoke(FieldableEntityInterface $entity): JsonResponse { $auto_save = $this->autoSaveManager->getAutoSaveData($entity); assert(is_array($auto_save)); - $violations = $this->clientDataToEntityConverter->convert([ - 'layout' => reset($auto_save['layout']), - 'model' => $auto_save['model'], - 'entity_form_fields' => $auto_save['entity_form_fields'], - ], $entity); - if ($validation_errors_response = self::createJsonResponseFromViolations($violations)) { - return $validation_errors_response; + try { + $this->clientDataToEntityConverter->convert([ + 'layout' => reset($auto_save['layout']), + 'model' => $auto_save['model'], + 'entity_form_fields' => $auto_save['entity_form_fields'], + ], $entity); + } + catch (ConstraintViolationException $e) { + if ($validation_errors_response = self::createJsonResponseFromViolations($e->getConstraintViolationList())) { + return $validation_errors_response; + } } return self::save($entity); diff --git a/src/Controller/ApiPreviewController.php b/src/Controller/ApiPreviewController.php index b6ef5206d7..961bc7f534 100644 --- a/src/Controller/ApiPreviewController.php +++ b/src/Controller/ApiPreviewController.php @@ -126,10 +126,8 @@ final class ApiPreviewController { 'data_definition' => $field_item_definition, ]); - // @todo Use $violations in https://www.drupal.org/project/experience_builder/issues/3485878 - // phpcs:disable DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable - [$tree, $violations] = self::clientLayoutToServerTree($layout); - // phpcs:enable + // @todo Catch violations in https://www.drupal.org/project/experience_builder/issues/3485878 + $tree = self::clientLayoutToServerTree($layout); // This uses a partial override of the XB field type, because the client is // sending explicit prop values in its `model`, not prop sources. Use these diff --git a/src/Controller/ApiPublishAllController.php b/src/Controller/ApiPublishAllController.php index 83ef244c7f..242860b08b 100644 --- a/src/Controller/ApiPublishAllController.php +++ b/src/Controller/ApiPublishAllController.php @@ -107,21 +107,26 @@ class ApiPublishAllController extends ApiControllerBase { // context. // @see https://www.drupal.org/project/drupal/issues/3495599 $violations = $this->typedConfigManager->createFromNameAndData($entity->getConfigDependencyName(), $entity->toArray())->validate(); + if ($violations->count() > 0) { + $violationSets[] = $violations; + } } else { assert($entity instanceof FieldableEntityInterface); // Pluck out only the content region. $content_region = \array_values(\array_filter($auto_save['data']['layout'], static fn(array $region) => $region['id'] === 'content')); - $violations = $this->clientDataToEntityConverter->convert([ - 'layout' => reset($content_region), - 'model' => $auto_save['data']['model'], - 'entity_form_fields' => $auto_save['data']['entity_form_fields'], - ], $entity); + try { + $this->clientDataToEntityConverter->convert([ + 'layout' => reset($content_region), + 'model' => $auto_save['data']['model'], + 'entity_form_fields' => $auto_save['data']['entity_form_fields'], + ], $entity); + } + catch (ConstraintViolationException $e) { + $violationSets[] = $e->getConstraintViolationList(); + } } $entities[] = $entity; - if ($violations->count() > 0) { - $violationSets[] = $violations; - } } if (\count($violationSets) > 0) { $validation_errors_response = self::createJsonResponseFromViolationSets(...$violationSets); diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index 9a62009c98..879a865207 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -8,6 +8,7 @@ use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItemInterface; use Drupal\Core\StreamWrapper\PublicStream; use Drupal\Core\TypedData\DataDefinition; use Drupal\experience_builder\Entity\Component; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Drupal\experience_builder\InvalidRequestBodyValue; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\SingleDirectoryComponent; @@ -26,9 +27,10 @@ trait ClientServerConversionTrait { /** * @todo Refactor/remove in https://www.drupal.org/project/experience_builder/issues/3467954. * - * @return array{0: ComponentTreeStructureArray, 1: \Symfony\Component\Validator\ConstraintViolationListInterface} + * @phpstan-return ComponentTreeStructureArray + * @throws \Drupal\experience_builder\Exception\ConstraintViolationException */ - private static function clientLayoutToServerTree(array $layout) : array { + private static function clientLayoutToServerTree(array $layout): array { // Transform client-side representation to server-side representation. // The entire component tree is nested under the reserved root UUID. $tree = self::doClientSlotToServerTree($layout, [], ComponentTreeStructure::ROOT_UUID); @@ -38,8 +40,11 @@ trait ClientServerConversionTrait { $component_tree_structure = new ComponentTreeStructure($definition, 'component_tree_structure'); $component_tree_structure->setValue(json_encode($tree, JSON_UNESCAPED_UNICODE)); $violations = $component_tree_structure->validate(); + if ($violations->count()) { + throw ConstraintViolationException::forViolationList($violations); + } - return [$tree, $violations]; + return $tree; } /** @@ -91,7 +96,8 @@ trait ClientServerConversionTrait { } /** - * @return array{0: array<string, array<string, \Drupal\experience_builder\PropSource\StaticPropSource>>, 1: \Symfony\Component\Validator\ConstraintViolationList} + * @return array<string, array<string, \Drupal\experience_builder\PropSource\StaticPropSource>> + * @throws \Drupal\experience_builder\Exception\ConstraintViolationException */ private function clientModelToServerProps(array $tree, array $model): array { $definition = DataDefinition::create('component_tree_structure'); @@ -113,7 +119,10 @@ trait ClientServerConversionTrait { $violation_list->add($violation); } } - return [$props, $violation_list]; + if ($violation_list->count()) { + throw ConstraintViolationException::forViolationList($violation_list); + } + return $props; } /** @@ -206,7 +215,8 @@ trait ClientServerConversionTrait { } /** - * @return array{0: ?array, 1: ?array, 2: \Symfony\Component\Validator\ConstraintViolationList} + * @return array{0: ?array, 1: ?array} + * @throws \Drupal\experience_builder\Exception\ConstraintViolationException */ protected function convertClientToServer(array $layout, array $model): array { // Denormalize the `layout` the client sent into a value that the server- @@ -214,13 +224,15 @@ trait ClientServerConversionTrait { // (This is the value for the `tree` field prop on the XB field type.) // @see \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure // @see \Drupal\experience_builder\Plugin\Validation\Constraint\ComponentTreeStructureConstraintValidator - [$tree, $violations] = self::clientLayoutToServerTree($layout); - $transformed_violations = new ConstraintViolationList(array_map( - fn (ConstraintViolationInterface $v) => self::violationWithPropertyPathReplacePrefix($v, '[' . ComponentTreeStructure::ROOT_UUID . ']', "layout.children"), - iterator_to_array($violations), - )); - if ($transformed_violations->count() > 0) { - return [NULL, NULL, $transformed_violations]; + try { + $tree = self::clientLayoutToServerTree($layout); + } + catch (ConstraintViolationException $e) { + throw ConstraintViolationException::forViolationList(new ConstraintViolationList(array_map( + // @todo move to a helper on ConstraintViolationException? + fn (ConstraintViolationInterface $v) => self::violationWithPropertyPathReplacePrefix($v, '[' . ComponentTreeStructure::ROOT_UUID . ']', "layout.children"), + iterator_to_array($e->getConstraintViolationList()), + ))); } // Denormalize the `model` the client sent into a value that the server-side @@ -230,10 +242,7 @@ trait ClientServerConversionTrait { // ⚠️ TRICKY: in order to denormalize `model`, `layout` must already been // been denormalized to `tree`, because only those values in `model` that // are for actually existing XB components can be denormalized. - [$props, $violations] = $this->clientModelToServerProps($tree, $model); - if ($violations->count() > 0) { - return [NULL, NULL, $violations]; - } + $props = $this->clientModelToServerProps($tree, $model); // Update the entity, validate and save. // Note: constructing ComponentTreeStructure from `layout` and @@ -249,7 +258,7 @@ trait ClientServerConversionTrait { $props_prepared_for_saving[$component_instance_uuid][$prop_name] = json_decode((string) $prop_source, TRUE); } } - return [$tree, $props_prepared_for_saving, new ConstraintViolationList()]; + return [$tree, $props_prepared_for_saving]; } private static function violationWithPropertyPathReplacePrefix(ConstraintViolationInterface $v, string $prefix_original, string $prefix_new): ConstraintViolationInterface { diff --git a/src/Entity/PageTemplate.php b/src/Entity/PageTemplate.php index 698cf87955..f32d91aebd 100644 --- a/src/Entity/PageTemplate.php +++ b/src/Entity/PageTemplate.php @@ -86,9 +86,11 @@ final class PageTemplate extends ConfigEntityBase implements XbHttpApiEligibleCo $treeItems = \array_intersect_key($values['component_trees'] ?? [], \array_flip(['content'])); $allViolations = new ConstraintViolationList(); foreach ($autoSaveData['layout'] as $region) { - [$tree, $violations] = self::clientLayoutToServerTree($region); - $allViolations->addAll($violations); - if ($tree === NULL) { + try { + $tree = self::clientLayoutToServerTree($region); + } + catch (ConstraintViolationException $e) { + $allViolations->addAll($e->getConstraintViolationList()); continue; } @@ -104,9 +106,11 @@ final class PageTemplate extends ConfigEntityBase implements XbHttpApiEligibleCo $component_tree_structure = new ComponentTreeStructure($definition, 'component_tree_structure'); $component_tree_structure->setValue(json_encode($tree, JSON_UNESCAPED_UNICODE)); - [$client_props, $violations] = $this->clientModelToServerProps($tree, \array_intersect_key($autoSaveData['model'], \array_flip($component_tree_structure->getComponentInstanceUuids()))); - $allViolations->addAll($violations); - if ($client_props === NULL) { + try { + $client_props = $this->clientModelToServerProps($tree, \array_intersect_key($autoSaveData['model'], \array_flip($component_tree_structure->getComponentInstanceUuids()))); + } + catch (ConstraintViolationException $e) { + $allViolations->addAll($e->getConstraintViolationList()); continue; } diff --git a/tests/src/Kernel/ClientServerConversionTraitTest.php b/tests/src/Kernel/ClientServerConversionTraitTest.php index 513d5e3f01..faddb72ae7 100644 --- a/tests/src/Kernel/ClientServerConversionTraitTest.php +++ b/tests/src/Kernel/ClientServerConversionTraitTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\experience_builder\Kernel; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\experience_builder\Controller\ClientServerConversionTrait; use Drupal\experience_builder\Entity\Pattern; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\KernelTests\KernelTestBase; use Drupal\node\Entity\Node; @@ -51,8 +52,7 @@ class ClientServerConversionTraitTest extends KernelTestBase { public function testConvertClientToServer(): void { ['layout' => $layout, 'model' => $model] = $this->getValidClientJson(); - [$tree, $props, $violations] = $this->convertClientToServer($layout, $model); - $this->assertCount(0, $violations); + [$tree, $props] = $this->convertClientToServer($layout, $model); $this->assertSame($this->getValidConvertedProps(), $props); $this->assertSame([ ComponentTreeStructure::ROOT_UUID => [ @@ -126,10 +126,13 @@ class ClientServerConversionTraitTest extends KernelTestBase { } private function assertConversionErrors(array $client_json, array $errors): void { - [$tree, $props, $violations] = $this->convertClientToServer($client_json['layout'], $client_json['model']); - $this->assertNull($tree); - $this->assertNull($props); - $this->assertSame($errors, $this->violationsToArray($violations)); + try { + $this->convertClientToServer($client_json['layout'], $client_json['model']); + $this->fail(); + } + catch (ConstraintViolationException $e) { + $this->assertSame($errors, $this->violationsToArray($e->getConstraintViolationList())); + } } /** -- GitLab From 82477e10b1d00c5da623d71b791f48aba684ca76 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Fri, 3 Jan 2025 17:19:28 +0000 Subject: [PATCH 02/15] Second pass. --- src/ClientDataToEntityConverter.php | 39 +++++++++---------- .../ClientDataToEntityConverterTest.php | 16 +++++--- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php index 2caccf6772..58d50cdbd0 100644 --- a/src/ClientDataToEntityConverter.php +++ b/src/ClientDataToEntityConverter.php @@ -4,7 +4,6 @@ namespace Drupal\experience_builder; use Drupal\Core\Access\AccessException; use Drupal\Core\Entity\EntityConstraintViolationList; -use Drupal\Core\Entity\EntityConstraintViolationListInterface; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; @@ -26,7 +25,7 @@ class ClientDataToEntityConverter { private readonly EntityDisplayRepositoryInterface $entityDisplayRepository, ) {} - public function convert(array $client_data, FieldableEntityInterface $entity): EntityConstraintViolationListInterface { + public function convert(array $client_data, FieldableEntityInterface $entity): void { // @todo Security hardening: any key besides `layout`, `model` and `entity_form_fields` should trigger an error response. ['layout' => $layout, 'model' => $model, 'entity_form_fields' => $entity_form_fields] = $client_data; @@ -34,7 +33,7 @@ class ClientDataToEntityConverter { [$tree, $props] = $this->convertClientToServer($layout, $model); } catch (ConstraintViolationException $e) { - return new EntityConstraintViolationList($entity, iterator_to_array($e->getConstraintViolationList())); + throw ConstraintViolationException::forViolationList(new EntityConstraintViolationList($entity, iterator_to_array($e->getConstraintViolationList()))); } $field_name = InternalXbFieldNameResolver::getXbFieldName($entity); @@ -44,27 +43,25 @@ class ClientDataToEntityConverter { 'tree' => json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT), 'props' => json_encode($props, JSON_UNESCAPED_UNICODE), ]); - $violations = $this->setEntityFields($entity, $entity_form_fields); - if ($violations->count() > 0) { - return $violations; - } + $this->setEntityFields($entity, $entity_form_fields); $original_entity_violations = $entity->validate(); // Validation happens using the server-side representation, but the // error message should use the client-side representation received in // the request body. // @see ::clientLayoutToServerTree() // @see ::clientModelToServerProps() - $transformed_violations = new EntityConstraintViolationList($entity, array_map( - fn (ConstraintViolationInterface $v) => match (TRUE) { - str_starts_with($v->getPropertyPath(), "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . ']', 'layout.children'), - // @todo Perform a more complex transformation to accurately point to non-root-level components, OR remove the need for that in https://www.drupal.org/project/experience_builder/issues/3467954 - str_starts_with($v->getPropertyPath(), "$field_name.0.tree") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.tree", 'layout'), - str_starts_with($v->getPropertyPath(), "$field_name.0.props") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.props", 'model'), - default => $v, - }, - iterator_to_array($original_entity_violations), - )); - return $transformed_violations; + if ($original_entity_violations->count()) { + throw ConstraintViolationException::forViolationList(new EntityConstraintViolationList($entity, array_map( + fn (ConstraintViolationInterface $v) => match (TRUE) { + str_starts_with($v->getPropertyPath(), "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . ']', 'layout.children'), + // @todo Perform a more complex transformation to accurately point to non-root-level components, OR remove the need for that in https://www.drupal.org/project/experience_builder/issues/3467954 + str_starts_with($v->getPropertyPath(), "$field_name.0.tree") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.tree", 'layout'), + str_starts_with($v->getPropertyPath(), "$field_name.0.props") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.props", 'model'), + default => $v, + }, + iterator_to_array($original_entity_violations), + ))); + } } /** @@ -111,7 +108,7 @@ class ClientDataToEntityConverter { throw new AccessException("The current user is not allowed to update the field '$field_name'."); } - private function setEntityFields(FieldableEntityInterface $entity, array $entity_form_fields): EntityConstraintViolationListInterface { + private function setEntityFields(FieldableEntityInterface $entity, array $entity_form_fields): void { // Create a form state from the received entity fields. $form_state = new FormState(); $form_state->set('entity', $entity); @@ -155,7 +152,9 @@ class ClientDataToEntityConverter { $violations_list->add(new ConstraintViolation($e->getMessage(), $e->getMessage(), [], $field_value, "entity_form_fields.$field_name", $field_value)); } } - return $violations_list; + if ($violations_list->count()) { + throw ConstraintViolationException::forViolationList($violations_list); + } } /** diff --git a/tests/src/Kernel/ClientDataToEntityConverterTest.php b/tests/src/Kernel/ClientDataToEntityConverterTest.php index 1225d634d7..7059fdf82e 100644 --- a/tests/src/Kernel/ClientDataToEntityConverterTest.php +++ b/tests/src/Kernel/ClientDataToEntityConverterTest.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace Drupal\Tests\experience_builder\Kernel; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Entity\EntityConstraintViolationList; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldWidget\StringTextfieldWidget; use Drupal\Core\Field\WidgetPluginManager; use Drupal\Core\Form\FormStateInterface; use Drupal\experience_builder\ClientDataToEntityConverter; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\KernelTests\KernelTestBase; use Drupal\node\Entity\Node; @@ -169,15 +171,19 @@ class ClientDataToEntityConverterTest extends KernelTestBase { } } } - $violations = $this->container->get(ClientDataToEntityConverter::class)->convert($client_json, $node); - $this->assertSame($node->id(), $violations->getEntity()->id()); - $this->assertSame($expected_errors, self::violationsToArray($violations)); - $this->assertSame($expected_title, (string) $node->getTitle()); - if ($violations->count() === 0) { + try { + $this->container->get(ClientDataToEntityConverter::class)->convert($client_json, $node); // If no violations occurred, the node should be valid. $this->assertCount(0, $node->validate()); $this->assertSame(SAVED_UPDATED, $node->save()); } + catch (ConstraintViolationException $e) { + $violations = $e->getConstraintViolationList(); + $this->assertInstanceOf(EntityConstraintViolationList::class, $violations); + $this->assertSame($node->id(), $violations->getEntity()->id()); + $this->assertSame($expected_errors, self::violationsToArray($violations)); + $this->assertSame($expected_title, (string) $node->getTitle()); + } // Ensure the unchanged fields are not updated. // TRICKY: We can't directly compare `$client_json['entity_form_fields'][$field_name]` -- GitLab From 71dc7b629689a8bb2dec14a49ef9517e30d273e9 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Fri, 3 Jan 2025 17:22:19 +0000 Subject: [PATCH 03/15] Third pass. --- .../ClientServerConversionTrait.php | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index 879a865207..bf07c84547 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -110,13 +110,17 @@ trait ClientServerConversionTrait { $violation_list = new ConstraintViolationList(); foreach ($model as $uuid => $client_props) { $component = $component_tree_structure->getComponentId($uuid); - [$props[$uuid], $violations_for_component_instance] = $this->createPropsForComponent($uuid, $component, $client_props); - foreach ($violations_for_component_instance as $violation) { - // We use ::add here rather than ::addAll because ::addAll doesn't reset - // the internal groupings in EntityConstraintViolationList whereas ::add - // does. - // @see https://drupal.org/i/3490588 - $violation_list->add($violation); + try { + $props[$uuid] = $this->createPropsForComponent($uuid, $component, $client_props); + } + catch (ConstraintViolationException $e) { + foreach ($e->getConstraintViolationList() as $violation) { + // We use ::add here rather than ::addAll because ::addAll doesn't reset + // the internal groupings in EntityConstraintViolationList whereas ::add + // does. + // @see https://drupal.org/i/3490588 + $violation_list->add($violation); + } } } if ($violation_list->count()) { @@ -126,7 +130,7 @@ trait ClientServerConversionTrait { } /** - * @return array{0: array<string, \Drupal\experience_builder\PropSource\StaticPropSource>, 1: \Symfony\Component\Validator\ConstraintViolationListInterface} + * @return array<string, \Drupal\experience_builder\PropSource\StaticPropSource> * * @todo Refactor in https://www.drupal.org/project/experience_builder/issues/3495126, because that issue introduces storing inputs for Block-sourced Components. * @todo Refactor to use the Symfony denormalizer infrastructure? @@ -139,7 +143,7 @@ trait ClientServerConversionTrait { if (!$component_entity->getComponentSource() instanceof SingleDirectoryComponent) { // @todo Ask the component source how it would like to handle this. // @see https://www.drupal.org/project/experience_builder/issues/3495126 - return [$client_props, $violation_list]; + return $client_props; } // @todo All of the validation conversion logic below is specific to SDC // components AND specifically static prop sources. Move it behind the SDC @@ -173,7 +177,10 @@ trait ClientServerConversionTrait { } $props[$prop] = $updated_static_source; } - return [$props, $violation_list]; + if ($violation_list->count()) { + throw ConstraintViolationException::forViolationList($violation_list); + } + return $props; } /** -- GitLab From 0d023e353f4c6d1cb46a07e30dc456c9a1cdc299 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Fri, 3 Jan 2025 17:38:09 +0000 Subject: [PATCH 04/15] Simplify. --- src/Controller/ApiPublishAllController.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Controller/ApiPublishAllController.php b/src/Controller/ApiPublishAllController.php index 242860b08b..7d70b0df64 100644 --- a/src/Controller/ApiPublishAllController.php +++ b/src/Controller/ApiPublishAllController.php @@ -128,11 +128,8 @@ class ApiPublishAllController extends ApiControllerBase { } $entities[] = $entity; } - if (\count($violationSets) > 0) { - $validation_errors_response = self::createJsonResponseFromViolationSets(...$violationSets); - if ($validation_errors_response !== NULL) { - return $validation_errors_response; - } + if ($validation_errors_response = self::createJsonResponseFromViolationSets(...$violationSets)) { + return $validation_errors_response; } foreach ($entities as $entity) { $entity->save(); -- GitLab From 10e82cb319246a93d4ed230c6f9b6c91cd2f1f97 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Fri, 3 Jan 2025 18:01:36 +0000 Subject: [PATCH 05/15] Add missing return types. --- src/ComponentSource/ComponentSourceInterface.php | 2 +- src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ComponentSource/ComponentSourceInterface.php b/src/ComponentSource/ComponentSourceInterface.php index 1ce3b41714..2ad45d7fba 100644 --- a/src/ComponentSource/ComponentSourceInterface.php +++ b/src/ComponentSource/ComponentSourceInterface.php @@ -157,6 +157,6 @@ interface ComponentSourceInterface extends PluginInspectionInterface, Derivative * * @todo Refactor to use the Symfony denormalizer infrastructure? */ - public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props); + public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props): array; } diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php index c0f268d39c..ad5a67cd40 100644 --- a/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php +++ b/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php @@ -227,7 +227,7 @@ final class BlockComponent extends ComponentSourceBase implements ContainerFacto /** * {@inheritdoc} */ - public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props) { + public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props): array { return $client_props; } -- GitLab From 1d8d42fd9022ada91ceeee942bac4ba6f750d4eb Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 13:13:43 +0000 Subject: [PATCH 06/15] Improve return shape of convertClientToServer(). --- src/ClientDataToEntityConverter.php | 13 +++++-------- src/Controller/ClientServerConversionTrait.php | 8 ++++++-- .../src/Kernel/ClientServerConversionTraitTest.php | 11 +++-------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php index b42cb342c3..c756f74ec5 100644 --- a/src/ClientDataToEntityConverter.php +++ b/src/ClientDataToEntityConverter.php @@ -29,20 +29,17 @@ class ClientDataToEntityConverter { // @todo Security hardening: any key besides `layout`, `model` and `entity_form_fields` should trigger an error response. ['layout' => $layout, 'model' => $model, 'entity_form_fields' => $entity_form_fields] = $client_data; + $field_name = InternalXbFieldNameResolver::getXbFieldName($entity); + $item = $entity->get($field_name)->first(); + assert($item instanceof ComponentTreeItem); + try { - [$tree, $props] = $this->convertClientToServer($layout, $model); + $item->setValue($this->convertClientToServer($layout, $model)); } catch (ConstraintViolationException $e) { throw ConstraintViolationException::forViolationList(new EntityConstraintViolationList($entity, iterator_to_array($e->getConstraintViolationList()))); } - $field_name = InternalXbFieldNameResolver::getXbFieldName($entity); - $item = $entity->get($field_name)->first(); - assert($item instanceof ComponentTreeItem); - $item->setValue([ - 'tree' => json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT), - 'props' => json_encode($props, JSON_UNESCAPED_UNICODE), - ]); $this->setEntityFields($entity, $entity_form_fields); $original_entity_violations = $entity->validate(); // Validation happens using the server-side representation, but the diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index 532e2717b7..366c60cc60 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -125,7 +125,7 @@ trait ClientServerConversionTrait { } /** - * @return array{0: ?array, 1: ?array} + * @return array{tree: string, props: string} * @throws \Drupal\experience_builder\Exception\ConstraintViolationException */ protected function convertClientToServer(array $layout, array $model): array { @@ -168,7 +168,11 @@ trait ClientServerConversionTrait { $props_prepared_for_saving[$component_instance_uuid][$prop_name] = json_decode((string) $prop_source, TRUE); } } - return [$tree, $props_prepared_for_saving]; + + return [ + 'tree' => json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT | JSON_THROW_ON_ERROR), + 'props' => json_encode($props_prepared_for_saving, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ]; } private static function violationWithPropertyPathReplacePrefix(ConstraintViolationInterface $v, string $prefix_original, string $prefix_new): ConstraintViolationInterface { diff --git a/tests/src/Kernel/ClientServerConversionTraitTest.php b/tests/src/Kernel/ClientServerConversionTraitTest.php index 4f0f56c351..6ff6b79877 100644 --- a/tests/src/Kernel/ClientServerConversionTraitTest.php +++ b/tests/src/Kernel/ClientServerConversionTraitTest.php @@ -48,8 +48,8 @@ class ClientServerConversionTraitTest extends KernelTestBase { public function testConvertClientToServer(): void { ['layout' => $layout, 'model' => $model] = $this->getValidClientJson(); - [$tree, $props] = $this->convertClientToServer($layout, $model); - $this->assertSame($this->getValidConvertedProps(), $props); + $converted_item = $this->convertClientToServer($layout, $model); + $this->assertSame($this->getValidConvertedProps(), json_decode($converted_item['props'], TRUE)); $this->assertSame([ ComponentTreeStructure::ROOT_UUID => [ [ @@ -61,12 +61,7 @@ class ClientServerConversionTraitTest extends KernelTestBase { 'component' => 'sdc.experience_builder.image', ], ], - ], $tree); - - $converted_item = [ - 'tree' => self::encodeXBData($tree), - 'props' => self::encodeXBData($props), - ]; + ], json_decode($converted_item['tree'], TRUE)); // Ensure convert 'tree' and 'props' can be used both to create both a // config entity and a content entity field value. -- GitLab From 75b7500b0530b09c49c6f9a7c840a8a7b7eda5c5 Mon Sep 17 00:00:00 2001 From: Dave Long <24510-longwave@users.noreply.drupalcode.org> Date: Mon, 6 Jan 2025 13:16:10 +0000 Subject: [PATCH 07/15] Apply 2 suggestion(s) to 2 file(s) Co-authored-by: Wim Leers <44946-wimleers@users.noreply.drupalcode.org> --- src/Controller/ApiContentUpdateForDemoController.php | 5 ++--- src/Controller/ClientServerConversionTrait.php | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Controller/ApiContentUpdateForDemoController.php b/src/Controller/ApiContentUpdateForDemoController.php index cb88235356..3ab030db2b 100644 --- a/src/Controller/ApiContentUpdateForDemoController.php +++ b/src/Controller/ApiContentUpdateForDemoController.php @@ -42,9 +42,8 @@ final class ApiContentUpdateForDemoController extends ApiControllerBase { ], $entity); } catch (ConstraintViolationException $e) { - if ($validation_errors_response = self::createJsonResponseFromViolations($e->getConstraintViolationList())) { - return $validation_errors_response; - } + assert($e->getConstraintViolationList()->count() > 0); + return self::createJsonResponseFromViolations($e->getConstraintViolationList()); } return self::save($entity); diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index 366c60cc60..bf4b4f7a65 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -113,7 +113,7 @@ trait ClientServerConversionTrait { // We use ::add here rather than ::addAll because ::addAll doesn't reset // the internal groupings in EntityConstraintViolationList whereas ::add // does. - // @see https://drupal.org/i/3490588 + // @todo Remove this comment and change this foreach loop to a single ::addAll() call after https://drupal.org/i/3490588 lands. $violation_list->add($violation); } } -- GitLab From f553fb480a48da2c91be64861f8aac05b70b5122 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 13:18:42 +0000 Subject: [PATCH 08/15] Add missing try/catch. --- src/Controller/ApiPreviewController.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Controller/ApiPreviewController.php b/src/Controller/ApiPreviewController.php index 8f06984a3b..475f5e196a 100644 --- a/src/Controller/ApiPreviewController.php +++ b/src/Controller/ApiPreviewController.php @@ -9,6 +9,7 @@ use Drupal\Core\Render\Element; use Drupal\Core\TypedData\TypedDataManagerInterface; use Drupal\experience_builder\AutoSave\AutoSaveManager; use Drupal\experience_builder\Entity\PageTemplate; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; use Drupal\experience_builder\Render\PreviewEnvelope; @@ -124,8 +125,13 @@ final class ApiPreviewController { 'data_definition' => $field_item_definition, ]); - // @todo Catch violations in https://www.drupal.org/project/experience_builder/issues/3485878 - $tree = self::clientLayoutToServerTree($layout); + try { + $tree = self::clientLayoutToServerTree($layout); + } + catch (ConstraintViolationException) { + // @todo Handle violations in https://www.drupal.org/project/experience_builder/issues/3485878 + $tree = NULL; + } // This uses a partial override of the XB field type, because the client is // sending explicit prop values in its `model`, not prop sources. Use these -- GitLab From 66f925f595e7c9f8c2ef3d7d0fb92126cd2b14a7 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 13:35:09 +0000 Subject: [PATCH 09/15] Allow ApiExceptionSubscriber to catch ConstraintViolationException. --- src/Controller/ApiConfigControllers.php | 16 +++-- .../ApiContentUpdateForDemoController.php | 16 ++--- src/Controller/ApiControllerBase.php | 67 +------------------ .../ApiExceptionSubscriber.php | 52 +++++++++++++- 4 files changed, 67 insertions(+), 84 deletions(-) diff --git a/src/Controller/ApiConfigControllers.php b/src/Controller/ApiConfigControllers.php index 53e5b25442..4e021d1214 100644 --- a/src/Controller/ApiConfigControllers.php +++ b/src/Controller/ApiConfigControllers.php @@ -13,6 +13,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\GeneratedUrl; use Drupal\Core\Url; use Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -103,9 +104,7 @@ final class ApiConfigControllers extends ApiControllerBase { ->getStorage($xb_config_entity_type_id) ->create($denormalized); assert($xb_config_entity instanceof XbHttpApiEligibleConfigEntityInterface); - if ($validation_errors_response = self::createJsonResponseFromViolations($xb_config_entity->getTypedData()->validate())) { - return $validation_errors_response; - } + $this->validate($xb_config_entity); // Save the XB config entity, respond with a 201. $xb_config_entity->save(); @@ -139,9 +138,7 @@ final class ApiConfigControllers extends ApiControllerBase { foreach ($denormalized as $property_name => $property_value) { $xb_config_entity->set($property_name, $property_value); } - if ($validation_errors_response = self::createJsonResponseFromViolations($xb_config_entity->getTypedData()->validate())) { - return $validation_errors_response; - } + $this->validate($xb_config_entity); // Save the XB config entity, respond with a 200. $xb_config_entity->save(); @@ -151,6 +148,13 @@ final class ApiConfigControllers extends ApiControllerBase { return new JsonResponse(status: 200, data: $normalization); } + private function validate(XbHttpApiEligibleConfigEntityInterface $xb_config_entity): void { + $violations = $xb_config_entity->getTypedData()->validate(); + if ($violations->count()) { + throw ConstraintViolationException::forViolationList($violations); + } + } + /** * Normalizes all config entities of a given config entity type. * diff --git a/src/Controller/ApiContentUpdateForDemoController.php b/src/Controller/ApiContentUpdateForDemoController.php index 3ab030db2b..e5333184af 100644 --- a/src/Controller/ApiContentUpdateForDemoController.php +++ b/src/Controller/ApiContentUpdateForDemoController.php @@ -34,17 +34,11 @@ final class ApiContentUpdateForDemoController extends ApiControllerBase { public function __invoke(FieldableEntityInterface $entity): JsonResponse { $auto_save = $this->autoSaveManager->getAutoSaveData($entity); assert(is_array($auto_save)); - try { - $this->clientDataToEntityConverter->convert([ - 'layout' => reset($auto_save['layout']), - 'model' => $auto_save['model'], - 'entity_form_fields' => $auto_save['entity_form_fields'], - ], $entity); - } - catch (ConstraintViolationException $e) { - assert($e->getConstraintViolationList()->count() > 0); - return self::createJsonResponseFromViolations($e->getConstraintViolationList()); - } + $this->clientDataToEntityConverter->convert([ + 'layout' => reset($auto_save['layout']), + 'model' => $auto_save['model'], + 'entity_form_fields' => $auto_save['entity_form_fields'], + ], $entity); return self::save($entity); } diff --git a/src/Controller/ApiControllerBase.php b/src/Controller/ApiControllerBase.php index 1cc14eaf5f..fe0ab54b05 100644 --- a/src/Controller/ApiControllerBase.php +++ b/src/Controller/ApiControllerBase.php @@ -3,7 +3,7 @@ namespace Drupal\experience_builder\Controller; use Drupal\Core\Entity\EntityConstraintViolationListInterface; -use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\experience_builder\EventSubscriber\ApiExceptionSubscriber; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\ConstraintViolationListInterface; @@ -14,32 +14,6 @@ use Symfony\Component\Validator\ConstraintViolationListInterface; */ class ApiControllerBase { - /** - * Creates a JSON:API-style error response from a list of violations. - * - * @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations - * The violations list. - * - * @return \Symfony\Component\HttpFoundation\JsonResponse|null - * A JSON:API-style error response, with a top-level `errors` member that - * contains an array of `error` objects. - * - * @see https://jsonapi.org/format/#document-top-level - * @see https://jsonapi.org/format/#error-objects - */ - protected static function createJsonResponseFromViolations(ConstraintViolationListInterface $violations): ?JsonResponse { - if ($violations->count() === 0) { - return NULL; - } - - return new JsonResponse(status: 422, data: [ - 'errors' => array_map( - fn($violation) => self::violationToJsonApiStyleErrorObject($violation), - iterator_to_array($violations) - ), - ]); - } - /** * Creates a JSON:API-style error response from a set of entity violations. * @@ -62,7 +36,7 @@ class ApiControllerBase { return new JsonResponse(status: 422, data: [ 'errors' => \array_reduce($violationSets, static fn(array $carry, ConstraintViolationListInterface $violationList): array => [ ...$carry, - ...\array_map(static fn(ConstraintViolationInterface $violation) => self::violationToJsonApiStyleErrorObject( + ...\array_map(static fn(ConstraintViolationInterface $violation) => ApiExceptionSubscriber::violationToJsonApiStyleErrorObject( $violation, $violationList instanceof EntityConstraintViolationListInterface ? $violationList->getEntity() : NULL, ), \iterator_to_array($violationList)), @@ -70,41 +44,4 @@ class ApiControllerBase { ]); } - /** - * Transforms a constraint violation to a JSON:API-style error object. - * - * @param \Symfony\Component\Validator\ConstraintViolationInterface $violation - * A validation constraint violation. - * @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity - * An associated entity if appropriate. - * - * @return array{'detail': string, 'source': array{'pointer': string}} - * A subset of a JSON:API error object. - * - * @see https://jsonapi.org/format/#error-objects - * @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer - */ - private static function violationToJsonApiStyleErrorObject( - ConstraintViolationInterface $violation, - ?FieldableEntityInterface $entity = NULL, - ): array { - $meta = []; - if ($entity !== NULL) { - $meta = [ - 'meta' => [ - 'entity_type' => $entity->getEntityTypeId(), - 'entity_id' => $entity->id(), - 'label' => $entity->label(), - ], - ]; - } - return [ - 'detail' => (string) $violation->getMessage(), - 'source' => [ - // @todo Correctly convert to a JSON pointer. - 'pointer' => $violation->getPropertyPath(), - ], - ] + $meta; - } - } diff --git a/src/EventSubscriber/ApiExceptionSubscriber.php b/src/EventSubscriber/ApiExceptionSubscriber.php index c024ad31b7..deb79ca65c 100644 --- a/src/EventSubscriber/ApiExceptionSubscriber.php +++ b/src/EventSubscriber/ApiExceptionSubscriber.php @@ -5,15 +5,18 @@ namespace Drupal\experience_builder\EventSubscriber; use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheableJsonResponse; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\ParamConverter\ParamNotConvertedException; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Validator\ConstraintViolationInterface; /** * Handle exceptions for Experience Builder API routes. @@ -56,8 +59,16 @@ final class ApiExceptionSubscriber implements EventSubscriberInterface { default => Response::HTTP_INTERNAL_SERVER_ERROR, }; - // Generate a JSON response with a message when the status is not 404 - if ($status !== 404) { + if ($exception instanceof ConstraintViolationException) { + $status = Response::HTTP_UNPROCESSABLE_ENTITY; + $response['errors'] = array_map( + fn($violation) => self::violationToJsonApiStyleErrorObject($violation), + iterator_to_array($exception->getConstraintViolationList()) + ); + } + + // Generate a JSON response with a message when the status is not 404 or 422. + if ($status !== Response::HTTP_NOT_FOUND && $status !== Response::HTTP_UNPROCESSABLE_ENTITY) { $response['message'] = $exception->getMessage(); } @@ -99,4 +110,41 @@ final class ApiExceptionSubscriber implements EventSubscriberInterface { return $events; } + /** + * Transforms a constraint violation to a JSON:API-style error object. + * + * @param \Symfony\Component\Validator\ConstraintViolationInterface $violation + * A validation constraint violation. + * @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity + * An associated entity if appropriate. + * + * @return array{'detail': string, 'source': array{'pointer': string}} + * A subset of a JSON:API error object. + * + * @see https://jsonapi.org/format/#error-objects + * @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer + */ + public static function violationToJsonApiStyleErrorObject( + ConstraintViolationInterface $violation, + ?FieldableEntityInterface $entity = NULL, + ): array { + $meta = []; + if ($entity !== NULL) { + $meta = [ + 'meta' => [ + 'entity_type' => $entity->getEntityTypeId(), + 'entity_id' => $entity->id(), + 'label' => $entity->label(), + ], + ]; + } + return [ + 'detail' => (string) $violation->getMessage(), + 'source' => [ + // @todo Correctly convert to a JSON pointer. + 'pointer' => $violation->getPropertyPath(), + ], + ] + $meta; + } + } -- GitLab From d7bf61fc53de21516bc8236269de065b09ada94e Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 13:53:01 +0000 Subject: [PATCH 10/15] Combine try/catch blocks. --- src/Controller/ApiPublishAllController.php | 51 +++++++++++----------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/Controller/ApiPublishAllController.php b/src/Controller/ApiPublishAllController.php index 7d70b0df64..e7145aeb21 100644 --- a/src/Controller/ApiPublishAllController.php +++ b/src/Controller/ApiPublishAllController.php @@ -93,40 +93,31 @@ class ApiPublishAllController extends ApiControllerBase { $violationSets = []; $entities = []; foreach ($all_auto_saves as $auto_save) { - $entity = $this->entityTypeManager->getStorage($auto_save['entity_type'])->load($auto_save['entity_id']); - if ($entity instanceof PageTemplate) { - try { + $entity = $this->entityTypeManager->getStorage($auto_save['entity_type']) + ->load($auto_save['entity_id']); + + try { + if ($entity instanceof PageTemplate) { $entity = $entity->forAutoSaveData($auto_save['data']); + $entity->enforceIsNew(FALSE); + $this->validatePageTemplate($entity); } - catch (ConstraintViolationException $e) { - $violationSets[] = $e->getConstraintViolationList(); - continue; - } - $entity->enforceIsNew(FALSE); - // @todo Use a violation list that allows keeping track of the entity - // context. - // @see https://www.drupal.org/project/drupal/issues/3495599 - $violations = $this->typedConfigManager->createFromNameAndData($entity->getConfigDependencyName(), $entity->toArray())->validate(); - if ($violations->count() > 0) { - $violationSets[] = $violations; - } - } - else { - assert($entity instanceof FieldableEntityInterface); - // Pluck out only the content region. - $content_region = \array_values(\array_filter($auto_save['data']['layout'], static fn(array $region) => $region['id'] === 'content')); - try { + else { + assert($entity instanceof FieldableEntityInterface); + // Pluck out only the content region. + $content_region = \array_values(\array_filter($auto_save['data']['layout'], static fn(array $region) => $region['id'] === 'content')); $this->clientDataToEntityConverter->convert([ 'layout' => reset($content_region), 'model' => $auto_save['data']['model'], 'entity_form_fields' => $auto_save['data']['entity_form_fields'], ], $entity); } - catch (ConstraintViolationException $e) { - $violationSets[] = $e->getConstraintViolationList(); - } + + $entities[] = $entity; + } + catch (ConstraintViolationException $e) { + $violationSets[] = $e->getConstraintViolationList(); } - $entities[] = $entity; } if ($validation_errors_response = self::createJsonResponseFromViolationSets(...$violationSets)) { return $validation_errors_response; @@ -138,4 +129,14 @@ class ApiPublishAllController extends ApiControllerBase { return new JsonResponse(data: ['message' => new PluralTranslatableMarkup(\count($all_auto_saves), 'Successfully published 1 item.', 'Successfully published @count items.')], status: 200); } + private function validatePageTemplate(PageTemplate $entity): void { + // @todo Use a violation list that allows keeping track of the entity + // context. + // @see https://www.drupal.org/project/drupal/issues/3495599 + $violations = $this->typedConfigManager->createFromNameAndData($entity->getConfigDependencyName(), $entity->toArray())->validate(); + if ($violations->count() > 0) { + throw ConstraintViolationException::forViolationList($violations); + } + } + } -- GitLab From c0aae598033c357f18c5d09422a7c8fe05f00797 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 14:49:03 +0000 Subject: [PATCH 11/15] Unused use statement. --- src/Controller/ApiContentUpdateForDemoController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Controller/ApiContentUpdateForDemoController.php b/src/Controller/ApiContentUpdateForDemoController.php index e5333184af..25abf4f510 100644 --- a/src/Controller/ApiContentUpdateForDemoController.php +++ b/src/Controller/ApiContentUpdateForDemoController.php @@ -8,7 +8,6 @@ use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\RevisionableInterface; use Drupal\experience_builder\AutoSave\AutoSaveManager; use Drupal\experience_builder\ClientDataToEntityConverter; -use Drupal\experience_builder\Exception\ConstraintViolationException; use Symfony\Component\HttpFoundation\JsonResponse; /** -- GitLab From 69e137475b682340b2ef58755e5b7986f2bdf55f Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 15:48:13 +0000 Subject: [PATCH 12/15] Move property path remapping to ConstraintViolationException. --- src/ClientDataToEntityConverter.php | 19 ++++++-------- .../ClientServerConversionTrait.php | 25 +++---------------- .../ConstraintViolationException.php | 22 +++++++++++++++- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php index c756f74ec5..619033f8e9 100644 --- a/src/ClientDataToEntityConverter.php +++ b/src/ClientDataToEntityConverter.php @@ -14,7 +14,6 @@ use Drupal\experience_builder\Exception\ConstraintViolationException; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; use Symfony\Component\Validator\ConstraintViolation; -use Symfony\Component\Validator\ConstraintViolationInterface; class ClientDataToEntityConverter { @@ -48,16 +47,14 @@ class ClientDataToEntityConverter { // @see ::clientLayoutToServerTree() // @see ::clientModelToServerProps() if ($original_entity_violations->count()) { - throw ConstraintViolationException::forViolationList(new EntityConstraintViolationList($entity, array_map( - fn (ConstraintViolationInterface $v) => match (TRUE) { - str_starts_with($v->getPropertyPath(), "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . ']', 'layout.children'), - // @todo Perform a more complex transformation to accurately point to non-root-level components, OR remove the need for that in https://www.drupal.org/project/experience_builder/issues/3467954 - str_starts_with($v->getPropertyPath(), "$field_name.0.tree") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.tree", 'layout'), - str_starts_with($v->getPropertyPath(), "$field_name.0.props") => self::violationWithPropertyPathReplacePrefix($v, "$field_name.0.props", 'model'), - default => $v, - }, - iterator_to_array($original_entity_violations), - ))); + throw ConstraintViolationException::forViolationList( + new EntityConstraintViolationList($entity, iterator_to_array($original_entity_violations)), + [ + "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children', + "$field_name.0.tree" => 'layout', + "$field_name.0.props" => 'model', + ] + ); } } diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index bf4b4f7a65..1e9cbc8b4b 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -8,8 +8,6 @@ use Drupal\Core\TypedData\DataDefinition; use Drupal\experience_builder\Entity\Component; use Drupal\experience_builder\Exception\ConstraintViolationException; use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure; -use Symfony\Component\Validator\ConstraintViolation; -use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\ConstraintViolationList; /** @@ -138,11 +136,9 @@ trait ClientServerConversionTrait { $tree = self::clientLayoutToServerTree($layout); } catch (ConstraintViolationException $e) { - throw ConstraintViolationException::forViolationList(new ConstraintViolationList(array_map( - // @todo move to a helper on ConstraintViolationException? - fn (ConstraintViolationInterface $v) => self::violationWithPropertyPathReplacePrefix($v, '[' . ComponentTreeStructure::ROOT_UUID . ']', "layout.children"), - iterator_to_array($e->getConstraintViolationList()), - ))); + throw ConstraintViolationException::forViolationList($e->getConstraintViolationList(), [ + "[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children', + ]); } // Denormalize the `model` the client sent into a value that the server-side @@ -175,19 +171,4 @@ trait ClientServerConversionTrait { ]; } - private static function violationWithPropertyPathReplacePrefix(ConstraintViolationInterface $v, string $prefix_original, string $prefix_new): ConstraintViolationInterface { - return new ConstraintViolation( - $v->getMessage(), - $v->getMessageTemplate(), - $v->getParameters(), - $v->getRoot(), - preg_replace('/^' . preg_quote($prefix_original, '/') . '/', $prefix_new, $v->getPropertyPath()), - $v->getInvalidValue(), - $v->getPlural(), - $v->getCode(), - $v->getConstraint(), - $v->getCause(), - ); - } - } diff --git a/src/Exception/ConstraintViolationException.php b/src/Exception/ConstraintViolationException.php index ff0fc289f2..79422ce142 100644 --- a/src/Exception/ConstraintViolationException.php +++ b/src/Exception/ConstraintViolationException.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\experience_builder\Exception; +use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationListInterface; /** @@ -15,7 +16,26 @@ final class ConstraintViolationException extends \Exception { parent::__construct($message); } - public static function forViolationList(ConstraintViolationListInterface $violation_list): static { + public static function forViolationList(ConstraintViolationListInterface $violation_list, array $map = []): static { + foreach ($map as $prefix_original => $prefix_new) { + foreach ($violation_list as $key => $v) { + if (str_starts_with($v->getPropertyPath(), $prefix_original)) { + $violation_list[$key] = new ConstraintViolation( + $v->getMessage(), + $v->getMessageTemplate(), + $v->getParameters(), + $v->getRoot(), + preg_replace('/^' . preg_quote($prefix_original, '/') . '/', $prefix_new, $v->getPropertyPath()), + $v->getInvalidValue(), + $v->getPlural(), + $v->getCode(), + $v->getConstraint(), + $v->getCause(), + ); + } + } + } + return new static( $violation_list, 'Validation errors exist', -- GitLab From 902f90c7423bff47ed30cc4847803697370d0b3f Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 15:55:52 +0000 Subject: [PATCH 13/15] Simplify API. --- src/ClientDataToEntityConverter.php | 13 +++++-------- .../ClientServerConversionTrait.php | 4 +--- .../ConstraintViolationException.php | 19 +++++++++++-------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php index 619033f8e9..1371f1de11 100644 --- a/src/ClientDataToEntityConverter.php +++ b/src/ClientDataToEntityConverter.php @@ -47,14 +47,11 @@ class ClientDataToEntityConverter { // @see ::clientLayoutToServerTree() // @see ::clientModelToServerProps() if ($original_entity_violations->count()) { - throw ConstraintViolationException::forViolationList( - new EntityConstraintViolationList($entity, iterator_to_array($original_entity_violations)), - [ - "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children', - "$field_name.0.tree" => 'layout', - "$field_name.0.props" => 'model', - ] - ); + throw ConstraintViolationException::forViolationList(new EntityConstraintViolationList($entity, iterator_to_array($original_entity_violations)))->renamePropertyPaths([ + "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children', + "$field_name.0.tree" => 'layout', + "$field_name.0.props" => 'model', + ]); } } diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index 1e9cbc8b4b..01db7f8bb5 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -136,9 +136,7 @@ trait ClientServerConversionTrait { $tree = self::clientLayoutToServerTree($layout); } catch (ConstraintViolationException $e) { - throw ConstraintViolationException::forViolationList($e->getConstraintViolationList(), [ - "[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children', - ]); + throw $e->renamePropertyPaths(["[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children']); } // Denormalize the `model` the client sent into a value that the server-side diff --git a/src/Exception/ConstraintViolationException.php b/src/Exception/ConstraintViolationException.php index 79422ce142..9cb6b85426 100644 --- a/src/Exception/ConstraintViolationException.php +++ b/src/Exception/ConstraintViolationException.php @@ -16,11 +16,18 @@ final class ConstraintViolationException extends \Exception { parent::__construct($message); } - public static function forViolationList(ConstraintViolationListInterface $violation_list, array $map = []): static { + public static function forViolationList(ConstraintViolationListInterface $violation_list): static { + return new static( + $violation_list, + 'Validation errors exist', + ); + } + + public function renamePropertyPaths(array $map): self { foreach ($map as $prefix_original => $prefix_new) { - foreach ($violation_list as $key => $v) { + foreach ($this->constraintViolationList as $key => $v) { if (str_starts_with($v->getPropertyPath(), $prefix_original)) { - $violation_list[$key] = new ConstraintViolation( + $this->constraintViolationList[$key] = new ConstraintViolation( $v->getMessage(), $v->getMessageTemplate(), $v->getParameters(), @@ -35,11 +42,7 @@ final class ConstraintViolationException extends \Exception { } } } - - return new static( - $violation_list, - 'Validation errors exist', - ); + return $this; } /** -- GitLab From 168215f8d098d6ebad50a191aba24658a6d6f8b6 Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 15:59:44 +0000 Subject: [PATCH 14/15] Remove exception factory method. --- src/ClientDataToEntityConverter.php | 6 +++--- src/Controller/ApiConfigControllers.php | 2 +- src/Controller/ApiPublishAllController.php | 2 +- src/Controller/ClientServerConversionTrait.php | 4 ++-- src/Entity/PageTemplate.php | 2 +- src/Exception/ConstraintViolationException.php | 9 +-------- .../ComponentSource/SingleDirectoryComponent.php | 2 +- 7 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php index 1371f1de11..e5cb03a70c 100644 --- a/src/ClientDataToEntityConverter.php +++ b/src/ClientDataToEntityConverter.php @@ -36,7 +36,7 @@ class ClientDataToEntityConverter { $item->setValue($this->convertClientToServer($layout, $model)); } catch (ConstraintViolationException $e) { - throw ConstraintViolationException::forViolationList(new EntityConstraintViolationList($entity, iterator_to_array($e->getConstraintViolationList()))); + throw new ConstraintViolationException(new EntityConstraintViolationList($entity, iterator_to_array($e->getConstraintViolationList()))); } $this->setEntityFields($entity, $entity_form_fields); @@ -47,7 +47,7 @@ class ClientDataToEntityConverter { // @see ::clientLayoutToServerTree() // @see ::clientModelToServerProps() if ($original_entity_violations->count()) { - throw ConstraintViolationException::forViolationList(new EntityConstraintViolationList($entity, iterator_to_array($original_entity_violations)))->renamePropertyPaths([ + throw (new ConstraintViolationException(new EntityConstraintViolationList($entity, iterator_to_array($original_entity_violations))))->renamePropertyPaths([ "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children', "$field_name.0.tree" => 'layout', "$field_name.0.props" => 'model', @@ -144,7 +144,7 @@ class ClientDataToEntityConverter { } } if ($violations_list->count()) { - throw ConstraintViolationException::forViolationList($violations_list); + throw new ConstraintViolationException($violations_list); } } diff --git a/src/Controller/ApiConfigControllers.php b/src/Controller/ApiConfigControllers.php index 4e021d1214..0f533dbb9d 100644 --- a/src/Controller/ApiConfigControllers.php +++ b/src/Controller/ApiConfigControllers.php @@ -151,7 +151,7 @@ final class ApiConfigControllers extends ApiControllerBase { private function validate(XbHttpApiEligibleConfigEntityInterface $xb_config_entity): void { $violations = $xb_config_entity->getTypedData()->validate(); if ($violations->count()) { - throw ConstraintViolationException::forViolationList($violations); + throw new ConstraintViolationException($violations); } } diff --git a/src/Controller/ApiPublishAllController.php b/src/Controller/ApiPublishAllController.php index e7145aeb21..9040d60cc2 100644 --- a/src/Controller/ApiPublishAllController.php +++ b/src/Controller/ApiPublishAllController.php @@ -135,7 +135,7 @@ class ApiPublishAllController extends ApiControllerBase { // @see https://www.drupal.org/project/drupal/issues/3495599 $violations = $this->typedConfigManager->createFromNameAndData($entity->getConfigDependencyName(), $entity->toArray())->validate(); if ($violations->count() > 0) { - throw ConstraintViolationException::forViolationList($violations); + throw new ConstraintViolationException($violations); } } diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index 01db7f8bb5..787d9a6da5 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -33,7 +33,7 @@ trait ClientServerConversionTrait { $component_tree_structure->setValue(json_encode($tree, JSON_UNESCAPED_UNICODE)); $violations = $component_tree_structure->validate(); if ($violations->count()) { - throw ConstraintViolationException::forViolationList($violations); + throw new ConstraintViolationException($violations); } return $tree; @@ -117,7 +117,7 @@ trait ClientServerConversionTrait { } } if ($violation_list->count()) { - throw ConstraintViolationException::forViolationList($violation_list); + throw new ConstraintViolationException($violation_list); } return $props; } diff --git a/src/Entity/PageTemplate.php b/src/Entity/PageTemplate.php index f32d91aebd..d2a0f195c5 100644 --- a/src/Entity/PageTemplate.php +++ b/src/Entity/PageTemplate.php @@ -133,7 +133,7 @@ final class PageTemplate extends ConfigEntityBase implements XbHttpApiEligibleCo ]; } if ($allViolations->count() > 0) { - throw ConstraintViolationException::forViolationList($allViolations); + throw new ConstraintViolationException($allViolations); } $values['component_trees'] = $treeItems; return static::create($values); diff --git a/src/Exception/ConstraintViolationException.php b/src/Exception/ConstraintViolationException.php index 9cb6b85426..4e13b8b6a7 100644 --- a/src/Exception/ConstraintViolationException.php +++ b/src/Exception/ConstraintViolationException.php @@ -12,17 +12,10 @@ use Symfony\Component\Validator\ConstraintViolationListInterface; */ final class ConstraintViolationException extends \Exception { - private function __construct(protected ConstraintViolationListInterface $constraintViolationList, string $message) { + public function __construct(protected ConstraintViolationListInterface $constraintViolationList, string $message = 'Validation errors exist') { parent::__construct($message); } - public static function forViolationList(ConstraintViolationListInterface $violation_list): static { - return new static( - $violation_list, - 'Validation errors exist', - ); - } - public function renamePropertyPaths(array $map): self { foreach ($map as $prefix_original => $prefix_new) { foreach ($this->constraintViolationList as $key => $v) { diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php index 86c808b153..238f4a54ff 100644 --- a/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php +++ b/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php @@ -620,7 +620,7 @@ final class SingleDirectoryComponent extends ComponentSourceBase implements Comp } if ($violation_list->count()) { - throw ConstraintViolationException::forViolationList($violation_list); + throw new ConstraintViolationException($violation_list); } return $props; -- GitLab From 99b56d046910056a7ea5135b0e1539636c3f68ab Mon Sep 17 00:00:00 2001 From: Dave Long <dave@longwaveconsulting.com> Date: Mon, 6 Jan 2025 17:01:57 +0000 Subject: [PATCH 15/15] Add @todos. --- src/ClientDataToEntityConverter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php index e5cb03a70c..434dcf4684 100644 --- a/src/ClientDataToEntityConverter.php +++ b/src/ClientDataToEntityConverter.php @@ -36,6 +36,7 @@ class ClientDataToEntityConverter { $item->setValue($this->convertClientToServer($layout, $model)); } catch (ConstraintViolationException $e) { + // @todo Remove iterator_to_array() after https://www.drupal.org/project/drupal/issues/3497677 throw new ConstraintViolationException(new EntityConstraintViolationList($entity, iterator_to_array($e->getConstraintViolationList()))); } @@ -47,6 +48,7 @@ class ClientDataToEntityConverter { // @see ::clientLayoutToServerTree() // @see ::clientModelToServerProps() if ($original_entity_violations->count()) { + // @todo Remove iterator_to_array() after https://www.drupal.org/project/drupal/issues/3497677 throw (new ConstraintViolationException(new EntityConstraintViolationList($entity, iterator_to_array($original_entity_violations))))->renamePropertyPaths([ "$field_name.0.tree[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children', "$field_name.0.tree" => 'layout', -- GitLab