Last active
May 12, 2023 18:59
-
-
Save mrkmiller/a0357e4d759932f67a16f9cc4d0e8dff to your computer and use it in GitHub Desktop.
Seed Cypress data into a Drupal site using JsonAPI fixtures. You will need to create a module and set up a controller to execute the SeedFixtures.php class. This will require enabling the JsonAPI module.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Execute a PHP Class to create entities based on JSON:API Fixture files. This will also automatically seed any relationship dependencies up to one level deep. | |
| * | |
| * @param {array} fixtures - List of fixture files to be imported relative to the "fixtures" directory. | |
| * | |
| * @example | |
| * Seeding a Basic Page. | |
| * cy.seedFixtures(['test-page.json']) | |
| * // Or multiple entities. | |
| * cy.seedFixtures([ | |
| * 'term-tag.json', | |
| * 'test-page.json' | |
| * ]) | |
| */ | |
| Cypress.Commands.add('seedFixtures', (fixtures) => { | |
| cy.log('Seed Fixtures: ', fixtures) | |
| return cy.request({ | |
| method: 'POST', | |
| url: '/cypress/seed', | |
| timeout: 60000, | |
| body: fixtures | |
| }).then(response => { | |
| return response.body | |
| }) | |
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Drupal\my_module_name\Cypress; | |
| use Drupal\block\Entity\Block; | |
| use Drupal\Component\Serialization\Json; | |
| use Drupal\Core\Cache\CacheBackendInterface; | |
| use Drupal\Core\DependencyInjection\ContainerInjectionInterface; | |
| use Drupal\Core\Entity\EntityRepositoryInterface; | |
| use Drupal\Core\Entity\EntityTypeManagerInterface; | |
| use Drupal\Core\Site\Settings; | |
| use Drupal\user\Entity\Role; | |
| use Symfony\Component\DependencyInjection\ContainerInterface; | |
| use Symfony\Component\HttpFoundation\Request; | |
| use Symfony\Component\HttpFoundation\Response; | |
| use Symfony\Component\HttpKernel\HttpKernelInterface; | |
| use Drupal\Core\File\FileSystemInterface; | |
| use Drupal\file\Entity\File; | |
| /** | |
| * Seed the database from fixtures using the JsonAPI data structure. | |
| * | |
| * @param string|array fixtures - | |
| */ | |
| class SeedFixtures implements ContainerInjectionInterface { | |
| /** | |
| * The fixture files to be seeded. | |
| * | |
| * @var array | |
| */ | |
| protected array $fixtures = []; | |
| /** | |
| * The title fields mapped for each entity. | |
| * | |
| * @var array|string[] | |
| */ | |
| protected array $titleFields = [ | |
| 'taxonomy_term' => 'name', | |
| 'group' => 'label', | |
| 'group_content' => 'label', | |
| 'user' => 'name', | |
| 'node' => 'title', | |
| ]; | |
| /** | |
| * The bundle name mapped for each entity. | |
| * | |
| * @var array|string[] | |
| */ | |
| protected array $bundleName = [ | |
| 'taxonomy_term' => 'vid', | |
| 'group' => 'type', | |
| 'group_content' => 'type', | |
| 'user' => 'type', | |
| 'node' => 'type', | |
| ]; | |
| /** | |
| * Determine if the Bootstrap cache needs clearing. | |
| * | |
| * @var bool | |
| */ | |
| protected bool $needsCacheClear = FALSE; | |
| /** | |
| * List of all fixtures on file keyed by their UUID. | |
| * | |
| * @var array | |
| */ | |
| protected array $allFixturesMap = []; | |
| /** | |
| * Directory path where all fixture files reside within Cypress. | |
| * | |
| * @var string | |
| */ | |
| protected string $fixtureDir = ''; | |
| /** | |
| * {@inheritdoc} | |
| */ | |
| public function __construct( | |
| protected Settings $settings, | |
| protected EntityTypeManagerInterface $entityTypeManager, | |
| protected EntityRepositoryInterface $entityRepository, | |
| protected FileSystemInterface $fileSystem, | |
| protected CacheBackendInterface $cacheBackend, | |
| protected HttpKernelInterface $httpKernel, | |
| ) { | |
| $cypress_path = $this->settings->get('cypress_path') ?? DRUPAL_ROOT . '/tests/cypress'; | |
| $this->fixtureDir = $cypress_path . '/fixtures/'; | |
| } | |
| /** | |
| * {@inheritdoc} | |
| */ | |
| public static function create(ContainerInterface $container) { | |
| return new static( | |
| $container->get('settings'), | |
| $container->get('entity_type.manager'), | |
| $container->get('entity.repository'), | |
| $container->get('file_system'), | |
| $container->get('cache.bootstrap'), | |
| $container->get('http_kernel'), | |
| ); | |
| } | |
| /** | |
| * Execute this class and return a response. | |
| * | |
| * @param array $fixtures | |
| * The array of JsonAPI fixtures passed in from Cypress. | |
| * | |
| * @return \Symfony\Component\HttpFoundation\Response | |
| * The final response returned to Cypress. | |
| */ | |
| public function execute(array $fixtures): Response { | |
| if (empty($fixtures)) { | |
| throw new \Exception('You most specify fixture names'); | |
| } | |
| $this->fixtures = $fixtures; | |
| try { | |
| return new Response($this->body()); | |
| } | |
| catch (\Exception $e) { | |
| $code = $e->getCode() ?: 400; | |
| $message = $e->getMessage(); | |
| // Simplify JsonAPI errors so they do not clutter Cypress and will make | |
| // it easier to find the error message. If the full error data needs to be | |
| // seen then someone can use xdebug and put a breakpoint here. | |
| if (str_starts_with($message, '{"jsonapi"')) { | |
| $parsed_message = Json::decode($message); | |
| if (!empty($parsed_message['errors']) && count($parsed_message['errors']) == 1) { | |
| $error = $parsed_message['errors'][0]; | |
| $message = $error['title'] . ' - ' . $error['detail']; | |
| $code = (int) $error['status']; | |
| } | |
| } | |
| return new Response($message, $code); | |
| } | |
| } | |
| /** | |
| * The body for the main work. | |
| * | |
| * @return string | |
| * This message will be returned to cypress | |
| */ | |
| protected function body(): string { | |
| foreach ($this->fixtures as $fixture) { | |
| $fixture = (str_ends_with($fixture, '.json')) ? $fixture : $fixture . '.json'; | |
| $file = $this->fixtureDir . $fixture; | |
| if (!file_exists($file)) { | |
| throw new \Exception('The fixture does not exist: ' . $file); | |
| } | |
| $parsed_content = Json::decode(file_get_contents($file)); | |
| $fixture_data = (!empty($parsed_content['data']['type'])) ? [$parsed_content['data']] : $parsed_content['data']; | |
| foreach ($fixture_data as $data) { | |
| [$entity_name, $entity_type] = explode('--', $data['type']); | |
| $title = $data['attributes'][$this->titleFields[$entity_name]]; | |
| // If this is a physical file, copy it into the "files" directory. | |
| if ($entity_name == 'file') { | |
| $this->seedFile($data); | |
| continue; | |
| } | |
| // If this is a block config, process it via Entity API since you can't | |
| // post block config via JsonAPI. | |
| if ($entity_name == 'block') { | |
| $this->seedBlock($data); | |
| continue; | |
| } | |
| $this->seedRelationships($data); | |
| // If this is a user, get the user_role uuid to add to the relationship. | |
| // Get the role machine name from the user relationship meta target id. | |
| if ($entity_name === 'user' && $data['relationships']['roles']['data'][0]['id'] === NULL) { | |
| $role = Role::load($data['relationships']['roles']['data'][0]['meta']['drupal_internal__target_id']); | |
| $data['relationships']['roles']['data'][0]['id'] = $role->uuid(); | |
| } | |
| $url = "/jsonapi/${entity_name}/${entity_type}"; | |
| $uuid = $data['id'] ?? NULL; | |
| // Delete any pre-existing entities either by UUID or Title. | |
| $entity_storage = $this->entityTypeManager->getStorage($entity_name); | |
| if ($uuid) { | |
| $entities = $entity_storage->loadByProperties(['uuid' => $uuid]); | |
| } | |
| else { | |
| $entities = $entity_storage | |
| ->loadByProperties([ | |
| $this->bundleName[$entity_name] => $entity_type, | |
| $this->titleFields[$entity_name] => $title, | |
| ]); | |
| } | |
| foreach ($entities as $entity) { | |
| $entity->delete(); | |
| // The block data will not appear sometimes and needs a cache clear. | |
| if ($entity_name == 'block_content') { | |
| $this->needsCacheClear = TRUE; | |
| } | |
| } | |
| $this->submitJsonRequest($url, ['data' => $data]); | |
| } | |
| } | |
| $this->clearBootstrapCache(); | |
| return 'Fixtures Seeded'; | |
| } | |
| /** | |
| * Create a File Entity based on a fixture. | |
| * | |
| * @param array $data | |
| * The JsonAPI data from the fixture. | |
| * | |
| * @throws \Drupal\Core\Entity\EntityStorageException | |
| */ | |
| protected function seedFile(array $data): void { | |
| $uuid = $data['id']; | |
| // If a file with this uuid already exists then skip it. | |
| if ($uuid) { | |
| $file = $this->entityRepository->loadEntityByUuid('file', $uuid); | |
| if (!empty($file)) { | |
| return; | |
| } | |
| } | |
| $filename = $data['attributes']['filename']; | |
| if (!$filename) { | |
| throw new \Exception('A file must a have a filename it like: {"data": {"attributes": {"filename": "my-file.pdf"}}'); | |
| } | |
| $directory = 'public://test-files'; | |
| $this->fileSystem->prepareDirectory($directory, FileSystemInterface:: CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); | |
| $this->fileSystem->copy($this->fixtureDir . $filename, $directory . '/' . basename($filename), FileSystemInterface::EXISTS_REPLACE); | |
| $file = File::create([ | |
| 'filename' => basename($filename), | |
| 'uri' => $directory . '/' . basename($filename), | |
| 'status' => 1, | |
| 'uid' => 1, | |
| ]); | |
| if ($uuid) { | |
| $file->set('uuid', $uuid); | |
| } | |
| $file->save(); | |
| } | |
| /** | |
| * Create a Block Config entity based on a fixture. | |
| * | |
| * @param array $data | |
| * The JsonAPI data from the fixture. | |
| * | |
| * @throws \Drupal\Core\Entity\EntityStorageException | |
| */ | |
| protected function seedBlock(array $data): void { | |
| $uuid = $data['id']; | |
| $config = $data['attributes']; | |
| $block_machine_name = $config['drupal_internal__id']; | |
| if (!$block_machine_name) { | |
| throw new \Exception('A block must a have a machine_name like: {"data": {"attributes": {"drupal_internal__id": "my_block_name"}}'); | |
| } | |
| // If a block with this machine name already exists then delete it. | |
| $block = Block::load($block_machine_name); | |
| if ($block) { | |
| $block->delete(); | |
| } | |
| // Replace the drupal_internal__id with just "id". | |
| $config['id'] = $block_machine_name; | |
| unset($config['drupal_internal__id']); | |
| $block = Block::create($config); | |
| if ($uuid) { | |
| $block->set('uuid', $uuid); | |
| } | |
| $block->save(); | |
| } | |
| /** | |
| * Clear the Bootstrap cache if it needs it. | |
| */ | |
| protected function clearBootstrapCache(): void { | |
| if ($this->needsCacheClear) { | |
| $this->cacheBackend->deleteAll(); | |
| } | |
| } | |
| /** | |
| * Submit JsonAPI data and await a response. | |
| * | |
| * @param string $url | |
| * The JsonAPI endpoint to submit data towards. | |
| * @param array $data | |
| * The JsonAPI data array. | |
| * | |
| * @return \Symfony\Component\HttpFoundation\Response | |
| * A Response instance | |
| * | |
| * @throws \Exception | |
| */ | |
| protected function submitJsonRequest(string $url, array $data = []): Response { | |
| if (empty($data)) { | |
| $request = Request::create($url); | |
| } | |
| else { | |
| $request = Request::create($url, "POST", [], [], [], [], Json::encode($data)); | |
| } | |
| $request->headers->set('Accept', 'application/vnd.api+json'); | |
| $request->headers->set('Content-Type', 'application/vnd.api+json'); | |
| $response = $this->httpKernel->handle($request, HttpKernelInterface::MASTER_REQUEST); | |
| $status = $response->getStatusCode(); | |
| if (($status < 200) || ($status > 299)) { | |
| throw new \Exception($response->getContent()); | |
| } | |
| return $response; | |
| } | |
| /** | |
| * Create any missing relationship entities if they do not already exist. | |
| * | |
| * @param array $parent_data | |
| * The JsonAPI data coming from the primary fixture being seeded. | |
| */ | |
| protected function seedRelationships(array $parent_data): void { | |
| $relationships = (!empty($parent_data['relationships'])) ? $parent_data['relationships'] : []; | |
| // Ignore roles since these are already created through config. | |
| unset($relationships['roles']); | |
| if (empty($relationships)) { | |
| return; | |
| } | |
| // Flatten all relationships into one array. | |
| $flat_relationships = []; | |
| foreach ($relationships as $field) { | |
| $field_data_set = (!empty($field['data']['type'])) ? [$field['data']] : $field['data']; | |
| foreach ($field_data_set as $field_data) { | |
| $flat_relationships[] = $field_data; | |
| } | |
| } | |
| foreach ($flat_relationships as $data) { | |
| $uuid = $data['id']; | |
| [$entity_name, $entity_type] = explode('--', $data['type']); | |
| // Skip if the entity already exists. | |
| $entity = $this->entityRepository->loadEntityByUuid($entity_name, $uuid); | |
| if (!empty($entity)) { | |
| continue; | |
| } | |
| $file_content = $this->getRelatedFixtureData($uuid); | |
| // If this is a physical file, copy it into the files directory. | |
| if ($entity_name == 'file') { | |
| $this->seedFile($file_content['data']); | |
| continue; | |
| } | |
| // Add the entity via JsonAPI. | |
| $url = "/jsonapi/${entity_name}/${entity_type}"; | |
| $this->submitJsonRequest($url, $file_content); | |
| } | |
| } | |
| /** | |
| * Get a list of all fixtures on file keyed by their UUID. | |
| * | |
| * @return array | |
| * List of all fixtures on file keyed by their UUID. | |
| */ | |
| protected function getAllFixturesMap(): array { | |
| if (!empty($this->allFixturesMap)) { | |
| return $this->allFixturesMap; | |
| } | |
| if (!is_dir($this->fixtureDir)) { | |
| throw new \Exception('The fixture directory is not correct: ' . $this->fixtureDir); | |
| } | |
| // Create a map of all fixtures and their UUIDs. | |
| $fixtures = $this->fileSystem->scanDirectory($this->fixtureDir, '/.json$/'); | |
| foreach ($fixtures as $file) { | |
| $content = Json::decode(file_get_contents($file->uri)); | |
| $uuid = $content['data']['id']; | |
| if (empty($uuid)) { | |
| continue; | |
| } | |
| $this->allFixturesMap[$uuid] = $file->uri; | |
| } | |
| return $this->allFixturesMap; | |
| } | |
| /** | |
| * Get the data for a fixture defined from a relationship. | |
| * | |
| * @param string $uuid | |
| * The unique ID for the fixture entity. | |
| * | |
| * @return array | |
| * The data for the fixture. | |
| */ | |
| protected function getRelatedFixtureData(string $uuid): array { | |
| $fixture_map = $this->getAllFixturesMap(); | |
| $file = $fixture_map[$uuid]; | |
| if (!$file) { | |
| throw new \Exception('No related fixture file could be found for UUID: ' . $uuid); | |
| } | |
| return Json::decode(file_get_contents($file)); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "data": { | |
| "type": "taxonomy_term--tags", | |
| "id": "6baf6f82-369e-4155-bb7a-b40afeab1002", | |
| "attributes": { | |
| "name": "Term Sample 1" | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "data": { | |
| "type": "node--page", | |
| "attributes": { | |
| "title": "Test Page" | |
| }, | |
| "relationships": { | |
| "field_tags": { | |
| "data": { | |
| "type": "taxonomy_term--tags", | |
| "id": "6baf6f82-369e-4155-bb7a-b40afeab1002" | |
| } | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment