refactor: split commands into separate classes

Use mnapoli/silly-php-di which allows for using a dependency injection
container and for separating commands into separate classes and files.o

Refs: OD-20
This commit is contained in:
Oliver Davies 2023-04-21 22:05:42 +01:00
parent 0165029423
commit d701f0e2a3
5 changed files with 743 additions and 579 deletions

View file

@ -5,262 +5,39 @@ declare(strict_types=1);
require __DIR__.'/../vendor/autoload.php'; require __DIR__.'/../vendor/autoload.php';
use Illuminate\Support\{Arr, Collection}; use OliverDaviesLtd\BuildConfigs\Command\GenerateCommand;
use OliverDaviesLtd\BuildConfigs\ConfigurationData; use OliverDaviesLtd\BuildConfigs\Command\InitCommand;
use OliverDaviesLtd\BuildConfigs\DataTransferObject\TemplateFile; use Silly\Edition\PhpDi\Application;
use OliverDaviesLtd\BuildConfigs\Enum\{Language, WebServer};
use Silly\Application;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\{ConstraintViolationInterface, Validation};
use Symfony\Component\Yaml\Yaml;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
$app = new Application(); $app = new Application('build-configs');
$app->command( $app->command(
'init [-p|--project-name=] [-l|--language=] [-t|--type=]', expression: 'init [-p|--project-name=] [-l|--language=] [-t|--type=]',
function( callable: InitCommand::class,
string $projectName, )
string $language, ->descriptions(
string $type, description: 'Initialise a new build.yaml file.',
): void { argumentAndOptionDescriptions: [
$projectName = str_replace('.', '-', $projectName); '--project-name' => 'The name of the project.',
'--language' => 'The language used in the project.',
// TODO: validate the project type. '--type' => 'The project type.',
$output = <<<EOF ],
name: $projectName );
language: $language
type: $type
EOF;
file_put_contents('build.yaml', $output);
}
)->descriptions('Initialise a new build.yaml file.', [
'--project-name' => 'The name of the project.',
'--language' => 'The language used in the project.',
'--type' => 'The project type.',
]);;
$app->command( $app->command(
'generate [-c|--config-file=] [-o|--output-dir=]', expression: 'generate [-c|--config-file=] [-o|--output-dir=]',
function ( callable: GenerateCommand::class,
SymfonyStyle $io, )
string $configFile = 'build.yaml', ->descriptions(
string $outputDir = '.', description: 'Generate configuration files.',
): void { argumentAndOptionDescriptions: [
$configurationData = array_merge( '--config-file' => 'The path to the project\'s build.yaml file',
Yaml::parseFile(__DIR__ . '/../resources/build.defaults.yaml'), '--output-dir' => 'The directory to create files in',
Yaml::parseFile($configFile), ],
); )
->defaults(defaults: [
// Convert the input to a configuration data object. 'config-file' => 'build.yaml',
$normalizer = new ObjectNormalizer(null, new CamelCaseToSnakeCaseNameConverter()); 'output-dir' => '.',
$serializer = new Serializer([$normalizer], [new JsonEncoder()]);
$configurationDataObject = $serializer->deserialize(json_encode($configurationData), ConfigurationData::class, 'json');
$validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator();
$violations = $validator->validate($configurationDataObject);
if (0 < $violations->count()) {
$io->error('Configuration is invalid.');
$io->listing(
collect($violations)
->map(fn (ConstraintViolationInterface $v) => "{$v->getPropertyPath()} - {$v->getMessage()}")
->toArray()
);
return;
}
if (isset($configurationData['docker-compose'])) {
$configurationData['dockerCompose'] = $configurationData['docker-compose'];
$configurationData['docker-compose'] = null;
}
$configurationData['managedText'] = 'Do not edit this file. It is automatically generated by \'build-configs\'.';
$filesToGenerate = getFiles(configurationData: $configurationData);
$io->info("Building configuration for {$configurationData['name']}.");
$io->write('Generated files:');
$io->listing(getListOfFiles(filesToGenerate: $filesToGenerate)->toArray());
generateFiles(
configurationData: $configurationData,
filesToGenerate: $filesToGenerate,
outputDir: $outputDir,
);
}
)->descriptions('Generate project-specific configuration files.', [
'--config-file' => 'The path to the project\'s build.yaml file',
'--output-dir' => 'The directory to create files in',
]);
$app->run();
/**
* @param array<string, string> $configurationData
* @param Collection<int, TemplateFile> $filesToGenerate
*/
function generateFiles(
Collection $filesToGenerate,
string $outputDir,
array $configurationData,
): void
{
$filesystem = new Filesystem();
$twig = new Environment(new FilesystemLoader([__DIR__ . '/../templates']));
$filesToGenerate->each(function(TemplateFile $templateFile) use ($configurationData, $filesystem, $outputDir, $twig): void {
if ($templateFile->path !== null) {
if (!$filesystem->exists($templateFile->path)) {
$filesystem->mkdir("{$outputDir}/{$templateFile->path}");
}
}
$sourceFile = "{$templateFile->data}.twig";
$outputFile = collect([
$outputDir,
$templateFile->path,
$templateFile->name,
])->filter()->implode('/');
$filesystem->dumpFile($outputFile, $twig->render($sourceFile, $configurationData));
});
// If the Docker entrypoint file is generated, ensure it is executable.
if ($filesystem->exists("{$outputDir}/tools/docker/images/php/root/usr/local/bin/docker-entrypoint-php")) {
$filesystem->chmod("{$outputDir}/tools/docker/images/php/root/usr/local/bin/docker-entrypoint-php", 0755);
}
}
function getFiles(array $configurationData): Collection
{
/** @var Collection<int, TemplateFile> */
$filesToGenerate = collect([
new TemplateFile(data: 'common/.dockerignore', name: '.dockerignore'),
new TemplateFile(data: 'common/.hadolint.yaml', name: '.hadolint.yaml'),
new TemplateFile(data: 'env.example', name: '.env.example'),
]); ]);
$extraDatabases = Arr::get($configurationData, 'database.extra_databases', []); $app->run();
if (count($extraDatabases) > 0) {
$filesToGenerate[] = new TemplateFile(
data: 'extra-databases.sql',
name: 'extra-databases.sql',
path: 'tools/docker/images/database/root/docker-entrypoint-initdb.d',
);
}
if (false !== Arr::get($configurationData, "justfile", true)) {
$filesToGenerate[] = new TemplateFile(data: 'justfile', name: 'justfile');
}
if (isset($configurationData['dockerCompose']) && $configurationData['dockerCompose'] !== null) {
$filesToGenerate[] = new TemplateFile(data: 'docker-compose.yaml', name: 'docker-compose.yaml');
}
if (isPhp(Arr::get($configurationData, 'language'))) {
$filesToGenerate[] = new TemplateFile(data: 'php/Dockerfile', name: 'Dockerfile');
$filesToGenerate[] = new TemplateFile(data: 'php/phpcs.xml', name: 'phpcs.xml.dist');
$filesToGenerate[] = new TemplateFile(data: 'php/phpunit.xml', name: 'phpunit.xml.dist');
$filesToGenerate[] = new TemplateFile(
data: 'php/docker-entrypoint-php',
name: 'docker-entrypoint-php',
path: 'tools/docker/images/php/root/usr/local/bin',
);
if (Arr::has(array: $configurationData, keys: 'php.phpstan')) {
$filesToGenerate[] = new TemplateFile(data: 'php/phpstan.neon', name: 'phpstan.neon.dist');
}
}
if (isNode(Arr::get($configurationData, 'language'))) {
$filesToGenerate[] = new TemplateFile(data: 'node/.yarnrc', name: '.yarnrc');
$filesToGenerate[] = new TemplateFile(data: 'node/Dockerfile', name: 'Dockerfile');
}
if (isCaddy(Arr::get($configurationData, 'web.type'))) {
$filesToGenerate[] = new TemplateFile(
data: 'web/caddy/Caddyfile',
name: 'Caddyfile',
path: 'tools/docker/images/web/root/etc/caddy',
);
}
if (isNginx(Arr::get($configurationData, 'web.type'))) {
$filesToGenerate[] = new TemplateFile(
data: 'web/nginx/default.conf',
name: 'default.conf',
path: 'tools/docker/images/web/root/etc/nginx/conf.d',
);
}
if ('drupal-project' === Arr::get($configurationData, 'type')) {
// Add a Drupal version of phpunit.xml.dist.
$filesToGenerate[] = new TemplateFile(data: 'drupal-project/phpunit.xml.dist', name: 'phpunit.xml.dist');
}
if (Arr::get($configurationData, 'experimental.createGitHubActionsConfiguration', false) === true) {
$filesToGenerate[] = new TemplateFile(
data: 'ci/github-actions/ci.yml',
name: 'ci.yml',
path: '.github/workflows',
);
}
return $filesToGenerate;
}
function getListOfFiles(Collection $filesToGenerate): Collection
{
return $filesToGenerate
->map(fn (TemplateFile $templateFile): string =>
collect([$templateFile->path, $templateFile->name])->filter()->implode('/'))
->unique()
->sort();
}
function isCaddy(?string $webServer): bool
{
if (is_null($webServer)) {
return false;
}
return strtoupper($webServer) === WebServer::CADDY->name;
}
function isNginx(?string $webServer): bool
{
if (is_null($webServer)) {
return false;
}
return strtoupper($webServer) === WebServer::NGINX->name;
}
function isNode(?string $language): bool
{
if (is_null($language)) {
return false;
}
return strtoupper($language) === Language::NODE->name;
}
function isPhp(?string $language): bool
{
if (is_null($language)) {
return false;
}
return strtoupper($language) === Language::PHP->name;
}

