- Get link
- X
- Other Apps
- Get link
- X
- Other Apps
I have made a movie about apie/console. With apie/console you can just do the same actions you could do with apie/cms or apie/rest-api, but then in a (Linux) terminal.
Prerequisites
I had this idea, because in many applications I need to make some create:admin-user action to fix the bootstrapping issue that I need an initial user when you deploy an application for the first time. Writing this console command can be time consuming especially for a one time use. A hardcoded username/password is less secure but takes less time. Wouldn't it be great if you can map your actions to a auto-generated console command? And the idea of apie/console was born.
So I had a few requirements what the package should do:
- the console commands should not be usable in a single framework and should work just fine in Laravel or Symfony.
- I should be able to see all possible actions from listing all console commands, so not one generic console command for everything
- Asking the --help option should give me all input parameters of a console command.
- There should be an --interactive option to ask the input dynamically.
The first version
The first version I built was a very basic version and only working in Symfony, because Laravel documentation is very limited about adding dynamic console commands. Adding dynamic console commands in Symfony is just overwriting the registerCommands() method in the bundle class:
class ApieBundle extends Bundle
{
public function registerCommands(Application $application): void
{
if ($this->container->has('apie.console.factory')) {
/** @var ConsoleCommandFactory $factory */
$factory = $this->container->get('apie.console.factory');
$application->addCommands(iterator_to_array($factory->create($application)));
}
}
}
Tthe first version had no interactivity and would just map simple properties, but I did manage to make the --help option work and I did see all the console command options if I run bin/console.
But it still lacked a few requirements to call it done:
- There was no Laravel console command showing up, only the services were registered.
- The input fields are limited to look one level deep. If I use sub objects you would have to provide them as JSON or else there is no way to enter them.
- Interactivity was not possible at all.
- It could only do the create resource action
- The console command names generated were a mixture of kebab case with camel case.
Laravel support
Since Laravel is just extending the Symfony command class for his own syntactic sugar I thought the console commands would be easy to add to a Laravel application as well as it could use the same ConsoleCommandFactory class and Laravel is always using PHP files for configuration. However it worked a little bit differently. If you follow the documentations you need to register a command from a service provider you use it like this:
class ApieServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->commands([
MyCommand::class,
AnotherCommand::class,
]);
}
}
In this commands methods we provide service id's in the service container (that happen to be equal to the used class name), but we just happened to have console command instances instead if we run ConsoleCommandFactory::create() with the same class instance. To avoid performance issues (since this is executed on every HTTP request) we only register them if the cli is active:
class ApieServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
/** @var CommonConsoleCommandFactory $factory */
$factory = $this->app->get('apie.console.factory');
foreach ($factory->create($this->app->get(Application::class)) as $command) {
$serviceId = 'apie.console.registered.' . $command->getName();
$this->app->instance($serviceId, $command);
$this->commands($serviceId);
}
}
}
}
As you can see it works with mapping the console command instance to a unique service id and register this service id with $this->commands().
To make sure our console command works in Laravel and Symfony I make an integration test using the methods I wrote down in my previous article. I had to modify the TestApplicationInterface with methods to get me the Application instance that is needed for the console helper classes CommandTester and ApplicationTester. Both tests work in Laravel and Symfony applications and I'm surprised how little Laravel adds to the console commands at all.
Adding more console commands
I only added a command to make new instances of a domain object. But we want the rest of the operations as well (remove, modify, run methods). I made the second one (removal) and noticed there was a lot of similarity, so I made the generated console commands follow the template method pattern. Using this I could make the other commands very easily. It also allows me to make the names of the console commands much more consistent by making method names and the class name written in kebab case.
For example the modify command just looks like this.
final class ApieModifyResourceCommand extends ApieMetadataDirectedConsoleCommand
{
protected function getCommandName(): string
{
return KebabCaseSlug::fromClass($this->reflectionClass) . ':modify';
}
protected function getCommandHelp(): string
{
return 'This command allows you to modify a ' . $this->reflectionClass->getShortName() . ' instance';
}
protected function getMetadata(): MetadataInterface
{
return MetadataFactory::getModificationMetadata(
$this->reflectionClass,
$this->apieContext
);
}
protected function getSucessMessage(ActionResponse $actionResponse): string
{
return sprintf(
"Resource %s with id %s was successfully modified.",
(new ReflectionClass($actionResponse->resource))->getShortName(),
$actionResponse->resource->getId(),
);
}
protected function requiresId(): bool
{
return true;
}
}
First of all the KebabCaseSlug in getCommandName() is a default value object that I already made and I gave it a static method call to create an instance from a ReflectionClass. I use it for all console commands to rename a class name like 'App\Resources\InvoiceDescription' into 'invoice-description' so all console command names are consistently in kebab case.
- getCommandHelp() and getSuccessMessage() are just for writing console messages back.
- The method requiresId() tells the base class if there should be an id option or not.
- getMetadata() is how all the console commands get the metadata for the available input arguments and the interactivity fields (these metadata classes are also being used in apie/cms and apie/rest-api to get consistent behaviour between all libraries).
Making an interactive console command
I already made one interactive console command when writing the apie project starter (see the article about composer create-project). However this command was just written very imperatively and hardcoded for the skeleton project. But I need a very dynamic interactive console application that is using Reflection to get the right calls.
Before I started I looked up I could find similar projects and bumped into a blog article by Matthias Noback about symfony console forms. This package converts symfony forms into console commands. It does it's recursive nature by sending the HelperSet class to the helper, which contains the helper itself.
I used this same construction in the symfony console command. To my surprise the use of helper sets work even in Laravel so I did not need to workaround anything. It's surprising since the helper sets are not documented at all in the Laravel documentation.
I use a chain of responsibility to make it possible to hook into the system (see my article about making extendible services).
So an example class to modify the interactivity:
final class EnumInteractor implements InputInteractorInterface
{
public function supports(MetadataInterface $metadata): bool
{
return $metadata instanceof EnumMetadata;
}
public function interactWith(
MetadataInterface $metadata,
HelperSet $helperSet,
InputInterface $input,
OutputInterface $output,
ApieContext $context
): mixed {
$helper = $helperSet->get('question');
assert($helper instanceof QuestionHelper);
$class = $metadata->toClass();
assert($class instanceof ReflectionEnum);
$question = new ChoiceQuestion('Pick a value: ', EnumUtils::getValues($class));
$result = $helper->ask($input, $output, $question);
$output->writeln('');
return $result;
}
}
The above example is the current implementation for filling in a enum and looks like this:
At the writing of this article I still need to make an ItemListInteractor and an ItemHashmapInteractor class so you can provide lists of items from the console.
It's also possible to make custom interactor classes specific for the application as well. I can even enter 2FA codes by writing a special interactor class. Not sure many people will add 2FA from the command line, but it's cool that you are not restricted by it.
Conclusion
The conclusion is bright. Apie/console is not done yet as I still need to be able to make lists or hash maps interactively from a console, but I think this package can become really handy.
My experience is that since a technical person is always running these commands that custom commands are always limited or written a lower quality than normal (or not written at all as we just place data in the database instead). Hopefully apie/console changes this.
I also would like to thank Matthias Noback for his symfony form console package as it helped me a lot in writing the interactive console commands.
Comments
Post a Comment