Skip to content

Commit

Permalink
Fixes #7237 allow modules to add CLI commands. (#7238)
Browse files Browse the repository at this point in the history
* Fixes #7237 allow modules to add CLI commands.

We change up the OpenEMR command runner to be deprecated and add a
warning notice for people to use the OpenEMR symfony command runner in
bin/console.

Changed up the symfony command runner to use a class that also fires an
event to grab module commands if there exist any in the system.
Deprecated the create client assertions command since that is used in
our FHIR api documentation.  I completely migrated the api documentation
creation command into the symfony command runner.

* Fix copyright notice for paid work.

* Fix command description
  • Loading branch information
adunsulag committed Mar 14, 2024
1 parent 254f078 commit 075f300
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 47 deletions.
18 changes: 2 additions & 16 deletions bin/console
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,5 @@ $ignoreAuth = true;
$sessionAllowWrite = true;
require_once __DIR__ . '/../interface/globals.php';

use OpenEMR\Common\Command\AclModify;
use OpenEMR\Common\Command\CcdaNewpatientImport;
use OpenEMR\Common\Command\CcdaNewpatient;
use OpenEMR\Common\Command\CcdaImport;
use OpenEMR\Common\Command\Register;
use OpenEMR\Common\Command\ZfcModule;
use Symfony\Component\Console\Application;

$app = new Application();
$app->add(new AclModify());
$app->add(new CcdaNewpatientImport());
$app->add(new CcdaNewpatient());
$app->add(new CcdaImport());
$app->add(new Register());
$app->add(new ZfcModule());
$app->run();
$commandRunner = new \OpenEMR\Common\Command\SymfonyCommandRunner();
$commandRunner->run();
57 changes: 26 additions & 31 deletions src/Common/Command/CreateAPIDocumentationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,50 +11,45 @@

namespace OpenEMR\Common\Command;

use OpenEMR\Common\Command\Runner\CommandContext;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class CreateAPIDocumentationCommand implements IOpenEMRCommand
class CreateAPIDocumentationCommand extends Command
{
/**
* Prints the instructions on how to use this command
* @param CommandContext $context All the context about the command environment.
*/
public function printUsage(CommandContext $context)
protected function configure()
{
echo "Command Usage: " . $context->getScriptName() . " -c CreateAPIDocumentation" . "\n";
$this
->setName('openemr:create-api-documentation')
->setDescription("Generates an OpenAPI swagger file that documents the OpenEMR API")
->addUsage('--site=default')
->setDefinition(
new InputDefinition([
new InputOption('site', null, InputOption::VALUE_REQUIRED, 'Name of site', 'default'),
])
);
}

/**
* Returns a description of the command
* @return string
*/
public function getDescription(CommandContext $context): string
{
return "Generates an OpenAPI swagger file that documents the OpenEMR API";
}

/**
* Execute the command and spit any output to STDOUT and errors to STDERR
* @param CommandContext $context All the context information needed for the CLI Command to execute
*/
public function execute(CommandContext $context)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$routesLocation = $context->getRootPath() . "_rest_routes.inc.php";
$fileDestinationFolder = $context->getRootPath() . "swagger" . DIRECTORY_SEPARATOR;
$routesLocation = $GLOBALS['fileroot'] . DIRECTORY_SEPARATOR . "_rest_routes.inc.php";
$fileDestinationFolder = $GLOBALS['fileroot'] . DIRECTORY_SEPARATOR . "swagger" . DIRECTORY_SEPARATOR;
$fileDestinationYaml = $fileDestinationFolder . "openemr-api.yaml";
$site = $input->getOption('site') ?? 'default';

$openapi = \OpenApi\Generator::scan([$routesLocation]);

$resultYaml = file_put_contents($fileDestinationYaml, $openapi->toYaml());

if ($resultYaml === false) {
echo "No write access to " . $fileDestinationYaml . "\n";
$this->printUsage($context);
return;
$output->writeln("No write access to " . $fileDestinationYaml);
return Command::FAILURE;
} else {
echo "API file generated at " . $fileDestinationYaml . "\n";
echo "Your API documentation can now be viewed by going to <SITE_URL>/swagger/\n";
echo "For example on the easy docker installation this would be https://localhost:9300/swagger/\n";
$output->writeln("API file generated at " . $fileDestinationYaml);
$output->writeln("Your API documentation can now be viewed by going to <webroot>/swagger/");
$output->writeln("For example on the easy docker installation this would be https://localhost:9300/swagger/");
return Command::SUCCESS;
}
}
}
115 changes: 115 additions & 0 deletions src/Common/Command/CreateClientCredentialsAssertionSymfonyCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

