[Runtime] a new component to decouple applications from global state

This commit is contained in:
Nicolas Grekas 2020-05-01 12:47:45 +02:00
parent fc016ddd92
commit 61b32ab2a3
44 changed files with 1475 additions and 1 deletions

1
.gitattributes vendored
View File

@ -3,3 +3,4 @@
/src/Symfony/Component/Mailer/Bridge export-ignore
/src/Symfony/Component/Messenger/Bridge export-ignore
/src/Symfony/Component/Notifier/Bridge export-ignore
/src/Symfony/Component/Runtime export-ignore

View File

@ -30,6 +30,7 @@ foreach ($loader->getClassMap() as $class => $file) {
case false !== strpos($file, '/src/Symfony/Component/ErrorHandler/Tests/Fixtures/'):
case false !== strpos($file, '/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Dummy.php'):
case false !== strpos($file, '/src/Symfony/Component/PropertyInfo/Tests/Fixtures/ParentDummy.php'):
case false !== strpos($file, '/src/Symfony/Component/Runtime/Internal/ComposerPlugin.php'):
case false !== strpos($file, '/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ObjectOuter.php'):
case false !== strpos($file, '/src/Symfony/Component/VarDumper/Tests/Fixtures/NotLoadableClass.php'):
continue 2;

View File

@ -52,7 +52,8 @@
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php73": "^1.11",
"symfony/polyfill-php80": "^1.15",
"symfony/polyfill-uuid": "^1.15"
"symfony/polyfill-uuid": "^1.15",
"symfony/runtime": "self.version"
},
"replace": {
"symfony/asset": "self.version",
@ -193,6 +194,10 @@
"symfony/contracts": "2.4.x-dev"
}
}
},
{
"type": "path",
"url": "src/Symfony/Component/Runtime"
}
],
"minimum-stability": "dev"

View File

@ -38,6 +38,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Runtime\SymfonyRuntime;
use Symfony\Component\String\LazyString;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\String\Slugger\SluggerInterface;
@ -78,6 +79,7 @@ return static function (ContainerConfigurator $container) {
service('argument_resolver'),
])
->tag('container.hot_path')
->tag('container.preload', ['class' => SymfonyRuntime::class])
->alias(HttpKernelInterface::class, 'http_kernel')
->set('request_stack', RequestStack::class)

View File

@ -0,0 +1,4 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore

View File

@ -0,0 +1,3 @@
vendor/
composer.lock
phpunit.xml

View File

@ -0,0 +1,7 @@
CHANGELOG
=========
5.3.0
-----
* Add the component

View File

