Skip to content

Instantly share code, notes, and snippets.

@mrkmiller
Last active May 12, 2023 18:59
Show Gist options
  • Select an option

  • Save mrkmiller/a0357e4d759932f67a16f9cc4d0e8dff to your computer and use it in GitHub Desktop.

Select an option

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.
/**
* 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
})
})
<?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));
}
}
{
"data": {
"type": "taxonomy_term--tags",
"id": "6baf6f82-369e-4155-bb7a-b40afeab1002",
"attributes": {
"name": "Term Sample 1"
}
}
}
{
"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