/**
* CreateClientCredentialsAssertion Is a helper utility to create a Client Credentials Grant assertion statement as
* well as print out the Public JSON Web Key Set that can be used for a test System App.
*
* @package openemr
* @link http://www.open-emr.org
* @author Stephen Nielson <stephen@nielson.org>
* @copyright Copyright (c) 2021 Stephen Nielson <stephen@nielson.org>
* @copyright Copyright (c) 2024 Care Management Solutions, Inc. <stephen.waite@cmsvt.com>
* @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
*/

namespace OpenEMR\Common\Command;

use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha384;
use OpenEMR\Common\Auth\OpenIDConnect\Grant\CustomClientCredentialsGrant;
use OpenEMR\Common\Command\Runner\CommandContext;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class CreateClientCredentialsAssertionSymfonyCommand extends Command
{
protected function configure()
{
$this
->setName('openemr:create-client-credentials-assertion')
->setDescription("Utility class to help test and use the client credentials grant assertion")
->addUsage('--site=default')
->setDefinition(
new InputDefinition([
new InputOption('issuer', 'i', InputOption::VALUE_REQUIRED, 'JSON Web Token (JWT) Issuer. This should be the The Client ID received from the OpenEMR registration. Used as the issuer and subject for the JWT'),
new InputOption('oauth-token-url', 'a', InputOption::VALUE_REQUIRED, 'OpenEMR OAuth2 Token URL is the audience of the JWT', 'https://localhost:9300/default/oauth2/token'),
new InputOption('print-jwks', 'k', InputOption::VALUE_NONE, 'Prints out the JSON Web Key Set public key that can be registered with OpenEMR for the client application'),
])
);
}

/**
* Runs the command
* @param CommandContext $context
* @throws \Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$rootPath = $GLOBALS['fileroot'];

$keyLocation = $rootPath . DIRECTORY_SEPARATOR . "tests" . DIRECTORY_SEPARATOR . "Tests" . DIRECTORY_SEPARATOR
. "data" . DIRECTORY_SEPARATOR . "Unit" . DIRECTORY_SEPARATOR . "Common" . DIRECTORY_SEPARATOR . "Auth"
. DIRECTORY_SEPARATOR . "Grant" . DIRECTORY_SEPARATOR;

if ($input->getOption('print-jwks')) {
$jwks = file_get_contents($keyLocation . "jwk-public-valid.json");
$output->writeln("JSON Web Key Set (Public Key)");
$output->writeln("WARNING - THIS IS FOR TESTING PURPOSES ONLY!");
$output->writeln("DO NOT USE THIS IN PRODUCTION AS THE PRIVATE KEYS FOR THIS JWKS IS COMMITED TO THE SOURCE CODE\n");
$output->writeln($jwks . "\n");
return Command::SUCCESS;
}
$clientId = $input->getOption('issuer');
$oauthTokenUrl = $input->getOption('oauth-token-url');
if (empty($clientId) || empty($oauthTokenUrl)) {
$output->writeln("Missing required arguments.");
$output->writeln($this->getSynopsis());
return Command::FAILURE;
}

$configuration = Configuration::forAsymmetricSigner(
// You may use RSA or ECDSA and all their variations (256, 384, and 512)
new Sha384(),
InMemory::file($keyLocation . "openemr-rsa384-private.key"),
InMemory::file($keyLocation . "openemr-rsa384-public.pem")
// You may also override the JOSE encoder/decoder if needed by providing extra arguments here
);

$jti = Uuid::uuid4();

$now = new \DateTimeImmutable();
$token = $configuration->builder()
// Configures the issuer (iss claim)
->issuedBy($clientId)
// Configures the audience (aud claim)
->permittedFor($oauthTokenUrl)
// Configures the id (jti claim)
->identifiedBy($jti)
// Configures the time that the token was issue (iat claim)
->issuedAt($now)
// Configures the time that the token can be used (nbf claim)
->canOnlyBeUsedAfter($now)
// Configures the expiration time of the token (exp claim)
->expiresAt($now->modify('+60 seconds'))
->relatedTo($clientId)
->getToken($configuration->signer(), $configuration->signingKey());
$assertion = $token->toString(); // The string representation of the object is a JWT string
$output->writeln("Generated Client Credentials Assertion");
$output->writeln($assertion);

$output->writeln("\n\nSample CURL request using assertion: ");
$assertionType = CustomClientCredentialsGrant::OAUTH_JWT_CLIENT_ASSERTION_TYPE;
$scope = 'system/*.\$export system/*.\$bulkdata-status system/Group.\$export system/Patient.\$export '
. 'system/Encounter.read system/Binary.read';
$output->writeln("--> curl -k -X POST --data-urlencode \"client_assertion_type=$assertionType\" \\\n"
. " --data-urlencode \"client_assertion=$assertion\" \\\n"
. " --data-urlencode \"grant_type=client_credentials\" \\\n"
. " --data-urlencode \"scope=$scope\" $oauthTokenUrl");
return Command::SUCCESS;
}
}
2 changes: 2 additions & 0 deletions src/Common/Command/Runner/CommandRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public function __construct($rootPath, $scriptName)

public function run()
{
echo "This Command Runner is deprecated and will be removed at a future date. Use php bin/console as a replacement runner\n";

$shortOpts = "c:hl";
$options = getopt($shortOpts);

Expand Down
89 changes: 89 additions & 0 deletions src/Common/Command/SymfonyCommandRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

/**
* Wraps around the symfony console command runner and allows module writers to add commands that are
* in the system.
*
* @package OpenEMR
* @link http://www.open-emr.org
*
* @author Stephen Nielson <snielson@discoverandchange.com>
* @copyright Copyright (c) 2024 Care Management Solutions, Inc. <stephen.waite@cmsvt.com>
* @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
*/

namespace OpenEMR\Common\Command;

use OpenEMR\Common\Command\Runner\AclModify;
use OpenEMR\Common\Command\Runner\CcdaImport;
use OpenEMR\Common\Command\Runner\CcdaNewpatient;
use OpenEMR\Common\Command\Runner\CcdaNewpatientImport;
use OpenEMR\Common\Command\Runner\IOpenEMRCommand;
use OpenEMR\Common\Command\Runner\Register;
use OpenEMR\Common\Command\Runner\ZfcModule;
use OpenEMR\Events\Command\CommandRunnerFilterEvent;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Finder\Finder;

class SymfonyCommandRunner
{
private $eventDispatcher;

public function __construct()
{
}
public function setEventDispatcher(EventDispatcher $eventDispatcher)
{
$this->eventDispatcher = $eventDispatcher;
}
public function getEventDispatcher(): EventDispatcher
{
if (!isset($this->eventDispatcher)) {
$this->eventDispatcher = $GLOBALS['kernel']->getEventDispatcher();
}
return $this->eventDispatcher;
}

public function run()
{
$commands = $this->findCommands();
$app = new Application();
foreach ($commands as $command) {
$app->add($command);
}
$app->run();
}

/**
* @return Command[]
*/
private function findCommands(): array
{
try {
$finder = new Finder();
$files = $finder->files()->in(__DIR__)->name("*.php");
$filterCommand = new CommandRunnerFilterEvent();
foreach ($files as $file) {
$fileName = $file->getFilenameWithoutExtension();
$fqn = __NAMESPACE__ . "\\" . $fileName;
if (empty($fileName) || $fqn == self::class) {
continue; // skip over ourselves
}
if (class_exists($fqn)) {
$command = new $fqn();
if ($command instanceof Command) {
$filterCommand->setCommand($command::class, $command);
}
}
}
// dispatch an event so modules can also add commands
$this->getEventDispatcher()->dispatch($filterCommand, CommandRunnerFilterEvent::EVENT_NAME);
return $filterCommand->getCommands();
} catch (\Exception $ex) {
echo "Error in attempting to find commands " . $ex->getMessage() . "\n";
die();
}
}
}
58 changes: 58 additions & 0 deletions src/Events/Command/CommandRunnerFilterEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/**
* Used for adding / modifying / removing commands that are used in the core OpenEMR command runner
* IE php bin/console. @see https://symfony.com/doc/current/console.html for documentation on how
* the command runner is run. As a convention module writers should prefix their commands with a namespace name
* and NOT use openemr:<command-name> to clearly differentiate a module's commands from the core command API.
*
* @package OpenEMR
* @link http://www.open-emr.org
*
* @author Stephen Nielson <snielson@discoverandchange.com>
* @copyright Copyright (c) 2024 Care Management Solutions, Inc. <stephen.waite@cmsvt.com>
* @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
*/

namespace OpenEMR\Events\Command;

use Symfony\Component\Console\Command\Command;

class CommandRunnerFilterEvent
{
const EVENT_NAME = "openemr.command-runner.filter";
private $commands = [];

public function __construct()
{
$this->commands = [];
}

public function getCommands(): array
{
return $this->commands; // creates a copy of the arrays
}

public function setCommand($fqdn, Command $command)
{
$this->commands[$fqdn] = $command;
}
public function hasCommand($fqdn): bool
{
return isset($this->commands[$fqdn]);
}

public function removeCommand($command)
{
$commandFQDN = $command;
if ($command instanceof Command) {
$commandFQDN = $command::class;
}

if ($this->hasCommand($commandFQDN)) {
unset($this->commands[$commandFQDN]);
} else {
throw new \InvalidArgumentException("Passed in argument is not in list of commands");
}
}
}

0 comments on commit 075f300

Please sign in to comment.