@ -0,0 +1,170 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime;
use Symfony\Component\Runtime\Internal\BasicErrorHandler;
use Symfony\Component\Runtime\Resolver\ClosureResolver;
use Symfony\Component\Runtime\Resolver\DebugClosureResolver;
use Symfony\Component\Runtime\Runner\ClosureRunner;
// Help opcache.preload discover always-needed symbols
class_exists(ClosureResolver::class);
/**
* A runtime to do bare-metal PHP without using superglobals.
*
* One option named "debug" is supported; it toggles displaying errors
* and defaults to the "APP_ENV" environment variable.
*
* The app-callable can declare arguments among either:
* - "array $context" to get a local array similar to $_SERVER;
* - "array $argv" to get the command line arguments when running on the CLI;
* - "array $request" to get a local array with keys "query", "body", "files" and
* "session", which map to $_GET, $_POST, $FILES and &$_SESSION respectively.
*
* It should return a Closure():int|string|null or an instance of RunnerInterface.
*
* In debug mode, the runtime registers a strict error handler
* that throws exceptions when a PHP warning/notice is raised.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
class GenericRuntime implements RuntimeInterface
{
private $debug;
/**
* @param array {
* debug?: ?bool,
* } $options
*/
public function __construct(array $options = [])
{
$this->debug = $options['debug'] ?? $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? true;
if (!\is_bool($this->debug)) {
$this->debug = filter_var($this->debug, \FILTER_VALIDATE_BOOLEAN);
}
if ($this->debug) {
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '1';
$errorHandler = new BasicErrorHandler($this->debug);
set_error_handler($errorHandler);
} else {
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0';
}
}
/**
* {@inheritdoc}
*/
public function getResolver(callable $callable): ResolverInterface
{
if (!$callable instanceof \Closure) {
$callable = \Closure::fromCallable($callable);
}
$function = new \ReflectionFunction($callable);
$parameters = $function->getParameters();
$arguments = function () use ($parameters) {
$arguments = [];
try {
foreach ($parameters as $parameter) {
$type = $parameter->getType();
$arguments[] = $this->getArgument($parameter, $type instanceof \ReflectionNamedType ? $type->getName() : null);
}
} catch (\InvalidArgumentException $e) {
if (!$parameter->isOptional()) {
throw $e;
}
}
return $arguments;
};
if ($this->debug) {
return new DebugClosureResolver($callable, $arguments);
}
return new ClosureResolver($callable, $arguments);
}
/**
* {@inheritdoc}
*/
public function getRunner(?object $application): RunnerInterface
{
if (null === $application) {
$application = static function () { return 0; };
}
if ($application instanceof RunnerInterface) {
return $application;
}
if (!\is_callable($application)) {
throw new \LogicException(sprintf('"%s" doesn\'t know how to handle apps of type "%s".', get_debug_type($this), get_debug_type($application)));
}
if (!$application instanceof \Closure) {
$application = \Closure::fromCallable($application);
}
if ($this->debug && ($r = new \ReflectionFunction($application)) && $r->getNumberOfRequiredParameters()) {
throw new \ArgumentCountError(sprintf('Zero argument should be required by the runner callable, but at least one is in "%s" on line "%d.', $r->getFileName(), $r->getStartLine()));
}
return new ClosureRunner($application);
}
/**
* @return mixed
*/
protected function getArgument(\ReflectionParameter $parameter, ?string $type)
{
if ('array' === $type) {
switch ($parameter->name) {
case 'context':
$context = $_SERVER;
if ($_ENV && !isset($_SERVER['PATH']) && !isset($_SERVER['Path'])) {
$context += $_ENV;
}
return $context;
case 'argv':
return $_SERVER['argv'] ?? [];
case 'request':
return [
'query' => $_GET,
'body' => $_POST,
'files' => $_FILES,
'session' => &$_SESSION,
];
}
}
if (RuntimeInterface::class === $type) {
return $this;
}
$r = $parameter->getDeclaringFunction();
throw new \InvalidArgumentException(sprintf('Cannot resolve argument "%s $%s" in "%s" on line "%d": "%s" supports only arguments "array $context", "array $argv" and "array $request".', $type, $parameter->name, $r->getFileName(), $r->getStartLine(), get_debug_type($this)));
}
}

View File

@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Internal;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class BasicErrorHandler
{
public function __construct(bool $debug)
{
error_reporting(-1);
if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
ini_set('display_errors', $debug);
} elseif (!filter_var(ini_get('log_errors'), \FILTER_VALIDATE_BOOLEAN) || ini_get('error_log')) {
// CLI - display errors only if they're not already logged to STDERR
ini_set('display_errors', 1);
}
if (0 <= ini_get('zend.assertions')) {
ini_set('zend.assertions', 1);
ini_set('assert.active', $debug);
ini_set('assert.bail', 0);
ini_set('assert.warning', 0);
ini_set('assert.exception', 1);
}
}
public function __invoke(int $type, string $message, string $file, int $line): bool
{
if ((\E_DEPRECATED | \E_USER_DEPRECATED) & $type) {
return true;
}
if ((error_reporting() | \E_ERROR | \E_RECOVERABLE_ERROR | \E_PARSE | \E_CORE_ERROR | \E_COMPILE_ERROR | \E_USER_ERROR) & $type) {
throw new \ErrorException($message, 0, $type, $file, $line);
}
return false;
}
}

View File

