Added minimal webhook http server.

This commit is contained in:
abdulmohsen
2022-07-26 20:47:40 +03:00
parent 87f888d811
commit e7f05357f0
5 changed files with 704 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Commands\System;
use App\Command;
use App\Libs\Routable;
use App\Libs\Server;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[Routable(command: self::ROUTE)]
final class ServerCommand extends Command
{
public const ROUTE = 'system:server';
public function __construct(private Server $server)
{
parent::__construct();
}
protected function configure(): void
{
$this->setName(self::ROUTE)
->setDescription('Start minimal http server.')
->addOption('interface', 'i', InputOption::VALUE_REQUIRED, 'Bind to interface.', '0.0.0.0')
->addOption('port', 'p', InputOption::VALUE_REQUIRED, 'Bind to port.', 8080)
->addOption('threads', 't', InputOption::VALUE_REQUIRED, 'How many threads to use.', 1)
->setHelp(
<<<HELP
This server is not meant to be used in production. It is mainly for testing purposes.
HELP
);
}
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$host = $input->getOption('interface');
$port = (int)$input->getOption('port');
$threads = (int)$input->getOption('threads');
$this->server = $this->server->withInterface($host)->withPort($port)->withThreads($threads)
->runInBackground(
fn($std, $out) => $output->writeln(trim($out), OutputInterface::VERBOSITY_VERBOSE)
);
$output->writeln(
r('Listening on \'http://{host}:{port}\' for webhook events.', [
'host' => $host,
'port' => $port,
])
);
$this->server->wait();
return self::SUCCESS;
}
}

411
src/Libs/Server.php Normal file
View File

