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