@ -0,0 +1,128 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Internal;
use Composer\Composer;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Factory;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
use Composer\Script\ScriptEvents;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Runtime\RuntimeInterface;
use Symfony\Component\Runtime\SymfonyRuntime;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class ComposerPlugin implements PluginInterface, EventSubscriberInterface
{
/**
* @var Composer
*/
private $composer;
/**
* @var IOInterface
*/
private $io;
private static $activated = false;
public function activate(Composer $composer, IOInterface $io): void
{
self::$activated = true;
$this->composer = $composer;
$this->io = $io;
}
public function deactivate(Composer $composer, IOInterface $io): void
{
self::$activated = false;
}
public function uninstall(Composer $composer, IOInterface $io): void
{
@unlink($composer->getConfig()->get('vendor-dir').'/autoload_runtime.php');
}
public function updateAutoloadFile(): void
{
$vendorDir = $this->composer->getConfig()->get('vendor-dir');
if (!is_file($autoloadFile = $vendorDir.'/autoload.php')
|| false === $extra = $this->composer->getPackage()->getExtra()['runtime'] ?? []
) {
return;
}
$fs = new Filesystem();
$projectDir = \dirname(realpath(Factory::getComposerFile()));
if (null === $autoloadTemplate = $extra['autoload_template'] ?? null) {
$autoloadTemplate = __DIR__.'/autoload_runtime.template';
} else {
if (!$fs->isAbsolutePath($autoloadTemplate)) {
$autoloadTemplate = $projectDir.'/'.$autoloadTemplate;
}
if (!is_file($autoloadTemplate)) {
throw new \InvalidArgumentException(sprintf('File "%s" defined under "extra.runtime.autoload_template" in your composer.json file not found.', $this->composer->getPackage()->getExtra()['runtime']['autoload_template']));
}
}
$projectDir = $fs->makePathRelative($projectDir, $vendorDir);
$nestingLevel = 0;
while (0 === strpos($projectDir, '../')) {
++$nestingLevel;
$projectDir = substr($projectDir, 3);
}
if (!$nestingLevel) {
$projectDir = '__'.'DIR__.'.var_export('/'.$projectDir, true);
} else {
$projectDir = 'dirname(__'."DIR__, $nestingLevel)".('' !== $projectDir ? var_export('/'.$projectDir, true) : '');
}
$runtimeClass = $extra['class'] ?? SymfonyRuntime::class;
if (SymfonyRuntime::class !== $runtimeClass && !is_subclass_of($runtimeClass, RuntimeInterface::class)) {
throw new \InvalidArgumentException(sprintf('Class "%s" listed under "extra.runtime.class" in your composer.json file '.(class_exists($runtimeClass) ? 'should implement "%s".' : 'not found.'), $runtimeClass, RuntimeInterface::class));
}
if (!\is_array($runtimeOptions = $extra['options'] ?? [])) {
throw new \InvalidArgumentException('The "extra.runtime.options" entry in your composer.json file must be an array.');
}
$code = strtr(file_get_contents($autoloadTemplate), [
'%project_dir%' => $projectDir,
'%runtime_class%' => var_export($runtimeClass, true),
'%runtime_options%' => '['.substr(var_export($runtimeOptions, true), 7, -1)." 'project_dir' => {$projectDir},\n]",
]);
file_put_contents(substr_replace($autoloadFile, '_runtime', -4, 0), $code);
}
public static function getSubscribedEvents(): array
{
if (!self::$activated) {
return [];
}
return [
ScriptEvents::POST_AUTOLOAD_DUMP => 'updateAutoloadFile',
];
}
}

View File

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Internal;
/**
* @internal class that should be loaded only when symfony/dotenv is not installed
*/
class MissingDotenv
{
}

View File

@ -0,0 +1,26 @@
<?php
// autoload_runtime.php @generated by Symfony Runtime
if (true === (require_once __DIR__.'/autoload.php') || empty($_SERVER['SCRIPT_FILENAME'])) {
return;
}
if (!is_object($app = require $_SERVER['SCRIPT_FILENAME'])) {
throw new \TypeError(sprintf('Invalid return value: callable object expected, "%s" returned from "%s".', get_debug_type($app), $_SERVER['SCRIPT_FILENAME']));
}
$runtime = $_SERVER['APP_RUNTIME'] ?? %runtime_class%;
$runtime = new $runtime(($_SERVER['APP_RUNTIME_OPTIONS'] ?? []) + %runtime_options%);
[$app, $args] = $runtime
->getResolver($app)
->resolve();
$app = $app(...$args);
exit(
$runtime
->getRunner($app)
->run()
);

