diff --git a/src/ClientDataToEntityConverter.php b/src/ClientDataToEntityConverter.php index a8c697ea780af9f001b7db2833a0abec275538b6..434dcf4684fcc21d2e68be369c667eaf947c364f 100644 --- a/src/ClientDataToEntityConverter.php +++ b/src/ClientDataToEntityConverter.php @@ -4,17 +4,16 @@ 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; 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; -use Symfony\Component\Validator\ConstraintViolationInterface; class ClientDataToEntityConverter { @@ -25,43 +24,37 @@ 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; - [$tree, $props, $violations] = $this->convertClientToServer($layout, $model); - if ($violations->count() > 0) { - return new EntityConstraintViolationList($entity, iterator_to_array($violations)); - } - $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), - ]); - $violations = $this->setEntityFields($entity, $entity_form_fields); - if ($violations->count() > 0) { - return $violations; + + try { + $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()))); } + + $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()) { + // @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', + "$field_name.0.props" => 'model', + ]); + } } /** @@ -108,7 +101,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); @@ -152,7 +145,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 new ConstraintViolationException($violations_list); + } } } diff --git a/src/ComponentSource/ComponentSourceInterface.php b/src/ComponentSource/ComponentSourceInterface.php index b7a786a11e51a69e2132d32c1b2c876ed67de4fb..2ad45d7fba0ae1d3f9412b3403f9990720a10a87 100644 --- a/src/ComponentSource/ComponentSourceInterface.php +++ b/src/ComponentSource/ComponentSourceInterface.php @@ -152,10 +152,11 @@ interface ComponentSourceInterface extends PluginInspectionInterface, Derivative public function buildConfigurationForm(array $form, FormStateInterface $form_state, string $component_instance_uuid = '', ?EntityInterface $entity = NULL, array $settings = []): array; /** - * @return array{0: array<string, \Drupal\experience_builder\PropSource\StaticPropSource>, 1: \Symfony\Component\Validator\ConstraintViolationListInterface} + * @return array<string, \Drupal\experience_builder\PropSource\StaticPropSource> + * @throws \Drupal\experience_builder\Exception\ConstraintViolationException * * @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/Controller/ApiConfigControllers.php b/src/Controller/ApiConfigControllers.php index 53e5b25442bbfe7438f13ffdef04f75a09c5e634..0f533dbb9d1aef2afe5dd6feea6c6e3845262801 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 new ConstraintViolationException($violations); + } + } + /** * Normalizes all config entities of a given config entity type. * diff --git a/src/Controller/ApiContentUpdateForDemoController.php b/src/Controller/ApiContentUpdateForDemoController.php index d502cf537672c1b48debe72925a013a99250658f..25abf4f5100df44b66b35b0502882514858742ca 100644 --- a/src/Controller/ApiContentUpdateForDemoController.php +++ b/src/Controller/ApiContentUpdateForDemoController.php @@ -33,14 +33,11 @@ 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([ + $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; - } return self::save($entity); } diff --git a/src/Controller/ApiControllerBase.php b/src/Controller/ApiControllerBase.php index 1cc14eaf5f9952fc2423a112cfa0c9353742f435..fe0ab54b05d97c6e4ee2afdf1d67e7c3e5c2974a 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/Controller/ApiPreviewController.php b/src/Controller/ApiPreviewController.php index c4948cddea9f197def3cf783fad565539c4dfd7c..475f5e196a49c07ec84cc2502a3364bfa5b21ec8 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,10 +125,13 @@ 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 + 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 diff --git a/src/Controller/ApiPublishAllController.php b/src/Controller/ApiPublishAllController.php index 83ef244c7f8438ae77d0fdddb15901efa0fcae99..9040d60cc23e72f0ea60704f9988ca34b8042a29 100644 --- a/src/Controller/ApiPublishAllController.php +++ b/src/Controller/ApiPublishAllController.php @@ -93,41 +93,34 @@ 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; + 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); } - $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(); - } - 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); + + $entities[] = $entity; } - $entities[] = $entity; - if ($violations->count() > 0) { - $violationSets[] = $violations; + catch (ConstraintViolationException $e) { + $violationSets[] = $e->getConstraintViolationList(); } } - 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(); @@ -136,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 new ConstraintViolationException($violations); + } + } + } diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php index f7bd9548b453dfa48144ca9215f718bb512e73c8..787d9a6da522f142bea85141034e348b181a981c 100644 --- a/src/Controller/ClientServerConversionTrait.php +++ b/src/Controller/ClientServerConversionTrait.php @@ -6,9 +6,8 @@ namespace Drupal\experience_builder\Controller; 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; /** @@ -20,9 +19,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); @@ -32,8 +32,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 new ConstraintViolationException($violations); + } - return [$tree, $violations]; + return $tree; } /** @@ -85,7 +88,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'); @@ -99,20 +103,28 @@ trait ClientServerConversionTrait { foreach ($model as $uuid => $client_props) { $component = Component::load($component_tree_structure->getComponentId($uuid)); assert($component instanceof Component); - [$props[$uuid], $violations_for_component_instance] = $component->getComponentSource()->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] = $component->getComponentSource()->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. + // @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); + } } } - return [$props, $violation_list]; + if ($violation_list->count()) { + throw new ConstraintViolationException($violation_list); + } + return $props; } /** - * @return array{0: ?array, 1: ?array, 2: \Symfony\Component\Validator\ConstraintViolationList} + * @return array{tree: string, props: string} + * @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- @@ -120,13 +132,11 @@ 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 $e->renamePropertyPaths(["[" . ComponentTreeStructure::ROOT_UUID . "]" => 'layout.children']); } // Denormalize the `model` the client sent into a value that the server-side @@ -136,10 +146,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 @@ -155,22 +162,11 @@ 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()]; - } - 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(), - ); + 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), + ]; } } diff --git a/src/Entity/PageTemplate.php b/src/Entity/PageTemplate.php index 698cf8795598629239a482d08b0b6712e2c3818a..d2a0f195c51f7df79db866912838dc0a5c538023 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; } @@ -129,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/EventSubscriber/ApiExceptionSubscriber.php b/src/EventSubscriber/ApiExceptionSubscriber.php index c024ad31b7ee080475a10632851cfd618079f6ae..deb79ca65cd3ea7d0766fabdf2d203a40f2a5404 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; + } + } diff --git a/src/Exception/ConstraintViolationException.php b/src/Exception/ConstraintViolationException.php index ff0fc289f2ea89c621b1f86eaaff7abbf060df65..4e13b8b6a7826776db6c9694188d0bf44d53c47b 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; /** @@ -11,15 +12,30 @@ 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) { + if (str_starts_with($v->getPropertyPath(), $prefix_original)) { + $this->constraintViolationList[$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 $this; } /** diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php index 6a785506a86b4eab6e6b956fb1ad27feff904f89..ad5a67cd40cfe7ab667ea5310c322c697b9635b6 100644 --- a/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php +++ b/src/Plugin/ExperienceBuilder/ComponentSource/BlockComponent.php @@ -19,7 +19,6 @@ use Drupal\experience_builder\Entity\Component; use Drupal\experience_builder\Entity\Component as ComponentEntity; use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\Validator\ConstraintViolationList; /** * Defines a component source based on block plugins. @@ -228,8 +227,8 @@ final class BlockComponent extends ComponentSourceBase implements ContainerFacto /** * {@inheritdoc} */ - public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props) { - return [$client_props, new ConstraintViolationList()]; + public function createPropsForComponent(string $component_instance_uuid, Component $component, array $client_props): array { + return $client_props; } } diff --git a/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php b/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php index 6ffbb3fef7ef55e083dcf93452f225356c8646ce..238f4a54ff12deb939bb23689aee7fefccfd7b65 100644 --- a/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php +++ b/src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php @@ -29,6 +29,7 @@ use Drupal\experience_builder\Attribute\ComponentSource; use Drupal\experience_builder\ComponentSource\ComponentSourceBase; use Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface; use Drupal\experience_builder\Entity\Component as ComponentEntity; +use Drupal\experience_builder\Exception\ConstraintViolationException; use Drupal\experience_builder\InvalidRequestBodyValue; use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem; use Drupal\experience_builder\PropExpressions\Component\ComponentPropExpression; @@ -618,7 +619,11 @@ final class SingleDirectoryComponent extends ComponentSourceBase implements Comp $props[$prop] = $updated_static_source; } - return [$props, $violation_list]; + if ($violation_list->count()) { + throw new ConstraintViolationException($violation_list); + } + + return $props; } /** diff --git a/tests/src/Kernel/ClientDataToEntityConverterTest.php b/tests/src/Kernel/ClientDataToEntityConverterTest.php index 1225d634d7334edbadaa0f33e7b93f1a47237697..7059fdf82ec602c2c4cd7182347b3c17125fb13f 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]` diff --git a/tests/src/Kernel/ClientServerConversionTraitTest.php b/tests/src/Kernel/ClientServerConversionTraitTest.php index 1a8a2fa99564652ff4fb1316e9aba4b0f57164af..6ff6b7987718159176f02268784cafd9ada28f6a 100644 --- a/tests/src/Kernel/ClientServerConversionTraitTest.php +++ b/tests/src/Kernel/ClientServerConversionTraitTest.php @@ -6,6 +6,7 @@ namespace Drupal\Tests\experience_builder\Kernel; 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; @@ -47,9 +48,8 @@ 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); - $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. @@ -122,10 +117,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())); + } } }