View file

@ -1,7 +1,8 @@
{ {
"require": { "require": {
"illuminate/support": "^9.50", "illuminate/support": "^9.50",
"mnapoli/silly": "^1.8", "mnapoli/silly-php-di": "^1.2",
"php-di/php-di": "^6.3",
"symfony/console": "^6.2", "symfony/console": "^6.2",
"symfony/filesystem": "^6.2", "symfony/filesystem": "^6.2",
"symfony/property-access": "^6.2", "symfony/property-access": "^6.2",
@ -24,7 +25,8 @@
"config": { "config": {
"sort-packages": true, "sort-packages": true,
"allow-plugins": { "allow-plugins": {
"pestphp/pest-plugin": true "pestphp/pest-plugin": true,
"phpstan/extension-installer": true
} }
} }
} }

774
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace OliverDaviesLtd\BuildConfigs\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use OliverDaviesLtd\BuildConfigs\ConfigurationData;
use OliverDaviesLtd\BuildConfigs\DataTransferObject\TemplateFile;
use OliverDaviesLtd\BuildConfigs\Enum\Language;
use OliverDaviesLtd\BuildConfigs\Enum\WebServer;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Yaml\Yaml;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
final class GenerateCommand
{
public function __invoke(
SymfonyStyle $io,
string $configFile,
string $outputDir,
): void {
$configurationData = array_merge(
Yaml::parseFile(__DIR__ . '/../../resources/build.defaults.yaml'),
Yaml::parseFile($configFile),
);
$io->info("Building configuration for {$configurationData['name']}.");
// Convert the input to a configuration data object.
$normalizer = new ObjectNormalizer(null, new CamelCaseToSnakeCaseNameConverter());
$serializer = new Serializer([$normalizer], [new JsonEncoder()]);
$configurationDataObject = $serializer->deserialize(json_encode($configurationData), ConfigurationData::class, 'json');
$validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator();
$violations = $validator->validate($configurationDataObject);
if (0 < $violations->count()) {
$io->error('Configuration is invalid.');
$io->listing(
collect($violations)
->map(fn (ConstraintViolationInterface $v) => "{$v->getPropertyPath()} - {$v->getMessage()}")
->toArray()
);
return;
}
if (isset($configurationData['docker-compose'])) {
$configurationData['dockerCompose'] = $configurationData['docker-compose'];
$configurationData['docker-compose'] = null;
}
$configurationData['managedText'] = 'Do not edit this file. It is automatically generated by \'build-configs\'.';
$filesToGenerate = $this->getFiles(configurationData: $configurationData);
$io->write('Generated files:');
$io->listing($this->getListOfFiles(filesToGenerate: $filesToGenerate)->toArray());
$this->generateFiles(
configurationData: $configurationData,
filesToGenerate: $filesToGenerate,
outputDir: $outputDir,
);
}
/**
* @param array<string, string> $configurationData
* @param Collection<int, TemplateFile> $filesToGenerate
*/
private function generateFiles(
Collection $filesToGenerate,
string $outputDir,
array $configurationData,
): void
{
$filesystem = new Filesystem();
$twig = new Environment(new FilesystemLoader([__DIR__ . '/../../templates']));
$filesToGenerate->each(function(TemplateFile $templateFile) use ($configurationData, $filesystem, $outputDir, $twig): void {
if ($templateFile->path !== null) {
if (!$filesystem->exists($templateFile->path)) {
$filesystem->mkdir("{$outputDir}/{$templateFile->path}");
}
}
$sourceFile = "{$templateFile->data}.twig";
$outputFile = collect([
$outputDir,
$templateFile->path,
$templateFile->name,
])->filter()->implode('/');
$filesystem->dumpFile($outputFile, $twig->render($sourceFile, $configurationData));
});
// If the Docker entrypoint file is generated, ensure it is executable.
if ($filesystem->exists("{$outputDir}/tools/docker/images/php/root/usr/local/bin/docker-entrypoint-php")) {
$filesystem->chmod("{$outputDir}/tools/docker/images/php/root/usr/local/bin/docker-entrypoint-php", 0755);
}
}
private function getFiles(array $configurationData): Collection
{
/** @var Collection<int, TemplateFile> */
$filesToGenerate = collect([
new TemplateFile(data: 'common/.dockerignore', name: '.dockerignore'),
new TemplateFile(data: 'common/.hadolint.yaml', name: '.hadolint.yaml'),
new TemplateFile(data: 'env.example', name: '.env.example'),
]);
$extraDatabases = Arr::get($configurationData, 'database.extra_databases', []);
if (count($extraDatabases) > 0) {
$filesToGenerate[] = new TemplateFile(
data: 'extra-databases.sql',
name: 'extra-databases.sql',
path: 'tools/docker/images/database/root/docker-entrypoint-initdb.d',
);
}
if (false !== Arr::get($configurationData, "justfile", true)) {
$filesToGenerate[] = new TemplateFile(data: 'justfile', name: 'justfile');
}
if (isset($configurationData['dockerCompose']) && $configurationData['dockerCompose'] !== null) {
$filesToGenerate[] = new TemplateFile(data: 'docker-compose.yaml', name: 'docker-compose.yaml');
}
if (static::isPhp(Arr::get($configurationData, 'language'))) {
$filesToGenerate[] = new TemplateFile(data: 'php/Dockerfile', name: 'Dockerfile');
$filesToGenerate[] = new TemplateFile(data: 'php/phpcs.xml', name: 'phpcs.xml.dist');
$filesToGenerate[] = new TemplateFile(data: 'php/phpunit.xml', name: 'phpunit.xml.dist');
$filesToGenerate[] = new TemplateFile(
data: 'php/docker-entrypoint-php',
name: 'docker-entrypoint-php',
path: 'tools/docker/images/php/root/usr/local/bin',
);
if (Arr::has(array: $configurationData, keys: 'php.phpstan')) {
$filesToGenerate[] = new TemplateFile(data: 'php/phpstan.neon', name: 'phpstan.neon.dist');
}
}
if (static::isNode(Arr::get($configurationData, 'language'))) {
$filesToGenerate[] = new TemplateFile(data: 'node/.yarnrc', name: '.yarnrc');
$filesToGenerate[] = new TemplateFile(data: 'node/Dockerfile', name: 'Dockerfile');
}
if (static::isCaddy(Arr::get($configurationData, 'web.type'))) {
$filesToGenerate[] = new TemplateFile(
data: 'web/caddy/Caddyfile',
name: 'Caddyfile',
path: 'tools/docker/images/web/root/etc/caddy',
);
}
if (static::isNginx(Arr::get($configurationData, 'web.type'))) {
$filesToGenerate[] = new TemplateFile(
data: 'web/nginx/default.conf',
name: 'default.conf',
path: 'tools/docker/images/web/root/etc/nginx/conf.d',
);
}
if ('drupal-project' === Arr::get($configurationData, 'type')) {
// Add a Drupal version of phpunit.xml.dist.
$filesToGenerate[] = new TemplateFile(data: 'drupal-project/phpunit.xml.dist', name: 'phpunit.xml.dist');
}
if (Arr::get($configurationData, 'experimental.createGitHubActionsConfiguration', false) === true) {
$filesToGenerate[] = new TemplateFile(
data: 'ci/github-actions/ci.yml',
name: 'ci.yml',
path: '.github/workflows',
);
}
return $filesToGenerate;
}
private function getListOfFiles(Collection $filesToGenerate): Collection
{
return $filesToGenerate
->map(fn (TemplateFile $templateFile): string =>
collect([$templateFile->path, $templateFile->name])->filter()->implode('/'))
->unique()
->sort();
}
private static function isCaddy(?string $webServer): bool
{
if (is_null($webServer)) {
return false;
}
return strtoupper($webServer) === WebServer::CADDY->name;
}
private static function isNginx(?string $webServer): bool
{
if (is_null($webServer)) {
return false;
}
return strtoupper($webServer) === WebServer::NGINX->name;
}
private static function isNode(?string $language): bool
{
if (is_null($language)) {
return false;
}
return strtoupper($language) === Language::NODE->name;
}
private static function isPhp(?string $language): bool
{
if (is_null($language)) {
return false;
}
return strtoupper($language) === Language::PHP->name;
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace OliverDaviesLtd\BuildConfigs\Command;
final class InitCommand
{
public function __invoke(
string $projectName,
string $language,
string $type,
): void {
$projectName = str_replace('.', '-', $projectName);
// TODO: validate the project type.
$output = <<<EOF
name: $projectName
language: $language
type: $type
EOF;
file_put_contents('build.yaml', $output);
}
}