View File

@ -0,0 +1,19 @@
Copyright (c) 2021 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,112 @@
Runtime Component
=================
Symfony Runtime enables decoupling applications from global state.
**This Component is experimental**.
[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html)
are not covered by Symfony's
[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html).
Getting Started
---------------
```
$ composer require symfony/runtime
```
RuntimeInterface
----------------
The core of this component is the `RuntimeInterface` which describes a high-order
runtime logic.
It is designed to be totally generic and able to run any application outside of
the global state in 6 steps:
1. the main entry point returns a callable that wraps the application;
2. this callable is passed to `RuntimeInterface::getResolver()`, which returns a
`ResolverInterface`; this resolver returns an array with the (potentially
decorated) callable at index 0, and all its resolved arguments at index 1;
3. the callable is invoked with its arguments; it returns an object that
represents the application;
4. that object is passed to `RuntimeInterface::getRunner()`, which returns a
`RunnerInterface`: an instance that knows how to "run" the object;
5. that instance is `run()` and returns the exit status code as `int`;
6. the PHP engine is exited with this status code.
This process is extremely flexible as it allows implementations of
`RuntimeInterface` to hook into any critical steps.
Autoloading
-----------
This package registers itself as a Composer plugin to generate a
`vendor/autoload_runtime.php` file. This file shall be required instead of the
usual `vendor/autoload.php` in front-controllers that leverage this component
and return a callable.
Before requiring the `vendor/autoload_runtime.php` file, set the
`$_SERVER['APP_RUNTIME']` variable to a class that implements `RuntimeInterface`
and that should be used to run the returned callable.
Alternatively, the class of the runtime can be defined in the `extra.runtime.class`
entry of the `composer.json` file.
A `SymfonyRuntime` is used by default. It knows the conventions to run
Symfony and native PHP applications.
Examples
--------
This `public/index.php` is a "Hello World" that handles a "name" query parameter:
```php
<?php
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $request, array $context): void {
// $request holds keys "query", "body", "files" and "session",
// which map to $_GET, $_POST, $_FILES and &$_SESSION respectively
// $context maps to $_SERVER
$name = $request['query']['name'] ?? 'World';
$time = $context['REQUEST_TIME'];
echo sprintf('Hello %s, the current Unix timestamp is %s.', $name, $time);
};
```
This `bin/console.php` is a single-command "Hello World" application
(run `composer require symfony/console` before launching it):
```php
<?php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (Command $command) {
$command->addArgument('name', null, 'Who should I greet?', 'World');
return $command->setCode(function (InputInterface $input, OutputInterface $output) {
$name = $input->getArgument('name');
$output->writeln(sprintf('Hello <comment>%s</>', $name));
});
};
```
The `SymfonyRuntime` can resolve and handle many types related to the
`symfony/http-foundation` and `symfony/console` components.
Check its source code for more information.
Resources
---------
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Resolver;
use Symfony\Component\Runtime\ResolverInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
class ClosureResolver implements ResolverInterface
{
private $closure;
private $arguments;
public function __construct(\Closure $closure, \Closure $arguments)
{
$this->closure = $closure;
$this->arguments = $arguments;
}
/**
* {@inheritdoc}
*/
public function resolve(): array
{
return [$this->closure, ($this->arguments)()];
}
}

View File

@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Resolver;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
class DebugClosureResolver extends ClosureResolver
{
/**
* {@inheritdoc}
*/
public function resolve(): array
{
[$closure, $arguments] = parent::resolve();
return [
static function (...$arguments) use ($closure) {
if (\is_object($app = $closure(...$arguments)) || null === $app) {
return $app;
}
$r = new \ReflectionFunction($closure);
throw new \TypeError(sprintf('Unexpected value of type "%s" returned, "object" expected from "%s" on line "%d".', get_debug_type($app), $r->getFileName(), $r->getStartLine()));
},
$arguments,
];
}
}