@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
namespace App\Libs;
use Closure;
use RuntimeException;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
class Server
{
public const CONFIG_HOST = 'host';
public const CONFIG_PORT = 'port';
public const CONFIG_ROOT = 'root';
public const CONFIG_PHP = 'php';
public const CONFIG_ENV = 'env';
public const CONFIG_ROUTER = 'router';
public const CONFIG_THREADS = 'threads';
/**
* The default and user merged configuration array
*/
private array $config = [];
/**
* Indicate whether the server is currently running
*/
private bool $running = false;
/**
* The initiated process.
*/
private Process|null $process = null;
public function __construct(array $config = [])
{
$classExists = class_exists(PhpExecutableFinder::class);
$this->config = [
self::CONFIG_HOST => '0.0.0.0',
self::CONFIG_PORT => 8080,
self::CONFIG_ROUTER => null,
self::CONFIG_ROOT => realpath(__DIR__ . '/../../public'),
self::CONFIG_PHP => $classExists ? (new PhpExecutableFinder())->find(false) : PHP_BINARY,
self::CONFIG_ENV => array_replace_recursive($_ENV, getenv()),
self::CONFIG_THREADS => 1,
];
if (null !== ($config[self::CONFIG_HOST] ?? null)) {
$this->withInterface($config[self::CONFIG_HOST]);
$this->config[self::CONFIG_HOST] = $config[self::CONFIG_HOST];
}
if (null !== ($config[self::CONFIG_PORT] ?? null)) {
$this->withPort($config[self::CONFIG_PORT]);
$this->config[self::CONFIG_PORT] = $config[self::CONFIG_PORT];
}
if (null !== ($config[self::CONFIG_ROUTER] ?? null)) {
$this->withRouter($config[self::CONFIG_ROUTER]);
$this->config[self::CONFIG_ROUTER] = $config[self::CONFIG_ROUTER];
}
if (null !== ($config[self::CONFIG_ROOT] ?? null)) {
$this->withRoot($config[self::CONFIG_ROOT]);
$this->config[self::CONFIG_ROOT] = $config[self::CONFIG_ROOT];
}
if (null !== ($config[self::CONFIG_PHP] ?? null)) {
$this->withPHP($config[self::CONFIG_PHP]);
$this->config[self::CONFIG_PHP] = $config[self::CONFIG_PHP];
}
if (null !== ($config[self::CONFIG_THREADS] ?? null)) {
$this->withThreads($config[self::CONFIG_THREADS]);
$this->config[self::CONFIG_THREADS] = $config[self::CONFIG_THREADS];
}
if (null !== ($config[self::CONFIG_ENV] ?? null)) {
$this->withENV($config[self::CONFIG_ENV]);
$this->config[self::CONFIG_ENV] = array_replace_recursive(
$this->config[self::CONFIG_ENV],
$config[self::CONFIG_ENV]
);
}
}
/**
* Set Path to PHP binary.
*
* @param string $php
*
* @return $this cloned instance.
*/
public function withPHP(string $php): self
{
if (false === is_executable($php)) {
throw new RuntimeException(sprintf('PHP binary \'%s\' is not executable.', $php));
}
if ($this->config[self::CONFIG_PHP] === $php) {
return $this;
}
$instance = clone $this;
$instance->process = null;
$instance->running = false;
$instance->config[self::CONFIG_PHP] = $php;
return $instance;
}
/**
* Set Host to bind to.
*
* @param string $host
*
* @return $this cloned instance.
*/
public function withInterface(string $host): self
{
if ($this->config[self::CONFIG_HOST] === $host) {
return $this;
}
$instance = clone $this;
$instance->process = null;
$instance->running = false;
$instance->config[self::CONFIG_HOST] = $host;
return $instance;
}
/**
* Set Port.
*
* @param int $port
*
* @return $this cloned instance.
*/
public function withPort(int $port): self
{
if ($this->config[self::CONFIG_PORT] === $port) {
return $this;
}
$instance = clone $this;
$instance->process = null;
$instance->running = false;
$instance->config[self::CONFIG_PORT] = $port;
return $instance;
}
/**
* Set How many threads to use.
*
* @param int $threads
*
* @return $this cloned instance.
*/
public function withThreads(int $threads): self
{
if ($this->config[self::CONFIG_THREADS] === $threads) {
return $this;
}
$instance = clone $this;
$instance->process = null;
$instance->running = false;
$instance->config[self::CONFIG_THREADS] = $threads;
return $instance;
}
/**
* Set Root path.
*
* @param string $root
*
* @return $this cloned instance.
*/
public function withRoot(string $root): self
{
if (!is_dir($root)) {
throw new RuntimeException(sprintf('Root path \'%s\' is not a directory.', $root));
}
if ($this->config[self::CONFIG_ROOT] === $root) {
return $this;
}
$instance = clone $this;
$instance->process = null;
$instance->running = false;
$instance->config[self::CONFIG_ROOT] = $root;
return $instance;
}
/**
* Set PHP Router file.
*
* @param string $router
*
* @return $this cloned instance.
*/
public function withRouter(string $router): self
{
if (false === file_exists($router)) {
throw new RuntimeException(sprintf('The router file \'%s\' does not exist.', $router));
}
if ($this->config[self::CONFIG_ROUTER] === $router) {
return $this;
}
$instance = clone $this;
$instance->process = null;
$instance->running = false;
$instance->config[self::CONFIG_ROUTER] = $router;
return $instance;
}
/**
* Set Environment variables.
*
* @param array $vars key/value pair.
* @param bool $clear Clear Currently loaded environment.
*
* @return $this cloned instance.
*/
public function withENV(array $vars, bool $clear = false): self
{
$instance = clone $this;
$instance->process = null;
$instance->running = false;
$instance->config[self::CONFIG_ENV] = array_replace_recursive(
false === $clear ? $instance->config[self::CONFIG_ENV] : [],
$vars
);
return $instance->config[self::CONFIG_ENV] === $this->config[self::CONFIG_ENV] ? $this : $instance;
}
/**
* Exclude environment variables from loaded list.
*
* @param array $vars
*
* @return $this cloned instance.
*/
public function withoutENV(array $vars): self
{
$instance = clone $this;
$instance->process = null;
$instance->running = false;
$instance->config[self::CONFIG_ENV] = array_filter(
$instance->config[self::CONFIG_ENV],
fn($key) => false === in_array($key, $vars),
ARRAY_FILTER_USE_KEY
);
return $instance->config[self::CONFIG_ENV] === $this->config[self::CONFIG_ENV] ? $this : $instance;
}
/**
* Run Server in blocking mode.
*/
public function run(Closure|null $output = null): int
{
$this->process = $this->makeServer($output);
return $this->process->wait();
}
/**
* Hang around until the server is killed.
*/
public function wait(): int
{
if (false === $this->isRunning()) {
throw new RuntimeException('No server was started.');
}
return $this->process->wait();
}
/**
* Run server in background.
*/
public function runInBackground(Closure|null $output = null): self
{
$this->process = $this->makeServer($output);
return $this;
}
/**
* Stop currently running server.
*
* @param int $timeout kill process if it does not exist in given seconds.
* @param int|null $signal stop signal.
*
* @return int return 20002 if the server is not running. otherwise process exit code will be returned.
*/
public function stop(int $timeout = 10, int|null $signal = null): int
{
if (null === $this->process) {
return 20002;
}
$this->process->stop($timeout, $signal);
$this->running = false;
return $this->process->getExitCode();
}
public function getInterface(): string
{
return $this->config[self::CONFIG_HOST];
}
public function getPort(): int
{
return $this->config[self::CONFIG_PORT];
}
/**
* @return bool Whether the process is running.
*/
public function isRunning(): bool
{
return $this->running;
}
/**
* @return array Get loaded configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* @return Process|null Return server process or null if not initiated yet.
*/
public function getProcess(): Process|null
{
return $this->process;
}
private function buildServeCommand(): array
{
$command = [
$this->config[self::CONFIG_PHP],
'-S',
$this->config[self::CONFIG_HOST] . ':' . $this->config[self::CONFIG_PORT],
'-t',
$this->config[self::CONFIG_ROOT],
];
if (null !== $this->config[self::CONFIG_ROUTER]) {
$command[] = $this->config[self::CONFIG_ROUTER];
}
return $command;
}
private function makeServer(Closure|null $output = null): Process
{
$env = $this->config[self::CONFIG_ENV];
if (null !== ($this->config[self::CONFIG_THREADS] ?? null) && $this->config[self::CONFIG_THREADS] > 1) {
$env['PHP_CLI_SERVER_WORKERS'] = $this->config[self::CONFIG_THREADS];
}
$process = new Process(
command: $this->buildServeCommand(),
env: $env,
timeout: null
);
$process->start($output);
$this->running = $process->isRunning();
return $process;
}
public function __destruct()
{
if (true === $this->isRunning()) {
$this->stop();
}
}
}

