diff --git a/config/install/jsonapi_extras.settings.yml b/config/install/jsonapi_extras.settings.yml index fb35b06b7848f98503448042f2171ff29ddaedeb..009753110133df1d0637a5624a2e882ca1f329cb 100644 --- a/config/install/jsonapi_extras.settings.yml +++ b/config/install/jsonapi_extras.settings.yml @@ -1,3 +1,4 @@ path_prefix: jsonapi include_count: false default_disabled: false +validate_configuration_integrity: true diff --git a/config/schema/jsonapi_extras.schema.yml b/config/schema/jsonapi_extras.schema.yml index 5e7e499e9d97b74c3eab51b3d7a0a0a62bf02054..2a59be9d503ea4ed5897ff0fdb2f84da059c1cc8 100644 --- a/config/schema/jsonapi_extras.schema.yml +++ b/config/schema/jsonapi_extras.schema.yml @@ -75,3 +75,7 @@ jsonapi_extras.settings: type: boolean label: 'Disabled by default' description: "If activated, all resource types that don't have a matching enabled resource config will be disabled." + validate_configuration_integrity: + type: boolean + label: 'Validate configuration integrity' + description: "Enable a configuration validation step for the fields in your resources. This will ensure that new (and updated) fields also contain configuration for the corresponding resources." diff --git a/jsonapi_extras.install b/jsonapi_extras.install new file mode 100644 index 0000000000000000000000000000000000000000..1771e14bf47729547232dc4f221c5d8bd2fdfedc --- /dev/null +++ b/jsonapi_extras.install @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +use Drupal\Core\Config\ConfigFactoryInterface; + +/** + * @file + * + * Module installation file. + */ + +/** + * Implements hook_update_N(). + */ +function jsonapi_extras_update_9001() { + $config_factory = \Drupal::service('config.factory'); + assert($config_factory instanceof ConfigFactoryInterface); + $editable_config = $config_factory->getEditable('jsonapi_extras.settings'); + $editable_config->set('validate_configuration_integrity', FALSE); + // Since the schema changes may not be there yet, we need to trust the data. + $editable_config->save(TRUE); +} diff --git a/jsonapi_extras.services.yml b/jsonapi_extras.services.yml index 0447bb13b747586e6e82992e4896390d8b3241fa..0319b18bb09bc35a739f2386ad864cc980a732f4 100644 --- a/jsonapi_extras.services.yml +++ b/jsonapi_extras.services.yml @@ -65,3 +65,12 @@ services: - '@config.factory' tags: - { name: event_subscriber } + + jsonapi_extras.config_import_validate_subscriber: + arguments: + - '@config.manager' + - '@entity_type.manager' + - '@jsonapi_extras.resource_type.repository' + class: Drupal\jsonapi_extras\EventSubscriber\FieldConfigIntegrityValidation + tags: + - { name: event_subscriber } diff --git a/src/EventSubscriber/FieldConfigIntegrityValidation.php b/src/EventSubscriber/FieldConfigIntegrityValidation.php new file mode 100644 index 0000000000000000000000000000000000000000..cd27048957e7f39ae2c29a4ee12b8c608ab8d970 --- /dev/null +++ b/src/EventSubscriber/FieldConfigIntegrityValidation.php @@ -0,0 +1,180 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\jsonapi_extras\EventSubscriber; + +use Drupal\Core\Config\ConfigImporter; +use Drupal\Core\Config\ConfigImporterEvent; +use Drupal\Core\Config\ConfigImportValidateEventSubscriberBase; +use Drupal\Core\Config\ConfigManagerInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Url; +use Drupal\jsonapi_extras\ResourceType\ConfigurableResourceType; +use Drupal\jsonapi_extras\ResourceType\ConfigurableResourceTypeRepository; +use Drupal\jsonapi_extras\ResourceType\NullJsonapiResourceConfig; + +/** + * Makes sure that all resource config entities contain settings for all fields. + * + * This will avoid the use of default behavior when a field exists in an entity + * but there is no config about it. This typically happens when the field is + * added after the resource config was initially saved. + */ +class FieldConfigIntegrityValidation extends ConfigImportValidateEventSubscriberBase { + + /** + * The configuration manager. + * + * @var \Drupal\Core\Config\ConfigManagerInterface + */ + private ConfigManagerInterface $configManager; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + private EntityTypeManagerInterface $entityTypeManager; + + /** + * The resource type repository. + * + * @var \Drupal\jsonapi_extras\ResourceType\ConfigurableResourceTypeRepository + */ + private ConfigurableResourceTypeRepository $resourceTypeRepository; + + /** + * Creates a new validator. + * + * @param \Drupal\Core\Config\ConfigManagerInterface $config_manager + * The configuration manager. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Drupal\jsonapi_extras\ResourceType\ConfigurableResourceTypeRepository $resource_type_repository + * The resource type repository + */ + public function __construct( + ConfigManagerInterface $config_manager, + EntityTypeManagerInterface $entity_type_manager, + ConfigurableResourceTypeRepository $resource_type_repository, + ) { + $this->configManager = $config_manager; + $this->entityTypeManager = $entity_type_manager; + $this->resourceTypeRepository = $resource_type_repository; + } + + /** + * @inheritDoc + */ + public function onConfigImporterValidate(ConfigImporterEvent $event) { + $jsonapi_extras_settings = $this->configManager + ->getConfigFactory() + ->get('jsonapi_extras.settings'); + if (!$jsonapi_extras_settings->get('validate_configuration_integrity')) { + // Nothing to do. + return; + } + $config_importer = $event->getConfigImporter(); + // Get the configuration ready to be imported. Future configuration. + $changelist = $event->getChangelist(); + // Determine if any fields are being updated, if so grab their entity type + // ID and bundle. + $changed_info = $this->getChangedFields($changelist, $config_importer); + array_map( + function (array $info) use ($config_importer) { + $entity_type_id = $info['entity_type']; + $bundle = $info['bundle'] ?? $entity_type_id; + $field_name = $info['field_name']; + // First check if the configuration for JSON:API Extras will be + // installed. + $resource_config_name = sprintf( + '%s.%s--%s', + $this->entityTypeManager->getDefinition('jsonapi_resource_config')->getConfigPrefix(), + $entity_type_id, + $bundle + ); + // Check weather the field changes are accompanied by a resource change. + $new_resource_config = $config_importer->getStorageComparer()->getSourceStorage()->read($resource_config_name); + if ($new_resource_config && !empty($new_resource_config['resourceFields'][$field_name])) { + // All good. There are new fields, but they are coming in with the + // resource config as well. + return; + } + // Next let's grab the current configuration to see if there was + // configuration for that field already. + $resource_type = $this->resourceTypeRepository->get( + $entity_type_id, + $bundle, + ); + if (!$resource_type instanceof ConfigurableResourceType) { + return; + } + // Make sure there is configuration associated to the resource type, + // otherwise there is nothing to do. + $current_config = $resource_type->getJsonapiResourceConfig(); + if ($current_config instanceof NullJsonapiResourceConfig) { + return; + } + $missing = !isset($current_config->get('resourceFields')[$field_name]); + if ($missing) { + $config_importer->logError($this->t( + 'Integrity check failed for the JSON:API Extras configuration. There is no configuration set for the field "@field_name" on the resource "@entity_type--@bundle". To fix this, disable the configuration integrity check (in the JSON:API Extras settings page), so you can import these fields locally. After that configure and re-save this resource type in the JSON:API Extras configuration page (@url). Finally, re-enable the configuration integrity checks and export the configuration again.', + [ + '@field_name' => $field_name, + '@entity_type' => $entity_type_id, + '@bundle' => $bundle, + '@url' => $current_config->toUrl('edit-form', ['absolute' => TRUE])->toString(TRUE)->getGeneratedUrl(), + ], + )); + } + }, + $changed_info, + ); + } + + /** + * Get information about the fields being changed, if any. + * + * @param array $changes_per_operation + * The list of changed config names grouped by operation. + * @param \Drupal\Core\Config\ConfigImporter $importer + * The configuration importer + * + * @return array[] + * A list of associative arrays, each one containing the field name, bundle, + * and entity type of the fields being changed. + */ + private function getChangedFields(array $changes_per_operation, ConfigImporter $importer): array { + // We only care about create and update operations. + $changes_per_operation = array_intersect_key( + $changes_per_operation, + array_flip(['create', 'update']) + ); + // Filter sub-arrays to get config names that correspond to field_config. + $field_config_names_per_operation = array_map( + fn(array $config_names) => array_filter( + $config_names, + fn(string $config_name) => $this->configManager->getEntityTypeIdByName($config_name) === 'field_config' + ), + $changes_per_operation + ); + // Flatten the array. + $field_config_names = array_reduce( + $field_config_names_per_operation, + static fn(array $carry, array $names) => array_unique([...$carry, ...$names]), + [] + ); + // Read the configuration object for the field_config coming in, and collect + // the field name, bundle, and entity type. + return array_map( + static fn(string $config_name) => array_intersect_key( + $importer->getStorageComparer()->getSourceStorage()->read($config_name), + array_flip(['entity_type', 'bundle', 'field_name'])), + $field_config_names, + ); + } + + + +} diff --git a/src/Form/JsonapiExtrasSettingsForm.php b/src/Form/JsonapiExtrasSettingsForm.php index 7d594c3244b2131031a995186099fa9df2b3ded2..b3927a830c1394a6064936727d3b78f428d36d87 100644 --- a/src/Form/JsonapiExtrasSettingsForm.php +++ b/src/Form/JsonapiExtrasSettingsForm.php @@ -121,6 +121,13 @@ class JsonapiExtrasSettingsForm extends ConfigFormBase { '#default_value' => $config->get('default_disabled'), ]; + $form['validate_configuration_integrity'] = [ + '#title' => $this->t('Validate config integrity'), + '#type' => 'checkbox', + '#description' => $this->t("Enable a configuration validation step for the fields in your resources. This will ensure that new (and updated) fields also contain configuration for the corresponding resources.<br /><strong>IMPORTANT:</strong> disable this <em>temporarily</em> to allow importing incomplete configuration, so you can fix it locally and export complete configuration. Remember to re-enable this after the configuration has been fixed."), + '#default_value' => $config->get('validate_configuration_integrity'), + ]; + return parent::buildForm($form, $form_state); } @@ -137,6 +144,7 @@ class JsonapiExtrasSettingsForm extends ConfigFormBase { $this->config('jsonapi_extras.settings') ->set('include_count', $form_state->getValue('include_count')) ->set('default_disabled', $form_state->getValue('default_disabled')) + ->set('validate_configuration_integrity', $form_state->getValue('validate_configuration_integrity')) ->save(); // Rebuild the router.