View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
interface ResolverInterface
{
/**
* @return array{0: callable, 1: mixed[]}
*/
public function resolve(): array;
}

View File

@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Runner;
use Symfony\Component\Runtime\RunnerInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
class ClosureRunner implements RunnerInterface
{
private $closure;
public function __construct(\Closure $closure)
{
$this->closure = $closure;
}
public function run(): int
{
$exitStatus = ($this->closure)();
if (\is_string($exitStatus)) {
echo $exitStatus;
return 0;
}
if (null !== $exitStatus && !\is_int($exitStatus)) {
$r = new \ReflectionFunction($this->closure);
throw new \TypeError(sprintf('Unexpected value of type "%s" returned, "string|int|null" expected from "%s" on line "%d".', get_debug_type($exitStatus), $r->getFileName(), $r->getStartLine()));
}
return $exitStatus ?? 0;
}
}

View File

@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Runner\Symfony;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Runtime\RunnerInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
class ConsoleApplicationRunner implements RunnerInterface
{
private $application;
private $defaultEnv;
private $input;
private $output;
public function __construct(Application $application, ?string $defaultEnv, InputInterface $input, OutputInterface $output = null)
{
$this->application = $application;
$this->defaultEnv = $defaultEnv;
$this->input = $input;
$this->output = $output;
}
public function run(): int
{
if (null === $this->defaultEnv) {
return $this->application->run($this->input, $this->output);
}
$definition = $this->application->getDefinition();
if (!$definition->hasOption('env') && !$definition->hasOption('e') && !$definition->hasShortcut('e')) {
$definition->addOption(new InputOption('--env', '-e', InputOption::VALUE_REQUIRED, 'The Environment name.', $this->defaultEnv));
}
if (!$definition->hasOption('no-debug')) {
$definition->addOption(new InputOption('--no-debug', null, InputOption::VALUE_NONE, 'Switches off debug mode.'));
}
return $this->application->run($this->input, $this->output);
}
}

View File

@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Runner\Symfony;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\TerminableInterface;
use Symfony\Component\Runtime\RunnerInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
class HttpKernelRunner implements RunnerInterface
{
private $kernel;
private $request;
public function __construct(HttpKernelInterface $kernel, Request $request)
{
$this->kernel = $kernel;
$this->request = $request;
}
public function run(): int
{
$response = $this->kernel->handle($this->request);
$response->send();
if ($this->kernel instanceof TerminableInterface) {
$this->kernel->terminate($this->request, $response);
}
return 0;
}
}

View File

@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Runner\Symfony;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Runtime\RunnerInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
class ResponseRunner implements RunnerInterface
{
private $response;
public function __construct(Response $response)
{
$this->response = $response;
}
public function run(): int
{
$this->response->send();
return 0;
}
}

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
interface RunnerInterface
{
public function run(): int;
}

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime;
/**
* Enables decoupling applications from global state.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
interface RuntimeInterface
{
/**
* Returns a resolver that should compute the arguments of a callable.
*
* The callable itself should return an object that represents the application to pass to the getRunner() method.
*/
public function getResolver(callable $callable): ResolverInterface;
/**
* Returns a callable that knows how to run the passed object and that returns its exit status as int.
*
* The passed object is typically created by calling ResolverInterface::resolve().
*/
public function getRunner(?object $application): RunnerInterface;
}

View File