216
tests/Server/ServerTest.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace Tests\Server;
use App\Libs\Server;
use Exception;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Symfony\Component\Process\Process;
class ServerTest extends TestCase
{
private array $config = [];
private Server|null $server = null;
public function setUp(): void
{
try {
$randomPort = random_int(50000, 65535);
} catch (Exception) {
$randomPort = 24587;
}
$this->config = [
Server::CONFIG_HOST => '0.0.0.0',
Server::CONFIG_PORT => $randomPort,
Server::CONFIG_PHP => PHP_BINARY,
Server::CONFIG_THREADS => 1,
Server::CONFIG_ROOT => __DIR__,
Server::CONFIG_ROUTER => __FILE__,
Server::CONFIG_ENV => [
'test_a' => 1,
'test_b' => 2,
],
];
$this->server = new Server($this->config);
$this->server = $this->server->withENV($this->config[Server::CONFIG_ENV], true);
}
public function test_constructor_conditions(): void
{
$config = [
Server::CONFIG_HOST => '0.0.0.1',
Server::CONFIG_PORT => 8081,
Server::CONFIG_ROUTER => __FILE__,
Server::CONFIG_ROOT => __DIR__,
Server::CONFIG_PHP => PHP_BINARY,
];
$server = new Server($config);
$this->assertEquals(
array_intersect_key($server->getConfig(), $config),
$config,
'Should be equal config.'
);
$c = (new Server())->getConfig();
$this->assertSame($c[Server::CONFIG_HOST], '0.0.0.0', 'Default host has changed.');
$this->assertSame($c[Server::CONFIG_PORT], 8080, 'Default port has changed.');
$this->assertSame($c[Server::CONFIG_THREADS], 1, 'Default threads changed.');
$this->assertSame($c[Server::CONFIG_ROOT], realpath(__DIR__ . '/../../public'), 'Default root has changed.');
}
public function test_withPHP_conditions(): void
{
$this->assertSame(
$this->server,
$this->server->withPHP($this->config[Server::CONFIG_PHP]),
'Should be same object.'
);
$this->expectException(RuntimeException::class);
$this->server->withPHP($this->config[Server::CONFIG_PHP] . 'a');
}
public function test_withInterface_conditions(): void
{
$this->assertSame(
$this->server,
$this->server->withInterface($this->config[Server::CONFIG_HOST]),
'Should return same object.'
);
$this->assertNotSame(
$this->server,
$this->server->withInterface('0.0.0.1'),
'Should be different object.'
);
}
public function test_getInterface_conditions(): void
{
$this->assertSame(
$this->config[Server::CONFIG_HOST],
$this->server->getInterface(),
'Should be the same.'
);
}
public function test_withPort_conditions(): void
{
$this->assertSame(
$this->server,
$this->server->withPort($this->config[Server::CONFIG_PORT]),
'should return same object.'
);
$this->assertNotSame(
$this->server,
$this->server->withPort($this->config[Server::CONFIG_PORT] + 1),
'Should not be same object.'
);
}
public function test_getPort_conditions(): void
{
$this->assertSame(
$this->config[Server::CONFIG_PORT],
$this->server->getPort(),
'Should be the same.'
);
}
public function test_withThreads_conditions(): void
{
$this->assertSame(
$this->server,
$this->server->withThreads($this->config[Server::CONFIG_THREADS]),
'Should return same object. As threads has not changed.'
);
$this->assertNotSame(
$this->server,
$this->server->withThreads($this->config[Server::CONFIG_THREADS] + 1),
'Should return new object. As we have changed threads number.'
);
}
public function test_withRoot_conditions(): void
{
$this->assertSame(
$this->server,
$this->server->withRoot($this->config[Server::CONFIG_ROOT]),
'Should return same object.'
);
$this->expectException(RuntimeException::class);
$this->server->withRoot($this->config[Server::CONFIG_ROOT] . $this->config[Server::CONFIG_ROOT]);
}
public function test_withRouter_conditions(): void
{
$this->assertSame(
$this->server,
$this->server->withRouter($this->config[Server::CONFIG_ROUTER])
);
$this->expectException(RuntimeException::class);
$this->server->withRouter($this->config[Server::CONFIG_ROUTER] . 'zzz');
}
public function test_withENV_conditions(): void
{
$this->assertSame(
$this->server,
$this->server->withENV(['test_b' => 2]),
'Supposed to return same object. As value did not change.'
);
$this->assertNotSame(
$this->server,
$this->server->withENV(['foo' => 'bar']),
'Not supposed to return same object with changed value.'
);
}
public function test_withoutENV_conditions(): void
{
$this->assertSame(
$this->server,
$this->server->withoutENV(['test_non_existent_env']),
'Supposed to return same object. as key does not exists'
);
$this->assertNotSame(
$this->server,
$this->server->withoutENV(['test_a']),
'Not supposed to return same object with changed value.'
);
$this->assertEquals(
['test_a' => 1],
$this->server->withoutENV(['test_b'])->getConfig()[Server::CONFIG_ENV],
'Should be identical array.'
);
}
public function test_getProcess_conditions(): void
{
$this->assertNull($this->server->getProcess(), 'Should be null at this point');
$server = $this->server->withThreads(1)
->withRoot(__DIR__ . '/resources')
->withRouter(__DIR__ . '/resources/index.php')
->runInBackground();
$this->assertInstanceOf(Process::class, $server->getProcess());
$server->stop();
}
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,3 @@
<?php
echo 'HelloTester';