@ -0,0 +1,205 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Runtime\Internal\MissingDotenv;
use Symfony\Component\Runtime\Runner\Symfony\ConsoleApplicationRunner;
use Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner;
use Symfony\Component\Runtime\Runner\Symfony\ResponseRunner;
// Help opcache.preload discover always-needed symbols
class_exists(ResponseRunner::class);
class_exists(HttpKernelRunner::class);
class_exists(MissingDotenv::class, false) || class_exists(Dotenv::class) || class_exists(MissingDotenv::class);
/**
* Knows the basic conventions to run Symfony apps.
*
* Accepts the following options:
* - "debug" to toggle debugging features
* - "env" to define the name of the environment the app runs in
* - "disable_dotenv" to disable looking for .env files
* - "dotenv_path" to define the path of dot-env files - defaults to ".env"
* - "prod_envs" to define the names of the production envs - defaults to ["prod"]
* - "test_envs" to define the names of the test envs - defaults to ["test"]
*
* When these options are not defined, they will fallback:
* - to reading the "APP_DEBUG" and "APP_ENV" environment variables;
* - to parsing the "--env|-e" and "--no-debug" command line arguments
* if the "symfony/console" component is installed.
*
* When the "symfony/dotenv" component is installed, .env files are loaded.
* When "symfony/error-handler" is installed, it is registred in debug mode.
*
* On top of the base arguments provided by GenericRuntime,
* this runtime can feed the app-callable with arguments of type:
* - Request from "symfony/http-foundation" if the component is installed;
* - Application, Command, InputInterface and/or OutputInterface
* from "symfony/console" if the component is installed.
*
* This runtime can handle app-callables that return instances of either:
* - HttpKernelInterface,
* - Response,
* - Application,
* - Command,
* - int|string|null as handled by GenericRuntime.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @experimental in 5.3
*/
class SymfonyRuntime extends GenericRuntime
{
private $input;
private $output;
private $console;
private $command;
private $env;
/**
* @param array {
* debug?: ?bool,
* env?: ?string,
* disable_dotenv?: ?bool,
* project_dir?: ?string,
* prod_envs?: ?string[],
* dotenv_path?: ?string,
* test_envs?: ?string[],
* } $options
*/
public function __construct(array $options = [])
{
$this->env = $options['env'] ?? null;
$_SERVER['APP_ENV'] = $options['env'] ?? $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null;
if (isset($_SERVER['argv']) && null === $this->env && class_exists(ArgvInput::class)) {
$this->getInput();
}
if (!($options['disable_dotenv'] ?? false) && isset($options['project_dir']) && !class_exists(MissingDotenv::class, false)) {
(new Dotenv())
->setProdEnvs((array) ($options['prod_envs'] ?? ['prod']))
->bootEnv($options['project_dir'].'/'.($options['dotenv_path'] ?? '.env'), 'dev', (array) ($options['test_envs'] ?? ['test']));
$options['debug'] ?? $options['debug'] = '1' === $_SERVER['APP_DEBUG'];
}
parent::__construct($options);
if ($_SERVER['APP_DEBUG'] && class_exists(Debug::class)) {
restore_error_handler();
umask(0000);
Debug::enable();
}
}
public function getRunner(?object $application): RunnerInterface
{
if ($application instanceof HttpKernelInterface) {
return new HttpKernelRunner($application, Request::createFromGlobals());
}
if ($application instanceof Response) {
return new ResponseRunner($application);
}
if ($application instanceof Command) {
$console = $this->console ?? $this->console = new Application();
$console->setName($application->getName() ?: $console->getName());
if (!$application->getName() || !$console->has($application->getName())) {
$application->setName($_SERVER['argv'][0]);
$console->add($application);
}
$console->setDefaultCommand($application->getName(), true);
return $this->getRunner($console);
}
if ($application instanceof Application) {
if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) {
echo 'Warning: The console should be invoked via the CLI version of PHP, not the '.\PHP_SAPI.' SAPI'.\PHP_EOL;
}
set_time_limit(0);
$defaultEnv = null === $this->env ? ($_SERVER['APP_ENV'] ?? 'dev') : null;
$output = $this->output ?? $this->output = new ConsoleOutput();
return new ConsoleApplicationRunner($application, $defaultEnv, $this->getInput(), $output);
}
if ($this->command) {
$this->getInput()->bind($this->command->getDefinition());
}
return parent::getRunner($application);
}
/**
* @return mixed
*/
protected function getArgument(\ReflectionParameter $parameter, ?string $type)
{
switch ($type) {
case Request::class:
return Request::createFromGlobals();
case InputInterface::class:
return $this->getInput();
case OutputInterface::class:
return $this->output ?? $this->output = new ConsoleOutput();
case Application::class:
return $this->console ?? $this->console = new Application();
case Command::class:
return $this->command ?? $this->command = new Command();
}
return parent::getArgument($parameter, $type);
}
private function getInput(): ArgvInput
{
if (null !== $this->input) {
return $this->input;
}
$input = new ArgvInput();
if (null !== $this->env) {
return $this->input = $input;
}
if (null !== $env = $input->getParameterOption(['--env', '-e'], null, true)) {
putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env);
}
if ($input->hasParameterOption('--no-debug', true)) {
putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0');
}
return $this->input = $input;
}
}

View File

@ -0,0 +1 @@
SOME_VAR=foo_bar

View File

@ -0,0 +1,21 @@
<?php
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
require __DIR__.'/autoload.php';
return function (array $context) {
$command = new Command('go');
$command->setCode(function (InputInterface $input, OutputInterface $output) use ($context) {
$output->write('OK Application '.$context['SOME_VAR']);
});
$app = new Application();
$app->add($command);
$app->setDefaultCommand('go', true);
return $app;
};

View File

@ -0,0 +1,12 @@
--TEST--
Test Application
--INI--
display_errors=1
--FILE--
<?php
require $_SERVER['SCRIPT_FILENAME'] = __DIR__.'/application.php';
?>
--EXPECTF--
OK Application foo_bar

View File

@ -0,0 +1,24 @@
<?php
use Symfony\Component\Runtime\SymfonyRuntime;
$_SERVER['APP_RUNTIME_OPTIONS'] = [
'project_dir' => __DIR__,
];
if (file_exists(dirname(__DIR__, 2).'/vendor/autoload.php')) {
if (true === (require_once dirname(__DIR__, 2).'/vendor/autoload.php') || empty($_SERVER['SCRIPT_FILENAME'])) {
return;
}
$app = require $_SERVER['SCRIPT_FILENAME'];
$runtime = new SymfonyRuntime($_SERVER['APP_RUNTIME_OPTIONS']);
[$app, $args] = $runtime->getResolver($app)->resolve();
exit($runtime->getRunner($app(...$args))->run());
}
if (!file_exists(dirname(__DIR__, 6).'/vendor/autoload_runtime.php')) {
throw new LogicException('Autoloader not found.');
}
require dirname(__DIR__, 6).'/vendor/autoload_runtime.php';

View File

@ -0,0 +1,15 @@
<?php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
require __DIR__.'/autoload.php';
return function (Command $command, InputInterface $input, OutputInterface $output, array $context) {
$command->setCode(function () use ($output, $context) {
$output->write('OK Command '.$context['SOME_VAR']);
});
return $command;
};

View File

@ -0,0 +1,12 @@
--TEST--
Test Command
--INI--
display_errors=1
--FILE--
<?php
require $_SERVER['SCRIPT_FILENAME'] = __DIR__.'/command.php';
?>
--EXPECTF--
OK Command foo_bar

View File

@ -0,0 +1,16 @@
<?php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
require __DIR__.'/autoload.php';
return function (Command $command, InputInterface $input, OutputInterface $output, array $context) {
$command->addArgument('name', null, 'Who should I greet?', 'World');
return static function () use ($input, $output, $context) {
$output->writeln(sprintf('Hello %s', $input->getArgument('name')));
$output->write('OK Command '.$context['SOME_VAR']);
};
};

View File

@ -0,0 +1,13 @@
--TEST--
Test Command
--INI--
display_errors=1
--FILE--
<?php
require $_SERVER['SCRIPT_FILENAME'] = __DIR__.'/command2.php';
?>
--EXPECTF--
Hello World
OK Command foo_bar

View File

@ -0,0 +1,20 @@
<?php
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Runtime\RuntimeInterface;
require __DIR__.'/autoload.php';
return function (Application $app, Command $command, RuntimeInterface $runtime) {
$app->setVersion('1.2.3');
$app->setName('Hello console');
$command->setDescription('Hello description ');
$command->setName('my_command');
[$cmd, $args] = $runtime->getResolver(require __DIR__.'/command.php')->resolve();
$app->add($cmd(...$args));
return $app;
};

View File

@ -0,0 +1,38 @@
--TEST--
Test "list" Command
--INI--
display_errors=1
--FILE--
<?php
$argv = $_SERVER['argv'] = [
'my_app',
'list',
'--env=prod',
'--no-ansi',
];
$argc = $_SERVER['argc'] = count($argv);
require $_SERVER['SCRIPT_FILENAME'] = __DIR__.'/command_list.php';
?>
--EXPECTF--
Hello console 1.2.3
Usage:
command [options] [arguments]
Options:
-h, --help Display %s
-q, --quiet Do not output any message
-V, --version Display this application version
--ansi%A
-n, --no-interaction Do not ask any interactive question
-e, --env=ENV The Environment name. [default: "prod"]
--no-debug Switches off debug mode.
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
Available commands:
help Display%S help for a command
list List%S commands
my_command Hello description

View File

@ -0,0 +1,10 @@
<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
require __DIR__.'/autoload.php';
return function (array $context): void {
echo 'Hello World ', $context['SOME_VAR'];
};

View File

@ -0,0 +1,12 @@
--TEST--
Test Request/Response
--INI--
display_errors=1
--FILE--
<?php
require $_SERVER['SCRIPT_FILENAME'] = __DIR__.'/hello.php';
?>
--EXPECTF--
Hello World foo_bar

View File

@ -0,0 +1,25 @@
<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Runtime\SymfonyRuntime;
use Symfony\Component\Runtime\Runner\ClosureRunner;
use Symfony\Component\Runtime\RunnerInterface;
require __DIR__.'/autoload.php';
$runtime = new class(['project_dir' => __DIR__]) extends SymfonyRuntime {
public function getRunner(?object $kernel): RunnerInterface
{
return new ClosureRunner(static function () use ($kernel): int {
$kernel->handle(new Request())->send();
echo "\n";
$kernel->handle(new Request())->send();
echo "\n";
return 0;
});
}
};
[$app, $args] = $runtime->getResolver(require __DIR__.'/kernel.php')->resolve();
echo $runtime->getRunner($app(...$args))->run();

View File

@ -0,0 +1,16 @@
--TEST--
Test HttpKernelInterface
--INI--
display_errors=1
--FILE--
<?php
$_SERVER['SCRIPT_FILENAME'] = null;
require __DIR__.'/kernel-loop.php';
?>
--EXPECTF--
OK Kernel foo_bar
OK Kernel foo_bar
0

View File

@ -0,0 +1,26 @@
<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
require __DIR__.'/autoload.php';
class TestKernel implements HttpKernelInterface
{
private $var;
public function __construct(string $var)
{
$this->var = $var;
}
public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true)
{
return new Response('OK Kernel '.$this->var);
}
}
return function (array $context) {
return new TestKernel($context['SOME_VAR']);
};

View File

@ -0,0 +1,12 @@
--TEST--
Test HttpKernelInterface
--INI--
display_errors=1
--FILE--
<?php
require $_SERVER['SCRIPT_FILENAME'] = __DIR__.'/kernel.php';
?>
--EXPECTF--
OK Kernel foo_bar

View File

@ -0,0 +1,10 @@
<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
require __DIR__.'/autoload.php';
return function (Request $request, array $context) {
return new Response('OK Request '.$context['SOME_VAR']);
};

View File

@ -0,0 +1,12 @@
--TEST--
Test Request/Response
--INI--
display_errors=1
--FILE--
<?php
require $_SERVER['SCRIPT_FILENAME'] = __DIR__.'/request.php';
?>
--EXPECTF--
OK Request foo_bar

View File

@ -0,0 +1,42 @@
{
"name": "symfony/runtime",
"type": "composer-plugin",
"description": "Enables decoupling PHP applications from global state",
"homepage": "https://symfony.com",
"license" : "MIT",
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=7.2.5",
"composer-plugin-api": "^1.0|^2.0",
"symfony/polyfill-php80": "^1.15"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"symfony/console": "^4.4|^5",
"symfony/dotenv": "^5.1",
"symfony/http-foundation": "^4.4|^5",
"symfony/http-kernel": "^4.4|^5"
},
"conflict": {
"symfony/dotenv": "<5.1"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Runtime\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev",
"extra": {
"class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin"
}
}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony Runtime Component Test Suite">
<directory suffix=".phpt">./Tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>