diff --git a/composer.json b/composer.json index 38f7bff..285638d 100644 --- a/composer.json +++ b/composer.json @@ -23,16 +23,14 @@ "sort-packages": true }, "suggest": { - "makise-co/postgres": "To work with modern PostgreSQL Coroutine driver", "makise-co/orm-bundle": "To work with modern ORM", - "makise-co/postgres-spiral-driver": "To work with modern DBAL" + "makise-co/http": "To run HTTP server", + "makise-co/auth": "To authenticate/authrize HTTP requests" }, "require": { "php": "^7.4", "ext-json": "*", "ext-swoole": "^4.4", - "laminas/laminas-diactoros": "^2.4", - "makise-co/http-router": "^1.0", "monolog/monolog": "^2.0", "nunomaduro/collision": "^4.1", "php-di/php-di": "^6.1", @@ -40,10 +38,9 @@ "symfony/console": "^5.0", "symfony/event-dispatcher": "^5.0", "symfony/finder": "^5.0", - "symfony/http-foundation": "^5.0", "symfony/property-access": "^5.0", "symfony/var-dumper": "^5.0", - "vlucas/phpdotenv": "^4.1" + "vlucas/phpdotenv": "^5.0" }, "require-dev": { "phpstan/phpstan": "^0.12.18", @@ -53,7 +50,8 @@ }, "autoload": { "psr-4": { - "MakiseCo\\": "src/" + "MakiseCo\\": "src/", + "MakiseCo\\Tests\\": "tests/" }, "files": [ "src/Env/functions.php" diff --git a/phpunit.xml b/phpunit.xml.dist similarity index 71% rename from phpunit.xml rename to phpunit.xml.dist index 5ad3779..0c677ec 100644 --- a/phpunit.xml +++ b/phpunit.xml.dist @@ -1,5 +1,6 @@ - + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> + + + ./src + + ./tests - - - ./src - - diff --git a/src/Application.php b/src/Application.php index 48e5ce6..4ef03c4 100644 --- a/src/Application.php +++ b/src/Application.php @@ -11,10 +11,6 @@ namespace MakiseCo; use DI\Container; -use Dotenv\Repository\Adapter\EnvConstAdapter; -use Dotenv\Repository\Adapter\PutenvAdapter; -use Dotenv\Repository\Adapter\ServerConstAdapter; -use Dotenv\Repository\RepositoryBuilder; use MakiseCo\Config\ConfigRepositoryInterface; use MakiseCo\Config\Repository; use MakiseCo\Env\Env; @@ -102,36 +98,26 @@ protected function bootDi(): void $this->container->set(ApplicationInterface::class, $this); // alias to ApplicationInterface $this->container->set(self::class, \DI\get(ApplicationInterface::class)); + // add services bootstrapper + $this->container->set(Bootstrapper::class, new Bootstrapper()); } protected function bootEnv(): void { - $repository = RepositoryBuilder::create() - ->withReaders([new EnvConstAdapter, new PutenvAdapter, new ServerConstAdapter]) - ->withWriters([new EnvConstAdapter, new PutenvAdapter, new ServerConstAdapter]) + $repository = \Dotenv\Repository\RepositoryBuilder::createWithDefaultAdapters() ->make(); Env::setRepository($repository); - $dotenv = \Dotenv\Dotenv::create( - $repository, - [$this->appDir . DIRECTORY_SEPARATOR], - ['.env'], - true - ); - - $dotenv->safeLoad(); - - // load env-scoped variables - $env = Env::get('APP_ENV', null); - if (null !== $env) { - \Dotenv\Dotenv::create( - $repository, - [$this->appDir . DIRECTORY_SEPARATOR], - [".env.{$env}"], - true - )->safeLoad(); + $envFile = '.env'; + + if (($input = new ArgvInput)->hasParameterOption('--env')) { + $envFile .= ".{$input->getParameterOption('--env')}"; + } elseif (!empty($env = $repository->get('APP_ENV'))) { + $envFile .= ".{$env}"; } + + \Dotenv\Dotenv::create($repository, [$this->appDir], [$envFile])->safeLoad(); } protected function bootConfig(): void @@ -205,6 +191,6 @@ protected function bootCommands(): void public function getVersion(): string { - return '1.0.3'; + return '2.0.0'; } } diff --git a/src/Auth/AuthManager.php b/src/Auth/AuthManager.php deleted file mode 100644 index b22d98e..0000000 --- a/src/Auth/AuthManager.php +++ /dev/null @@ -1,103 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth; - -use DI\Container; -use MakiseCo\Auth\Guard\GuardInterface; -use function sprintf; - -class AuthManager -{ - protected Container $container; - - /** - * @var UserProviderInterface[] - */ - protected array $providers = []; - - /** - * @var GuardInterface[] - */ - protected array $guards = []; - - public function __construct(Container $container) - { - $this->container = $container; - } - - public function addProvider(string $name, string $class, array $params): void - { - $provider = $this->container->make($class, $params); - if (!$provider instanceof UserProviderInterface) { - throw new \InvalidArgumentException( - sprintf( - 'Wrong user provider "%s" - %s is not an instance of %s', - $name, - $class, - UserProviderInterface::class, - ) - ); - } - - $this->providers[$name] = $provider; - } - - public function addGuard(string $name, string $class, string $provider, array $params): void - { - $providerInstance = $this->providers[$provider] ?? null; - if (null === $providerInstance) { - throw new \InvalidArgumentException( - sprintf( - 'User provider "%s" - not found for guard %s', - $provider, - $name - ) - ); - } - - $args = ['provider' => $providerInstance]; - $args += $params; - - $guard = $this->container->make($class, $args); - if (!$guard instanceof GuardInterface) { - throw new \InvalidArgumentException( - sprintf( - 'Wrong guard "%s" - %s is not an instance of %s', - $name, - $class, - GuardInterface::class, - ) - ); - } - - $this->guards[$name] = $guard; - } - - public function getProvider(string $name): UserProviderInterface - { - $provider = $this->providers[$name] ?? null; - if (null === $provider) { - throw new \InvalidArgumentException(sprintf('Provider %s not found', $name)); - } - - return $provider; - } - - public function getGuard(string $name): GuardInterface - { - $guard = $this->guards[$name] ?? null; - if (null === $guard) { - throw new \InvalidArgumentException(sprintf('Guard %s not found', $name)); - } - - return $guard; - } -} diff --git a/src/Auth/AuthServiceProvider.php b/src/Auth/AuthServiceProvider.php deleted file mode 100644 index d3fccd5..0000000 --- a/src/Auth/AuthServiceProvider.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth; - -use DI\Container; -use MakiseCo\Config\ConfigRepositoryInterface; -use MakiseCo\Providers\ServiceProviderInterface; -use function array_key_exists; -use function is_array; -use function is_string; - -class AuthServiceProvider implements ServiceProviderInterface -{ - public function register(Container $container): void - { - $config = $container->get(ConfigRepositoryInterface::class); - - /* @var AuthManager $authManager */ - $authManager = $container->make(AuthManager::class); - $container->set(AuthManager::class, $authManager); - - foreach ($config->get('auth.providers', []) as $name => $params) { - if (is_array($params) && array_key_exists('class', $params)) { - $class = $params['class']; - unset($params['class']); - - $authManager->addProvider($name, $class, $params); - } elseif (is_string($params)) { - $authManager->addProvider($name, $params, []); - } else { - throw new \InvalidArgumentException("Wrong provider \"{$name}\" configuration"); - } - } - - foreach ($config->get('auth.guards', []) as $name => $guard) { - ['class' => $class, 'provider' => $provider] = $guard; - unset($guard['class'], $guard['provider']); - - $authManager->addGuard($name, $class, $provider, $guard); - } - } -} diff --git a/src/Auth/AuthenticatableInterface.php b/src/Auth/AuthenticatableInterface.php deleted file mode 100644 index 53e6c0f..0000000 --- a/src/Auth/AuthenticatableInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth; - -interface AuthenticatableInterface -{ - /** - * @return string|int|mixed - */ - public function getAuthIdentifier(); -} diff --git a/src/Auth/AuthorizableInterface.php b/src/Auth/AuthorizableInterface.php deleted file mode 100644 index d092545..0000000 --- a/src/Auth/AuthorizableInterface.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth; - -interface AuthorizableInterface -{ - /** - * @param string[] $roles - * @return bool - */ - public function hasAllRoles(array $roles): bool; - - /** - * @param string[] $permissions - * @return bool - */ - public function hasAllPermissions(array $permissions): bool; - - /** - * @param string $role - * @return bool - */ - public function hasRole(string $role): bool; - - /** - * @param string $permission - * @return bool - */ - public function hasPermission(string $permission): bool; - - /** - * @param string[] $roles - * @return bool - */ - public function hasAnyRoles(array $roles): bool; - - /** - * @param string[] $permissions - * @return bool - */ - public function hasAnyPermissions(array $permissions): bool; -} diff --git a/src/Auth/Exceptions/AccessDeniedException.php b/src/Auth/Exceptions/AccessDeniedException.php deleted file mode 100644 index a9dbcda..0000000 --- a/src/Auth/Exceptions/AccessDeniedException.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth\Exceptions; - -use MakiseCo\Http\Exceptions\HttpException; - -class AccessDeniedException extends HttpException -{ - protected array $permissions = []; - protected array $roles = []; - - public function __construct() - { - parent::__construct(403, 'access_denied'); - } - - public function getRoles(): array - { - return $this->roles; - } - - public function getPermissions(): array - { - return $this->permissions; - } - - public static function forRoles(array $roles): self - { - $self = new self(); - $self->roles = $roles; - - return $self; - } - - public static function forPermissions(array $permissions): self - { - $self = new self(); - $self->permissions = $permissions; - - return $self; - } -} diff --git a/src/Auth/Exceptions/UnauthenticatedException.php b/src/Auth/Exceptions/UnauthenticatedException.php deleted file mode 100644 index ab9aaa7..0000000 --- a/src/Auth/Exceptions/UnauthenticatedException.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth\Exceptions; - -use MakiseCo\Http\Exceptions\HttpException; - -class UnauthenticatedException extends HttpException -{ - public function __construct() - { - parent::__construct(401, 'unauthenticated'); - } -} diff --git a/src/Auth/Guard/BasicAuthGuard.php b/src/Auth/Guard/BasicAuthGuard.php deleted file mode 100644 index faad19f..0000000 --- a/src/Auth/Guard/BasicAuthGuard.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth\Guard; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\UserProviderInterface; -use Psr\Http\Message\ServerRequestInterface; -use function base64_decode; -use function explode; - -class BasicAuthGuard implements GuardInterface -{ - protected UserProviderInterface $provider; - protected string $storageUsernameKey; - protected string $storagePasswordKey; - - public function __construct( - UserProviderInterface $provider, - string $storageUsernameKey = 'email', - string $storagePasswordKey = 'password' - ) { - $this->provider = $provider; - $this->storageUsernameKey = $storageUsernameKey; - $this->storagePasswordKey = $storagePasswordKey; - } - - public function authenticate(ServerRequestInterface $request): ?AuthenticatableInterface - { - $token = $this->getBasicAuthString($request); - if (null === $token) { - return null; - } - - [$username, $password] = explode(':', $token); - - return $this->provider->retrieveByCredentials([ - $this->storageUsernameKey => $username, - $this->storagePasswordKey => $password, - ]); - } - - protected function getBasicAuthString(ServerRequestInterface $request): ?string - { - $token = $request->getHeader('Authorization')[0] ?? null; - if (null === $token) { - return null; - } - - $base64 = base64_decode($token); - if (false === $base64) { - return null; - } - - return $base64; - } -} diff --git a/src/Auth/Guard/BearerTokenGuard.php b/src/Auth/Guard/BearerTokenGuard.php deleted file mode 100644 index 1349402..0000000 --- a/src/Auth/Guard/BearerTokenGuard.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth\Guard; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\UserProviderInterface; -use Psr\Http\Message\ServerRequestInterface; -use function strpos; -use function substr; - -class BearerTokenGuard implements GuardInterface -{ - protected UserProviderInterface $provider; - protected string $storageKey; - - public function __construct( - UserProviderInterface $provider, - string $storageKey = 'bearer_token' - ) { - $this->provider = $provider; - $this->storageKey = $storageKey; - } - - public function authenticate(ServerRequestInterface $request): ?AuthenticatableInterface - { - $token = $this->getBearerToken($request); - if (null === $token) { - return null; - } - - return $this->provider->retrieveByCredentials([ - $this->storageKey => $token, - ]); - } - - protected function getBearerToken(ServerRequestInterface $request): ?string - { - $token = $request->getHeader('Authorization')[0] ?? null; - if (null === $token) { - return null; - } - - if (0 !== strpos($token, 'Bearer ')) { - return null; - } - - return substr($token, 7); - } -} diff --git a/src/Auth/Guard/GuardInterface.php b/src/Auth/Guard/GuardInterface.php deleted file mode 100644 index 79d3c37..0000000 --- a/src/Auth/Guard/GuardInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth\Guard; - -use MakiseCo\Auth\AuthenticatableInterface; -use Psr\Http\Message\ServerRequestInterface; - -interface GuardInterface -{ - public function authenticate(ServerRequestInterface $request): ?AuthenticatableInterface; -} diff --git a/src/Auth/Http/Middleware/AuthenticationMiddleware.php b/src/Auth/Http/Middleware/AuthenticationMiddleware.php deleted file mode 100644 index f923fcc..0000000 --- a/src/Auth/Http/Middleware/AuthenticationMiddleware.php +++ /dev/null @@ -1,83 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth\Http\Middleware; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\AuthManager; -use MakiseCo\Auth\AuthorizableInterface; -use MakiseCo\Auth\Exceptions\UnauthenticatedException; -use MakiseCo\Auth\Guard\GuardInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -use function is_array; -use function MakiseCo\Http\Router\Helper\getRouteAttribute; - -class AuthenticationMiddleware implements MiddlewareInterface -{ - protected AuthManager $authManager; - - public function __construct(AuthManager $authManager) - { - $this->authManager = $authManager; - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $guard = getRouteAttribute($request, GuardInterface::class, null); - if (null === $guard) { - throw new \LogicException(sprintf('Missing "%s" attribute', GuardInterface::class)); - } - - $user = $this->tryAuthenticate($request, $guard); - if (null === $user) { - throw new UnauthenticatedException(); - } - - // Perhaps it should be replaced to the mutable implementation, because memory allocation is slow - $request = $request->withAttribute(AuthenticatableInterface::class, $user); - - if ($user instanceof AuthorizableInterface) { - $request = $request->withAttribute(AuthorizableInterface::class, $user); - } - - return $handler->handle($request); - } - - /** - * @param ServerRequestInterface $request - * @param string|string[] $guard - list of guard names to authenticate - * @return AuthenticatableInterface|null - */ - protected function tryAuthenticate(ServerRequestInterface $request, $guard): ?AuthenticatableInterface - { - if (is_array($guard)) { - foreach ($guard as $item) { - $user = $this->authenticate($request, $item); - if (null !== $user) { - return $user; - } - } - } - - return $this->authenticate($request, $guard); - } - - protected function authenticate(ServerRequestInterface $request, string $guard): ?AuthenticatableInterface - { - return $this - ->authManager - ->getGuard($guard) - ->authenticate($request); - } -} diff --git a/src/Auth/Http/Middleware/AuthorizationMiddleware.php b/src/Auth/Http/Middleware/AuthorizationMiddleware.php deleted file mode 100644 index eb79433..0000000 --- a/src/Auth/Http/Middleware/AuthorizationMiddleware.php +++ /dev/null @@ -1,115 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth\Http\Middleware; - -use MakiseCo\Auth\AuthorizableInterface; -use MakiseCo\Auth\Exceptions\AccessDeniedException; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -use function MakiseCo\Http\Router\Helper\getRouteAttribute; - -class AuthorizationMiddleware implements MiddlewareInterface -{ - /** - * Request attribute that holds permissions list - */ - public const PERMISSIONS = 'permissions'; - - /** - * Request attribute that holds roles list - */ - public const ROLES = 'roles'; - - /** - * Request attribute that holds roles/permissions match mode - */ - public const MODE = 'auth_mode'; - - /** - * Authorizable must have all roles/permissions - */ - public const MODE_ALL = 'all'; - - /** - * Authorizable must have any of roles/permissions - */ - public const MODE_ANY = 'any'; - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $authorizable = $request->getAttribute(AuthorizableInterface::class, null); - if (!$authorizable instanceof AuthorizableInterface) { - throw new AccessDeniedException(); - } - - $mode = getRouteAttribute($request, self::MODE, self::MODE_ALL); - - $this->authorizePermissions($request, $authorizable, $mode); - $this->authorizeRoles($request, $authorizable, $mode); - - return $handler->handle($request); - } - - protected function authorizePermissions( - ServerRequestInterface $request, - AuthorizableInterface $authorizable, - string $mode - ): void { - $permissions = (array)getRouteAttribute($request, self::PERMISSIONS, []); - if ([] === $permissions) { - return; - } - - $authRes = false; - - switch ($mode) { - case self::MODE_ALL: - $authRes = $authorizable->hasAllPermissions($permissions); - break; - case self::MODE_ANY: - $authRes = $authorizable->hasAnyPermissions($permissions); - break; - } - - if (!$authRes) { - throw AccessDeniedException::forPermissions($permissions); - } - } - - protected function authorizeRoles( - ServerRequestInterface $request, - AuthorizableInterface $authorizable, - string $mode - ): void { - $roles = (array)getRouteAttribute($request, self::ROLES, []); - if ([] === $roles) { - return; - } - - $authRes = false; - - switch ($mode) { - case self::MODE_ALL: - $authRes = $authorizable->hasAllRoles($roles); - break; - case self::MODE_ANY: - $authRes = $authorizable->hasAnyRoles($roles); - break; - } - - if (!$authRes) { - throw AccessDeniedException::forRoles($roles); - } - } -} diff --git a/src/Auth/UserProviderInterface.php b/src/Auth/UserProviderInterface.php deleted file mode 100644 index 13be5d0..0000000 --- a/src/Auth/UserProviderInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Auth; - -interface UserProviderInterface -{ - /** - * @param string|int|mixed $id - * @return AuthenticatableInterface|null - */ - public function retrieveById($id): ?AuthenticatableInterface; - - /** - * @param array $credentials - * @return AuthenticatableInterface|null - */ - public function retrieveByCredentials(array $credentials): ?AuthenticatableInterface; -} diff --git a/src/Bootstrapper.php b/src/Bootstrapper.php new file mode 100644 index 0000000..3c688a9 --- /dev/null +++ b/src/Bootstrapper.php @@ -0,0 +1,116 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo; + +use Closure; +use InvalidArgumentException; +use Throwable; + +use function array_key_exists; + +class Bootstrapper +{ + /** + * @var Closure[]|array + */ + private array $inits = []; + + /** + * @var Closure[]|array + */ + private array $stops = []; + + /** + * @param string $service Service name (class-string or custom name) + * @param Closure $init Service initialize callback + * @param Closure $stop Service stop callback + * @param bool $overwrite Allow callback overwriting? + */ + public function addService(string $service, Closure $init, Closure $stop, bool $overwrite = false): void + { + if (!$overwrite && array_key_exists($service, $this->inits)) { + throw new InvalidArgumentException("Service {$service} already defined in inits"); + } + + if (!$overwrite && array_key_exists($service, $this->stops)) { + throw new InvalidArgumentException("Service {$service} already defined in stops"); + } + + $this->inits[$service] = $init; + $this->stops[$service] = $stop; + } + + /** + * @param string[]|null[] $inits optional, services list/order that should be initialized + * + * @throws Throwable + */ + public function init(array $inits = []): void + { + if ($inits === [null]) { + return; + } + + if ($inits === []) { + // initialize all services + foreach ($this->inits as $init) { + $init(); + } + + return; + } + + foreach ($inits as $service) { + if (null === $service) { + throw new InvalidArgumentException('Service name cannot be null'); + } + + if (array_key_exists($service, $this->inits)) { + throw new InvalidArgumentException("Service {$service} does not exists in inits map"); + } + + $this->inits[$service](); + } + } + + /** + * @param string[]|null[] $stops optional, services list/order that should be stopped + * + * @throws Throwable + */ + public function stop(array $stops = []): void + { + if ($stops === [null]) { + return; + } + + if ($stops === []) { + // initialize all services + foreach ($this->stops as $stop) { + $stop(); + } + + return; + } + + foreach ($stops as $service) { + if (null === $service) { + throw new InvalidArgumentException('Service name cannot be null'); + } + + if (array_key_exists($service, $this->stops)) { + throw new InvalidArgumentException("Service {$service} does not exists in stops map"); + } + + $this->stops[$service](); + } + } +} diff --git a/src/Console/Application.php b/src/Console/Application.php new file mode 100644 index 0000000..b59b8c0 --- /dev/null +++ b/src/Console/Application.php @@ -0,0 +1,60 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\Console; + +use MakiseCo\ApplicationInterface; +use MakiseCo\Console\Commands\AbstractCommand; +use Symfony\Component\Console\Application as SymfonyApplication; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputOption; + +class Application extends SymfonyApplication +{ + protected ApplicationInterface $makise; + + public function __construct(ApplicationInterface $makiseApp, string $name = 'UNKNOWN', string $version = 'UNKNOWN') + { + parent::__construct($name, $version); + + $this->makise = $makiseApp; + } + + public function add(Command $command): ?Command + { + $cmd = parent::add($command); + if ($cmd instanceof AbstractCommand) { + $cmd->setMakise($this->makise); + } + + return $cmd; + } + + protected function getDefaultInputDefinition(): InputDefinition + { + $definition = parent::getDefaultInputDefinition(); + $definition->addOption($this->getEnvironmentOption()); + + return $definition; + } + + /** + * Get the global environment option for the definition. + * + * @return InputOption + */ + protected function getEnvironmentOption(): InputOption + { + $message = 'The environment the command should run under'; + + return new InputOption('--env', null, InputOption::VALUE_OPTIONAL, $message); + } +} diff --git a/src/Console/Commands/AbstractCommand.php b/src/Console/Commands/AbstractCommand.php index d3560ec..0c16618 100644 --- a/src/Console/Commands/AbstractCommand.php +++ b/src/Console/Commands/AbstractCommand.php @@ -21,19 +21,12 @@ abstract class AbstractCommand extends SymfonyCommand { use CommandTrait; - protected ApplicationInterface $app; - protected string $name = ''; protected string $description = ''; protected array $arguments = []; protected array $options = []; - public function __construct(ApplicationInterface $app) - { - $this->app = $app; - - parent::__construct(null); - } + protected ApplicationInterface $makise; protected function configure(): void { @@ -58,14 +51,30 @@ protected function defineOptions(): void } } + public function setMakise(ApplicationInterface $makise): void + { + $this->makise = $makise; + } + final public function execute(InputInterface $input, OutputInterface $output): int { $this->input = $input; $this->output = $output; - $container = $this->app->getContainer(); + $container = $this->makise->getContainer(); $closure = Closure::fromCallable([$this, 'handle']); return $container->call($closure) ?? 0; } + + /** + * Returns services list that should be initialized before command starts and stopped after command finished + * + * @return string[]|null[] empty list means that the all services should be initialized/stopped, + * [null] means that the no services will be initialized/stopped + */ + public function getServices(): array + { + return []; + } } diff --git a/src/Console/Commands/DumpConfigCommand.php b/src/Console/Commands/DumpConfigCommand.php index 1eaddcb..344b2af 100644 --- a/src/Console/Commands/DumpConfigCommand.php +++ b/src/Console/Commands/DumpConfigCommand.php @@ -32,4 +32,12 @@ public function handle(ConfigRepositoryInterface $config): void dump($config->get($path)); } } + + /** + * @inheritDoc + */ + public function getServices(): array + { + return [null]; + } } diff --git a/src/Console/Commands/DumpEnvCommand.php b/src/Console/Commands/DumpEnvCommand.php index 618f5e1..a30de06 100644 --- a/src/Console/Commands/DumpEnvCommand.php +++ b/src/Console/Commands/DumpEnvCommand.php @@ -21,4 +21,12 @@ public function handle(): void $this->writeln("{$key}={$value}"); } } + + /** + * @inheritDoc + */ + public function getServices(): array + { + return [null]; + } } diff --git a/src/Console/Commands/MakiseCommand.php b/src/Console/Commands/MakiseCommand.php index 5ac61f1..e2a84d1 100644 --- a/src/Console/Commands/MakiseCommand.php +++ b/src/Console/Commands/MakiseCommand.php @@ -28,6 +28,14 @@ public function handle(): void $this->info($phrase); } + /** + * @inheritDoc + */ + public function getServices(): array + { + return [null]; + } + protected const PHRASES = [ 'Every brilliant day should be lived for those who passed away.', 'Everyone is watching someone other than themselves, someone important to them...', diff --git a/src/Console/Commands/RoutesDumpCommand.php b/src/Console/Commands/RoutesDumpCommand.php deleted file mode 100644 index 66d6f7f..0000000 --- a/src/Console/Commands/RoutesDumpCommand.php +++ /dev/null @@ -1,167 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Console\Commands; - -use Closure; -use MakiseCo\Http\Router\HandlerResolver\RouteHandlerPromise; -use MakiseCo\Http\Router\RouteCollectorInterface; -use MakiseCo\Http\Router\RouteInterface; -use ReflectionClass; -use ReflectionException; -use ReflectionFunction; -use ReflectionMethod; -use Symfony\Component\Console\Helper\Table; - -use function class_exists; -use function implode; -use function is_array; -use function is_callable; -use function is_string; -use function str_replace; - -class RoutesDumpCommand extends AbstractCommand -{ - protected string $name = 'routes:dump'; - protected string $description = 'Prints all app HTTP routes'; - - public function handle(RouteCollectorInterface $routeCollector): void - { - $table = new Table($this->output); - $table->setHeaders(['Method', 'Path', 'Name', 'Handler']); - $table->setColumnMaxWidth(0, 30); - $table->setColumnMaxWidth(1, 60); - $table->setColumnMaxWidth(2, 60); - $table->setColumnMaxWidth(3, 60); - - $cnt = 0; - - foreach ($routeCollector->getRoutes() as $route) { - $table->setRow( - $cnt, - $this->getRouteInfo($route) - ); - - $cnt++; - } - - $table->render(); - } - - protected function getRouteInfo(RouteInterface $route): array - { - return [ - implode(', ', $route->getMethods()), - $route->getPath(), - $route->getPath() !== ($name = $route->getName()) ? $name : '', - $this->getRouteHandlerInfo($route->getHandler()), - ]; - } - - protected function getRouteHandlerInfo(Closure $closure): string - { - $reflection = new ReflectionFunction($closure); - if (($promise = $reflection->getClosureThis()) instanceof RouteHandlerPromise) { - /** @var RouteHandlerPromise $promise */ - - $handler = $promise->getRouteHandler(); - if ($handler instanceof Closure) { - return $this->getClosureInfo($handler); - } - - return $this->getPromisedHandlerInfo($handler); - } - - return $this->getClosureInfo($closure); - } - - protected function getPromisedHandlerInfo($handler): string - { - if (is_string($handler)) { - if (0 !== strpos($handler, '@')) { - $handler = explode('@', $handler, 2); - } elseif (strpos($handler, '::') !== false) { - $handler = explode('::', $handler, 2); - } - } - - if (is_callable($handler)) { - // TODO with PHP 8 that should not be necessary to check this anymore - if (!$this->isStaticCallToNonStaticMethod($handler)) { - return $this->getClosureInfo(Closure::fromCallable($handler)); - } - } - - // The callable is an array whose first item is a container entry name - // e.g. ['some-container-entry', 'methodToCall'] - if (is_array($handler) && is_string($handler[0])) { - if (!class_exists($handler[0])) { - return "Class \"{$handler[0]}\" not found"; - } - - $refClass = new ReflectionClass($handler[0]); - try { - $refClass->getMethod($handler[1]); - } catch (ReflectionException $e) { - return "Method \"{$handler[1]}\" of class \"{$handler[0]}\" not found"; - } - - return $refClass->getName() . '::' . $handler[1]; - } - - return 'Bad route handler'; - } - - protected function getClosureInfo(Closure $closure): string - { - $reflection = new ReflectionFunction($closure); - - $class = $reflection->getClosureScopeClass(); - $name = $reflection->getName(); - - if (null === $class) { - $handler = $reflection->getName(); - } elseif ($name === '{closure}') { - // remove app path - $name = str_replace( - $this->app->getAppDir(), - '', - $reflection->getFileName() - ); - - $name .= ':' . $reflection->getStartLine(); - - $handler = $name; - } else { - $handler = "{$class->getName()}::{$name}"; - } - - return $handler; - } - - /** - * Check if the callable represents a static call to a non-static method. - * - * @param mixed $callable - * - * @throws ReflectionException - */ - private function isStaticCallToNonStaticMethod($callable): bool - { - if (is_array($callable) && is_string($callable[0])) { - [$class, $method] = $callable; - $reflection = new ReflectionMethod($class, $method); - - return !$reflection->isStatic(); - } - - return false; - } -} diff --git a/src/Console/Commands/StartHttpSeverCommand.php b/src/Console/Commands/StartHttpSeverCommand.php deleted file mode 100644 index a0c8432..0000000 --- a/src/Console/Commands/StartHttpSeverCommand.php +++ /dev/null @@ -1,74 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Console\Commands; - -use MakiseCo\Config\ConfigRepositoryInterface; -use MakiseCo\Http\Events\ServerStarted; -use MakiseCo\Http\HttpServer; -use Psr\Log\LoggerInterface; -use Swoole\Coroutine; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\EventDispatcher\EventDispatcher; - -class StartHttpSeverCommand extends AbstractCommand -{ - protected function configure(): void - { - $this->setName('http:start'); - $this->setDescription('Starts HTTP server'); - - $config = $this->app->getContainer()->get(ConfigRepositoryInterface::class); - - $this->addOption( - 'host', - null, - InputOption::VALUE_OPTIONAL, - 'Server host', - $config->get('http.host', '127.0.0.1') - ); - - $this->addOption( - 'port', - 'p', - InputOption::VALUE_OPTIONAL, - 'Server port', - $config->get('http.port', 10228) - ); - } - - public function handle(EventDispatcher $dispatcher, LoggerInterface $logger, HttpServer $server): int - { - if (Coroutine::getCid() > 0) { - $this->error("Please run {$this->getName()} command with \"--no-coroutine\" flag"); - - return 1; - } - - $port = $this->getOption('port'); - if (null !== $port) { - $port = (int)$port; - } - $host = $this->getOption('host'); - - $dispatcher->addListener( - ServerStarted::class, - static function () use ($host, $port, $logger) { - $logger->info('App is started', ['host' => $host, 'port' => $port]); - } - ); - - $server->start($host, $port); - - $logger->info('App is stopped'); - - return 0; - } -} diff --git a/src/Console/ConsoleServiceProvider.php b/src/Console/ConsoleServiceProvider.php index d0376b5..5bff320 100644 --- a/src/Console/ConsoleServiceProvider.php +++ b/src/Console/ConsoleServiceProvider.php @@ -11,29 +11,67 @@ namespace MakiseCo\Console; use DI\Container; -use MakiseCo\Config\ConfigRepositoryInterface; +use MakiseCo\ApplicationInterface; +use MakiseCo\Bootstrapper; +use MakiseCo\Config\ConfigRepositoryInterface as Config; +use MakiseCo\Console\Commands\AbstractCommand; use MakiseCo\Providers\ServiceProviderInterface; use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\Console\ConsoleEvents; +use Symfony\Component\Console\Event\ConsoleCommandEvent; +use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\EventDispatcher\EventDispatcher; class ConsoleServiceProvider implements ServiceProviderInterface { public function register(Container $container): void { - $container->set(ConsoleApplication::class, static function (ConfigRepositoryInterface $config) use ($container) { + $container->set(ConsoleApplication::class, static function (Container $container, Config $config) { + $eventDispatcher = $container->get(EventDispatcher::class); + $bootstrapper = $container->get(Bootstrapper::class); + + // add error listener $errorListener = new ErrorListener; - $callback = \Closure::fromCallable([$errorListener, 'onConsoleError']); - $eventDispatcher = $container->get(EventDispatcher::class); - $eventDispatcher->addListener(ConsoleEvents::ERROR, $callback); + $eventDispatcher->addListener( + ConsoleEvents::ERROR, + \Closure::fromCallable([$errorListener, 'onConsoleError']) + ); + + // initialize command dependencies + $eventDispatcher->addListener( + ConsoleEvents::COMMAND, + static function (ConsoleCommandEvent $event) use ($bootstrapper) { + $cmd = $event->getCommand(); + if ($cmd instanceof AbstractCommand) { + $bootstrapper->init($cmd->getServices()); + } + } + ); + + // stop command dependencies + $eventDispatcher->addListener( + ConsoleEvents::TERMINATE, + static function (ConsoleTerminateEvent $event) use ($bootstrapper) { + $cmd = $event->getCommand(); + if ($cmd instanceof AbstractCommand) { + $bootstrapper->stop($cmd->getServices()); + } + } + ); - $console = new ConsoleApplication($config->get('app.name')); + $console = new Application( + $container->get(ApplicationInterface::class), + $config->get('app.name', 'Makise-Co'), + $container->get(ApplicationInterface::class)->getVersion(), + ); $console->setAutoExit(false); $console->setDispatcher($eventDispatcher); return $console; }); + // alias + $container->set(Application::class, \DI\get(ConsoleApplication::class)); } } diff --git a/src/Disposable/DisposableInterface.php b/src/Disposable/DisposableInterface.php deleted file mode 100644 index a2a215c..0000000 --- a/src/Disposable/DisposableInterface.php +++ /dev/null @@ -1,23 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Disposable; - -/** - * Disposable is inspired from C# - * It means that objects which are implementing DisposableInterface should be freed - * - * Objects which are implementing DisposableInterface should be added to DisposableContainer - * If app needs to stop, then all disposable instances, e.g. connection pools, will be disposed - */ -interface DisposableInterface -{ - public function dispose(): void; -} diff --git a/src/Env/Env.php b/src/Env/Env.php index f31ca4e..59ed0d0 100644 --- a/src/Env/Env.php +++ b/src/Env/Env.php @@ -33,7 +33,6 @@ public static function setRepository(RepositoryInterface $repository): void */ public static function get(string $key, $default = null) { - /** @noinspection PhpParamsInspection */ return Option::fromValue(static::$repository->get($key)) ->map(function ($value) { switch (strtolower($value)) { diff --git a/src/Providers/EventDispatcherServiceProvider.php b/src/Event/EventDispatcherServiceProvider.php similarity index 92% rename from src/Providers/EventDispatcherServiceProvider.php rename to src/Event/EventDispatcherServiceProvider.php index 55af1eb..ddf7206 100644 --- a/src/Providers/EventDispatcherServiceProvider.php +++ b/src/Event/EventDispatcherServiceProvider.php @@ -8,9 +8,10 @@ declare(strict_types=1); -namespace MakiseCo\Providers; +namespace MakiseCo\Event; use DI\Container; +use MakiseCo\Providers\ServiceProviderInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcher; diff --git a/src/Http/Events/ManagerStarted.php b/src/Http/Events/ManagerStarted.php deleted file mode 100644 index 08b042f..0000000 --- a/src/Http/Events/ManagerStarted.php +++ /dev/null @@ -1,16 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Events; - -class ManagerStarted -{ - -} diff --git a/src/Http/Events/ServerShutdown.php b/src/Http/Events/ServerShutdown.php deleted file mode 100644 index fa4dc0c..0000000 --- a/src/Http/Events/ServerShutdown.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Events; - -class ServerShutdown -{ -} diff --git a/src/Http/Events/ServerStarted.php b/src/Http/Events/ServerStarted.php deleted file mode 100644 index 6f1d8bd..0000000 --- a/src/Http/Events/ServerStarted.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Events; - -class ServerStarted -{ - public function __construct() - { - } -} diff --git a/src/Http/Events/WorkerExit.php b/src/Http/Events/WorkerExit.php deleted file mode 100644 index 40afdeb..0000000 --- a/src/Http/Events/WorkerExit.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Events; - -class WorkerExit -{ - protected int $workerId; - - public function __construct(int $workerId) - { - $this->workerId = $workerId; - } - - public function getWorkerId(): int - { - return $this->workerId; - } -} diff --git a/src/Http/Events/WorkerStarted.php b/src/Http/Events/WorkerStarted.php deleted file mode 100644 index df9ac0f..0000000 --- a/src/Http/Events/WorkerStarted.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Events; - -class WorkerStarted -{ - protected int $workerId; - - public function __construct(int $workerId) - { - $this->workerId = $workerId; - } - - public function getWorkerId(): int - { - return $this->workerId; - } -} diff --git a/src/Http/Events/WorkerStopped.php b/src/Http/Events/WorkerStopped.php deleted file mode 100644 index 7b5a5d3..0000000 --- a/src/Http/Events/WorkerStopped.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Events; - -class WorkerStopped -{ - protected int $workerId; - - public function __construct(int $workerId) - { - $this->workerId = $workerId; - } - - public function getWorkerId(): int - { - return $this->workerId; - } -} diff --git a/src/Http/Exceptions/ExceptionHandler.php b/src/Http/Exceptions/ExceptionHandler.php deleted file mode 100644 index a2c7211..0000000 --- a/src/Http/Exceptions/ExceptionHandler.php +++ /dev/null @@ -1,159 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Exceptions; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Config\ConfigRepositoryInterface; -use MakiseCo\Http\Router\Exception\MethodNotAllowedException; -use MakiseCo\Http\Router\Exception\RouteNotFoundException; -use MakiseCo\Middleware\ErrorHandlerInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; -use Throwable; - -use function get_class; - -abstract class ExceptionHandler implements ErrorHandlerInterface -{ - protected ConfigRepositoryInterface $config; - protected LoggerInterface $logger; - - protected array $doNotLog = [ - HttpExceptionInterface::class, - RouteNotFoundException::class, - MethodNotAllowedException::class, - ]; - - public function __construct(ConfigRepositoryInterface $config, LoggerInterface $logger) - { - $this->config = $config; - $this->logger = $logger; - } - - public function handle(Throwable $e, ServerRequestInterface $request): ResponseInterface - { - if ($this->shouldReport($e)) { - $this->log($request, $e); - } - - return $this->render($request, $e); - } - - protected function log(ServerRequestInterface $request, Throwable $e): void - { - $exceptionInfo = $this->getExceptionInfo($e); - - $message = $exceptionInfo['message'] ?? 'Error'; - unset($exceptionInfo['message']); - - $extra = [ - 'uri' => $request->getUri()->__toString(), - 'method' => $request->getMethod(), - ]; - - $userId = $this->getUserIdFromRequest($request); - if (null !== $userId) { - $extra['userId'] = $userId; - } - - $exceptionInfo['extra'] = $extra; - - $this->logger->error($message, $exceptionInfo); - } - - protected function render(ServerRequestInterface $request, Throwable $e): ResponseInterface - { - if ($e instanceof HttpExceptionInterface) { - return $this->renderHttpException($request, $e); - } - - if ($e instanceof RouteNotFoundException) { - return $this->renderRouteNotFound($request, $e); - } - - if ($e instanceof MethodNotAllowedException) { - return $this->renderMethodNotAllowed($request, $e); - } - - return $this->renderThrowable($request, $e); - } - - abstract protected function renderThrowable(ServerRequestInterface $request, Throwable $e): ResponseInterface; - - abstract protected function renderHttpException( - ServerRequestInterface $request, - HttpExceptionInterface $e - ): ResponseInterface; - - abstract protected function renderRouteNotFound( - ServerRequestInterface $request, - RouteNotFoundException $e - ): ResponseInterface; - - abstract protected function renderMethodNotAllowed( - ServerRequestInterface $request, - MethodNotAllowedException $e - ): ResponseInterface; - - protected function shouldReport(Throwable $e): bool - { - foreach ($this->doNotLog as $ignoredException) { - if ($e instanceof $ignoredException) { - return false; - } - } - - return true; - } - - /** - * Convert the given exception to an array. - * - * @param Throwable $e - * @return array - */ - protected function convertExceptionToArray(Throwable $e): array - { - if (!$this->config->get('app.debug')) { - return [ - 'message' => 'Server Error' - ]; - } - - return $this->getExceptionInfo($e); - } - - protected function getExceptionInfo(Throwable $e): array - { - return [ - 'message' => $e->getMessage(), - 'exception' => get_class($e), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTrace(), - ]; - } - - /** - * @param ServerRequestInterface $request - * @return string|int|null - */ - protected function getUserIdFromRequest(ServerRequestInterface $request) - { - $user = $request->getAttribute(AuthenticatableInterface::class, null); - if ($user instanceof AuthenticatableInterface) { - return $user->getAuthIdentifier(); - } - - return null; - } -} diff --git a/src/Http/Exceptions/HttpException.php b/src/Http/Exceptions/HttpException.php deleted file mode 100644 index 64e1305..0000000 --- a/src/Http/Exceptions/HttpException.php +++ /dev/null @@ -1,55 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Exceptions; - -/** - * HttpException. - * - * @author Kris Wallsmith - */ -class HttpException extends \RuntimeException implements HttpExceptionInterface -{ - private int $statusCode; - private array $headers; - - public function __construct( - int $statusCode, - string $message = null, - \Throwable $previous = null, - array $headers = [], - ?int $code = 0 - ) { - $this->statusCode = $statusCode; - $this->headers = $headers; - - parent::__construct($message, $code, $previous); - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - - public function getHeaders(): array - { - return $this->headers; - } - - /** - * Set response headers. - * - * @param array $headers Response headers - */ - public function setHeaders(array $headers): void - { - $this->headers = $headers; - } -} diff --git a/src/Http/Exceptions/HttpExceptionInterface.php b/src/Http/Exceptions/HttpExceptionInterface.php deleted file mode 100644 index 1eb0a21..0000000 --- a/src/Http/Exceptions/HttpExceptionInterface.php +++ /dev/null @@ -1,33 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Exceptions; - -/** - * Interface for HTTP error exceptions. - * - * @author Kris Wallsmith - */ -interface HttpExceptionInterface extends \Throwable -{ - /** - * Returns the status code. - * - * @return int An HTTP response status code - */ - public function getStatusCode(): int; - - /** - * Returns response headers. - * - * @return array Response headers - */ - public function getHeaders(): array; -} diff --git a/src/Http/Exceptions/JsonExceptionHandler.php b/src/Http/Exceptions/JsonExceptionHandler.php deleted file mode 100644 index 189c6d1..0000000 --- a/src/Http/Exceptions/JsonExceptionHandler.php +++ /dev/null @@ -1,84 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Exceptions; - -use Laminas\Diactoros\Response; -use MakiseCo\Http\Router\Exception\MethodNotAllowedException; -use MakiseCo\Http\Router\Exception\RouteNotFoundException; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Throwable; - -use const JSON_PRETTY_PRINT; -use const JSON_UNESCAPED_UNICODE; -use const JSON_UNESCAPED_SLASHES; -use const JSON_UNESCAPED_LINE_TERMINATORS; - -class JsonExceptionHandler extends ExceptionHandler -{ - protected function renderHttpException( - ServerRequestInterface $request, - HttpExceptionInterface $e - ): ResponseInterface { - $statusCode = $e->getStatusCode(); - $headers = $e->getHeaders(); - - return new Response\JsonResponse( - ['message' => $e->getMessage()], - $statusCode, - $headers, - $this->getJsonOptions() - ); - } - - protected function renderThrowable(ServerRequestInterface $request, Throwable $e): ResponseInterface - { - return new Response\JsonResponse( - $this->convertExceptionToArray($e), - 500, - [], - $this->getJsonOptions() - ); - } - - protected function renderRouteNotFound( - ServerRequestInterface $request, - RouteNotFoundException $e - ): ResponseInterface { - return new Response\JsonResponse( - ['message' => 'Not Found'], - 404, - [], - $this->getJsonOptions() - ); - } - - protected function renderMethodNotAllowed( - ServerRequestInterface $request, - MethodNotAllowedException $e - ): ResponseInterface { - return new Response\JsonResponse( - ['message' => 'Method Not Allowed'], - 405, - ['Allow' => $e->getAllowedMethods()], - $this->getJsonOptions() - ); - } - - protected function getJsonOptions(): int - { - $defaultOptions = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS; - - return $this->config->get('app.debug') ? - $defaultOptions | JSON_PRETTY_PRINT : - $defaultOptions; - } -} diff --git a/src/Http/FakeStream.php b/src/Http/FakeStream.php deleted file mode 100644 index b222648..0000000 --- a/src/Http/FakeStream.php +++ /dev/null @@ -1,243 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http; - -use InvalidArgumentException; -use Psr\Http\Message\StreamInterface; -use RuntimeException; - -use function strlen; -use function substr; - -use const SEEK_CUR; -use const SEEK_END; -use const SEEK_SET; - -/** - * FakeStream is used to provide more PSR compatibility - */ -class FakeStream implements StreamInterface -{ - /** - * Memoized body content, as pulled via SwooleHttpRequest::rawContent(). - * - * @var string - */ - private string $body; - - /** - * Length of the request body content. - * - * @var int - */ - private int $bodySize; - - /** - * Index to which we have seek'd or read within the request body. - * - * @var int - */ - private int $index = 0; - - public function __construct(string $body) - { - $this->body = $body; - $this->bodySize = strlen($this->body); - } - - /** - * {@inheritdoc} - */ - public function getContents(): string - { - // If we're at the end of the string, return an empty string. - if ($this->eof()) { - return ''; - } - - $size = $this->getSize(); - // If we have not content, return an empty string - if ($size === 0) { - return ''; - } - - // Memoize index so we can use it to get a substring later, - // if required. - $index = $this->index; - - // Set the internal index to the end of the string - $this->index = $size; - - if ($index) { - // Per PSR-7 spec, if we have seeked or read to a given position in - // the string, we should only return the contents from that position - // forward. - return substr($this->body, $index); - } - - // If we're at the start of the content, return all of it. - return $this->body; - } - - /** - * {@inheritdoc} - */ - public function __toString(): string - { - return $this->body; - } - - public function getSize(): int - { - return $this->bodySize; - } - - /** - * {@inheritdoc} - */ - public function tell(): int - { - return $this->index; - } - - /** - * {@inheritdoc} - */ - public function eof(): bool - { - return $this->index >= $this->getSize(); - } - - /** - * {@inheritdoc} - */ - public function isReadable(): bool - { - return true; - } - - /** - * {@inheritdoc} - */ - public function read($length): string - { - $result = substr($this->body, $this->index, $length); - - // Reset index based on legnth; should not be > EOF position. - $size = $this->getSize(); - $this->index = $this->index + $length >= $size - ? $size - : $this->index + $length; - - return $result; - } - - /** - * {@inheritdoc} - */ - public function isSeekable(): bool - { - return true; - } - - /** - * {@inheritdoc} - */ - public function seek($offset, $whence = SEEK_SET): void - { - $size = $this->getSize(); - switch ($whence) { - case SEEK_SET: - if ($offset >= $size) { - throw new RuntimeException( - 'Offset cannot be longer than content size' - ); - } - $this->index = $offset; - break; - case SEEK_CUR: - if ($offset + $this->index >= $size) { - throw new RuntimeException( - 'Offset + current position cannot be longer than content size when using SEEK_CUR' - ); - } - $this->index += $offset; - break; - case SEEK_END: - if ($offset + $size >= $size) { - throw new RuntimeException( - 'Offset must be a negative number to be under the content size when using SEEK_END' - ); - } - $this->index = $size + $offset; - break; - default: - throw new InvalidArgumentException( - 'Invalid $whence argument provided; must be one of SEEK_CUR,' - . 'SEEK_END, or SEEK_SET' - ); - } - } - - /** - * {@inheritdoc} - */ - public function rewind(): void - { - $this->index = 0; - } - - /** - * {@inheritdoc} - */ - public function isWritable(): bool - { - return false; - } - - /** - * {@inheritdoc} - */ - public function write($string): int - { - throw new RuntimeException('Stream is not writable'); - } - - /** - * {@inheritdoc} - */ - public function getMetadata($key = null): ?array - { - return $key ? null : []; - } - - /** - * {@inheritdoc} - */ - public function detach() - { - $body = $this->body; - $this->body = ''; - - $stream = \fopen('php://memory', 'wb+'); - \fwrite($stream, $body, $this->bodySize); - \rewind($stream); - - return $stream; - } - - /** - * {@inheritdoc} - */ - public function close(): void - { - } -} diff --git a/src/Http/HttpServer.php b/src/Http/HttpServer.php deleted file mode 100644 index 71db72b..0000000 --- a/src/Http/HttpServer.php +++ /dev/null @@ -1,158 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http; - -use Closure; -use Psr\Http\Server\RequestHandlerInterface; -use Swoole\Http\Request; -use Swoole\Http\Response; -use Swoole\Http\Server as SwooleServer; -use Symfony\Component\EventDispatcher\EventDispatcher; - -use function array_merge; -use function swoole_set_process_name; - -class HttpServer -{ - public const MODE_MAIN = 'master'; - public const MODE_MANAGER = 'manager'; - public const MODE_WORKER = 'worker'; - - protected string $mode = self::MODE_MAIN; - - protected SwooleServer $server; - protected EventDispatcher $eventDispatcher; - protected Swoole\SwoolePsrRequestFactoryInterface $requestFactory; - protected Swoole\SwooleEmitter $emitter; - protected RequestHandlerInterface $requestHandler; - - protected array $swooleConfig; - protected string $appName; - - public function __construct( - EventDispatcher $eventDispatcher, - Swoole\SwoolePsrRequestFactoryInterface $requestFactory, - Swoole\SwooleEmitter $emitter, - RequestHandlerInterface $requestHandler, - array $config, - string $appName - ) { - $this->eventDispatcher = $eventDispatcher; - $this->requestFactory = $requestFactory; - $this->emitter = $emitter; - $this->requestHandler = $requestHandler; - - $this->swooleConfig = $config; - $this->appName = $appName; - } - - public function start(string $host, int $port): void - { - $this->server = new SwooleServer($host, $port); - $this->server->set( - array_merge( - [ - 'daemonize' => false, - 'worker_num' => 1, - 'send_yield' => true, - 'socket_type' => SWOOLE_SOCK_TCP, - 'process_type' => SWOOLE_PROCESS, - ], - $this->swooleConfig - ) - ); - - $this->server->on( - 'Start', - function (SwooleServer $server) { - $this->setProcessName('master process'); - - $this->eventDispatcher->dispatch(new Events\ServerStarted()); - } - ); - - $this->server->on( - 'ManagerStart', - function (SwooleServer $server) { - $this->mode = self::MODE_MANAGER; - - $this->setProcessName('manager process'); - - $this->eventDispatcher->dispatch(new Events\ManagerStarted()); - } - ); - - $this->server->on( - 'WorkerStart', - function (SwooleServer $server, int $workerId) { - $this->mode = self::MODE_WORKER; - - $this->setProcessName('worker process'); - - $this->eventDispatcher->dispatch(new Events\WorkerStarted($workerId)); - } - ); - - $this->server->on( - 'WorkerStop', - function (SwooleServer $server, int $workerId) { - $this->mode = self::MODE_WORKER; - - $this->eventDispatcher->dispatch(new Events\WorkerStopped($workerId)); - } - ); - - $this->server->on( - 'WorkerExit', - function (SwooleServer $server, int $workerId) { - $this->mode = self::MODE_WORKER; - - $this->eventDispatcher->dispatch(new Events\WorkerExit($workerId)); - } - ); - - $this->server->on( - 'Shutdown', - function (SwooleServer $server) { - $this->eventDispatcher->dispatch(new Events\ServerShutdown()); - } - ); - - $this->server->on('Request', Closure::fromCallable([$this, 'onRequest'])); - - $this->server->start(); - } - - public function stop(): void - { - $this->server->shutdown(); - } - - protected function onRequest(Request $request, Response $response): void - { - $psrRequest = $this->requestFactory->create($request); - - $psrResponse = $this->requestHandler->handle($psrRequest); - - $this->emitter->emit($response, $psrResponse); - } - - protected function setProcessName(string $name): void - { - if (!empty($this->appName)) { - swoole_set_process_name("{$this->appName} {$name}"); - - return; - } - - swoole_set_process_name($name); - } -} diff --git a/src/Http/HttpServiceProvider.php b/src/Http/HttpServiceProvider.php deleted file mode 100644 index c86ed5f..0000000 --- a/src/Http/HttpServiceProvider.php +++ /dev/null @@ -1,103 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http; - -use DI\Container; -use MakiseCo\Config\ConfigRepositoryInterface; -use MakiseCo\Http\Exceptions\JsonExceptionHandler; -use MakiseCo\Http\Router\RouteCollector; -use MakiseCo\Http\Router\RouteCollectorInterface; -use MakiseCo\Http\Router\RouteCollectorLazyFactory; -use MakiseCo\Http\Swoole\SwooleEmitter; -use MakiseCo\Http\Swoole\SwoolePsrRequestFactory; -use MakiseCo\Http\Swoole\SwoolePsrRequestFactoryInterface; -use MakiseCo\Middleware\ErrorHandlerInterface; -use MakiseCo\Middleware\ErrorHandlingMiddleware; -use MakiseCo\Middleware\MiddlewarePipeFactory; -use MakiseCo\Middleware\MiddlewareResolver; -use MakiseCo\Providers\ServiceProviderInterface; -use Symfony\Component\EventDispatcher\EventDispatcher; - -class HttpServiceProvider implements ServiceProviderInterface -{ - public const REQUEST_HANDLER = 'http.request_handler'; - - public function register(Container $container): void - { - // register JsonExceptionHandler as default exception handler - $container->set(ErrorHandlerInterface::class, \DI\get(JsonExceptionHandler::class)); - - // register SwoolePsrRequest factory (converts Swoole Http Requests to PSR HTTP Requests) - $container->set(SwoolePsrRequestFactoryInterface::class, \DI\get(SwoolePsrRequestFactory::class)); - - // register route collector - $container->set( - RouteCollector::class, - function (Container $container, ConfigRepositoryInterface $config) { - $factory = new RouteCollectorLazyFactory( - [ - \Laminas\Diactoros\ServerRequest::class, - ] - ); - - $collector = $factory->create($container); - - $this->loadRoutes($config, $collector); - - return $collector; - } - ); - $container->set(RouteCollectorInterface::class, \DI\get(RouteCollector::class)); - - // register request handler - $container->set( - self::REQUEST_HANDLER, - static function ( - Container $container, - ConfigRepositoryInterface $config, - RouteCollectorInterface $collector - ) { - $middlewares = [ErrorHandlingMiddleware::class]; - - foreach ((array)$config->get('http.middleware', []) as $middleware) { - $middlewares[] = $middleware; - } - - $middlewares[] = $collector->getRouter(); - - return (new MiddlewarePipeFactory(new MiddlewareResolver($container))) - ->create($middlewares); - } - ); - - // register HTTP server - $container->set( - HttpServer::class, - static function (Container $container, ConfigRepositoryInterface $config) { - return new HttpServer( - $container->get(EventDispatcher::class), - $container->make(SwoolePsrRequestFactoryInterface::class), - $container->make(SwooleEmitter::class), - $container->get(self::REQUEST_HANDLER), - $config->get('http.swoole', []), - $config->get('app.name') - ); - } - ); - } - - protected function loadRoutes(ConfigRepositoryInterface $config, RouteCollector $routes): void - { - foreach ($config->get('http.routes', []) as $file) { - include $file; - } - } -} diff --git a/src/Http/Swoole/SwooleEmitter.php b/src/Http/Swoole/SwooleEmitter.php deleted file mode 100644 index a359573..0000000 --- a/src/Http/Swoole/SwooleEmitter.php +++ /dev/null @@ -1,96 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Swoole; - -use Psr\Http\Message\ResponseInterface; -use Swoole\Http\Response as SwooleResponse; - -use function array_key_exists; -use function gmdate; - -final class SwooleEmitter -{ - /** - * @see https://www.swoole.co.uk/docs/modules/swoole-http-server/methods-properties#swoole-http-response-write - */ - private const CHUNK_SIZE = 8192; // 8 KB - - /** - * Emits a response for the Swoole environment. - * - * @param SwooleResponse $swooleResponse - * @param ResponseInterface $makiseResponse - */ - public function emit(SwooleResponse $swooleResponse, ResponseInterface $makiseResponse): void - { - $this->emitStatusCode($swooleResponse, $makiseResponse); - $this->emitHeaders($swooleResponse, $makiseResponse); - $this->emitBody($swooleResponse, $makiseResponse); - } - - /** - * Emit the status code - * - * @param SwooleResponse $swooleResponse - * @param ResponseInterface $makiseResponse - */ - private function emitStatusCode(SwooleResponse $swooleResponse, ResponseInterface $makiseResponse): void - { - $swooleResponse->status($makiseResponse->getStatusCode()); - } - - /** - * Emit the headers - * - * @param SwooleResponse $swooleResponse - * @param ResponseInterface $makiseResponse - */ - private function emitHeaders(SwooleResponse $swooleResponse, ResponseInterface $makiseResponse): void - { - $headers = $makiseResponse->getHeaders(); - - /* RFC2616 - 14.18 says all Responses need to have a Date */ - if (!array_key_exists('Date', $headers)) { - $date = gmdate('D, d M Y H:i:s GMT'); - $headers['Date'] = [$date]; - } - - foreach ($headers as $name => $values) { - foreach ($values as $value) { - $swooleResponse->header($name, $value); - } - } - } - - /** - * Emit the message body. - * - * @param SwooleResponse $swooleResponse - * @param ResponseInterface $makiseResponse - */ - private function emitBody(SwooleResponse $swooleResponse, ResponseInterface $makiseResponse): void - { - $body = $makiseResponse->getBody(); - - $body->rewind(); - - while (!$body->eof()) { - $chunk = $body->read(static::CHUNK_SIZE); - if (empty($chunk)) { - break; - } - - $swooleResponse->write($chunk); - } - - $swooleResponse->end(); - } -} diff --git a/src/Http/Swoole/SwoolePsrRequestFactory.php b/src/Http/Swoole/SwoolePsrRequestFactory.php deleted file mode 100644 index a30015d..0000000 --- a/src/Http/Swoole/SwoolePsrRequestFactory.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Swoole; - -use Laminas\Diactoros\ServerRequest; -use Psr\Http\Message\ServerRequestInterface; -use Swoole\Http\Request as SwooleRequest; - -use function array_change_key_case; -use function Laminas\Diactoros\marshalMethodFromSapi; -use function Laminas\Diactoros\marshalProtocolVersionFromSapi; -use function Laminas\Diactoros\marshalUriFromSapi; -use function Laminas\Diactoros\normalizeUploadedFiles; - -use const CASE_UPPER; - -class SwoolePsrRequestFactory implements SwoolePsrRequestFactoryInterface -{ - public function create(SwooleRequest $request): ServerRequestInterface - { - // Aggregate values from Swoole request object - $get = $request->get ?? []; - $post = $request->post ?? []; - $cookie = $request->cookie ?? []; - $files = $request->files ?? []; - $server = $request->server ?? []; - $headers = $request->header ?? []; - - // Normalize SAPI params - $server = array_change_key_case($server, CASE_UPPER); - - return new ServerRequest( - $server, - normalizeUploadedFiles($files), - marshalUriFromSapi($server, $headers), - marshalMethodFromSapi($server), - new SwooleStream($request), - $headers, - $cookie, - $get, - $post, - marshalProtocolVersionFromSapi($server) - ); - } -} diff --git a/src/Http/Swoole/SwoolePsrRequestFactoryInterface.php b/src/Http/Swoole/SwoolePsrRequestFactoryInterface.php deleted file mode 100644 index 95a5b67..0000000 --- a/src/Http/Swoole/SwoolePsrRequestFactoryInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Swoole; - -use Psr\Http\Message\ServerRequestInterface; -use Swoole\Http\Request as SwooleRequest; - -interface SwoolePsrRequestFactoryInterface -{ - public function create(SwooleRequest $request): ServerRequestInterface; -} diff --git a/src/Http/Swoole/SwooleStream.php b/src/Http/Swoole/SwooleStream.php deleted file mode 100644 index 3b35a70..0000000 --- a/src/Http/Swoole/SwooleStream.php +++ /dev/null @@ -1,229 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Http\Swoole; - -use InvalidArgumentException; -use Psr\Http\Message\StreamInterface; -use RuntimeException; -use Swoole\Http\Request as SwooleHttpRequest; - -use function strlen; -use function substr; - -use const SEEK_CUR; -use const SEEK_END; -use const SEEK_SET; - -/** - * @see https://github.com/mezzio/mezzio-swoole for the canonical source repository - * @copyright https://github.com/mezzio/mezzio-swoole/blob/master/COPYRIGHT.md - * @license https://github.com/mezzio/mezzio-swoole/blob/master/LICENSE.md New BSD License - */ -final class SwooleStream implements StreamInterface -{ - /** - * Memoized body content, as pulled via SwooleHttpRequest::rawContent(). - * - * @var string|null - */ - private ?string $body = null; - - /** - * Length of the request body content. - * - * @var int|null - */ - private ?int $bodySize = null; - - /** - * Index to which we have seek'd or read within the request body. - * - * @var int - */ - private int $index = 0; - - /** - * Swoole request containing the body contents. - */ - private SwooleHttpRequest $request; - - public function __construct(SwooleHttpRequest $request) - { - $this->request = $request; - } - - // phpcs:disable WebimpressCodingStandard.Functions.Param.MissingSpecification - // phpcs:disable WebimpressCodingStandard.Functions.ReturnType.ReturnValue - - public function getContents(): string - { - // If we're at the end of the string, return an empty string. - if ($this->eof()) { - return ''; - } - - $size = $this->getSize(); - // If we have not content, return an empty string - if ($size === 0) { - return ''; - } - - // Memoize index so we can use it to get a substring later, - // if required. - $index = $this->index; - - // Set the internal index to the end of the string - $this->index = $size; - - if ($index) { - // Per PSR-7 spec, if we have seeked or read to a given position in - // the string, we should only return the contents from that position - // forward. - return substr($this->body, $index); - } - - // If we're at the start of the content, return all of it. - return $this->body; - } - - public function __toString(): string - { - $this->body !== null || $this->initRawContent(); - return $this->body; - } - - public function getSize(): int - { - if (null === $this->bodySize) { - $this->body !== null || $this->initRawContent(); - $this->bodySize = strlen($this->body); - } - return $this->bodySize; - } - - public function tell(): int - { - return $this->index; - } - - public function eof(): bool - { - return $this->index >= $this->getSize(); - } - - public function isReadable(): bool - { - return true; - } - - public function read($length) - { - $this->body !== null || $this->initRawContent(); - $result = substr($this->body, $this->index, $length); - - // Reset index based on legnth; should not be > EOF position. - $size = $this->getSize(); - $this->index = $this->index + $length >= $size - ? $size - : $this->index + $length; - - return $result; - } - - public function isSeekable(): bool - { - return true; - } - - /** - * @psalm-return void - */ - public function seek($offset, $whence = SEEK_SET): void - { - $size = $this->getSize(); - switch ($whence) { - case SEEK_SET: - if ($offset >= $size) { - throw new RuntimeException( - 'Offset cannot be longer than content size' - ); - } - $this->index = $offset; - break; - case SEEK_CUR: - if ($offset + $this->index >= $size) { - throw new RuntimeException( - 'Offset + current position cannot be longer than content size when using SEEK_CUR' - ); - } - $this->index += $offset; - break; - case SEEK_END: - if ($offset + $size >= $size) { - throw new RuntimeException( - 'Offset must be a negative number to be under the content size when using SEEK_END' - ); - } - $this->index = $size + $offset; - break; - default: - throw new InvalidArgumentException( - 'Invalid $whence argument provided; must be one of SEEK_CUR,' - . 'SEEK_END, or SEEK_SET' - ); - } - } - - /** - * @psalm-return void - */ - public function rewind(): void - { - $this->index = 0; - } - - public function isWritable(): bool - { - return false; - } - - public function write($string): int - { - throw new RuntimeException('Stream is not writable'); - } - - public function getMetadata($key = null): ?array - { - return $key ? null : []; - } - - public function detach(): SwooleHttpRequest - { - return $this->request; - } - - public function close(): void - { - } - - // phpcs:enable - - /** - * Memoize the request raw content in the $body property, if not already done. - */ - private function initRawContent(): void - { - if ($this->body) { - return; - } - $this->body = $this->request->rawContent() ?: ''; - } -} diff --git a/src/Testing/Concerns/DatabaseTransactions.php b/src/Testing/Concerns/DatabaseTransactions.php deleted file mode 100644 index bdfcc93..0000000 --- a/src/Testing/Concerns/DatabaseTransactions.php +++ /dev/null @@ -1,59 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Testing\Concerns; - -use Spiral\Database\DatabaseManager; - -/** - * Cycle ORM Database Transactions Trait - */ -trait DatabaseTransactions -{ - protected function bootDatabaseTransactions(): void - { - /* @var DatabaseManager $db */ - $db = $this->container->get(DatabaseManager::class); - - foreach ($this->connectionsToTransact() as $connection) { - $db->driver($connection)->beginTransaction(); - } - } - - protected function cleanupDatabaseTransactions(): void - { - /* @var DatabaseManager $db */ - $db = $this->container->get(DatabaseManager::class); - - foreach ($this->connectionsToTransact() as $connection) { - try { - $db->driver($connection)->rollbackTransaction(); - } catch (\Throwable $e) { - $this->addWarning("Unable to ROLLBACK transaction on \"{$connection}\" connection: {$e->getMessage()}"); - } - - try { - $db->driver($connection)->disconnect(); - } catch (\Throwable $e) { - $this->addWarning("Unable to disconnect \"{$connection}\" connection: {$e->getMessage()}"); - } - } - } - - /** - * The database connections that should have transactions. - * - * @return string[] - */ - protected function connectionsToTransact(): array - { - return property_exists($this, 'connectionsToTransact') ? $this->connectionsToTransact : []; - } -} diff --git a/src/Testing/Concerns/MakesHttpRequests.php b/src/Testing/Concerns/MakesHttpRequests.php deleted file mode 100644 index def2b3c..0000000 --- a/src/Testing/Concerns/MakesHttpRequests.php +++ /dev/null @@ -1,422 +0,0 @@ - - * - */ - -declare(strict_types=1); - -namespace MakiseCo\Testing\Concerns; - -use Laminas\Diactoros\ServerRequest; -use MakiseCo\Http\FakeStream; -use MakiseCo\Testing\Http\TestResponse; -use Psr\Http\Message\ResponseInterface; -use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; - -/** - * @copyright Laravel - */ -trait MakesHttpRequests -{ - /** - * Additional headers for the request. - * - * @var array - */ - protected array $defaultHeaders = []; - - /** - * Additional cookies for the request. - * - * @var array - */ - protected array $defaultCookies = []; - - /** - * Additional server variables for the request. - * - * @var array - */ - protected array $serverVariables = []; - - /** - * Define additional headers to be sent with the request. - * - * @param array $headers - * @return $this - */ - public function withHeaders(array $headers): self - { - $this->defaultHeaders = array_merge($this->defaultHeaders, $headers); - - return $this; - } - - /** - * Add a header to be sent with the request. - * - * @param string $name - * @param string $value - * @return $this - */ - public function withHeader(string $name, string $value): self - { - $this->defaultHeaders[$name] = $value; - - return $this; - } - - /** - * Flush all the configured headers. - * - * @return $this - */ - public function flushHeaders(): self - { - $this->defaultHeaders = []; - - return $this; - } - - /** - * Define a set of server variables to be sent with the requests. - * - * @param array $server - * @return $this - */ - public function withServerVariables(array $server): self - { - $this->serverVariables = $server; - - return $this; - } - - /** - * Define additional cookies to be sent with the request. - * - * @param array $cookies - * @return $this - */ - public function withCookies(array $cookies): self - { - $this->defaultCookies = array_merge($this->defaultCookies, $cookies); - - return $this; - } - - /** - * Add a cookie to be sent with the request. - * - * @param string $name - * @param string $value - * @return $this - */ - public function withCookie(string $name, string $value): self - { - $this->defaultCookies[$name] = $value; - - return $this; - } - - /** - * Visit the given URI with a GET request. - * - * @param string $uri - * @param array $headers - * @return TestResponse - */ - public function get($uri, array $headers = []): TestResponse - { - return $this->call('GET', $uri, [], $this->defaultCookies, [], $headers); - } - - /** - * Visit the given URI with a GET request, expecting a JSON response. - * - * @param string $uri - * @param array $headers - * @return TestResponse - */ - public function getJson($uri, array $headers = []): TestResponse - { - return $this->json('GET', $uri, [], $headers); - } - - /** - * Visit the given URI with a POST request. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function post($uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('POST', $uri, $data, $this->defaultCookies, [], $headers); - } - - /** - * Visit the given URI with a POST request, expecting a JSON response. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function postJson($uri, array $data = [], array $headers = []): TestResponse - { - return $this->json('POST', $uri, $data, $headers); - } - - /** - * Visit the given URI with a PUT request. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function put($uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('PUT', $uri, $data, $this->defaultCookies, [], $headers); - } - - /** - * Visit the given URI with a PUT request, expecting a JSON response. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function putJson($uri, array $data = [], array $headers = []): TestResponse - { - return $this->json('PUT', $uri, $data, $headers); - } - - /** - * Visit the given URI with a PATCH request. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function patch($uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('PATCH', $uri, $data, $this->defaultCookies, [], $headers); - } - - /** - * Visit the given URI with a PATCH request, expecting a JSON response. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function patchJson($uri, array $data = [], array $headers = []): TestResponse - { - return $this->json('PATCH', $uri, $data, $headers); - } - - /** - * Visit the given URI with a DELETE request. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function delete($uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('DELETE', $uri, $data, $this->defaultCookies, [], $headers); - } - - /** - * Visit the given URI with a DELETE request, expecting a JSON response. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function deleteJson($uri, array $data = [], array $headers = []): TestResponse - { - return $this->json('DELETE', $uri, $data, $headers); - } - - /** - * Visit the given URI with a OPTIONS request. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function options($uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('OPTIONS', $uri, $data, $this->defaultCookies, [], $headers); - } - - /** - * Visit the given URI with a OPTIONS request, expecting a JSON response. - * - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function optionsJson($uri, array $data = [], array $headers = []): TestResponse - { - return $this->json('OPTIONS', $uri, $data, $headers); - } - - /** - * Call the given URI with a JSON request. - * - * @param string $method - * @param string $uri - * @param array $data - * @param array $headers - * @return TestResponse - */ - public function json($method, $uri, array $data = [], array $headers = []): TestResponse - { - $files = $this->extractFilesFromDataArray($data); - - $content = new FakeStream(json_encode($data)); - - $headers = array_merge([ - 'CONTENT_LENGTH' => $content->getSize(), - 'CONTENT_TYPE' => 'application/json', - 'Accept' => 'application/json', - ], $headers); - - return $this->call( - $method, $uri, $data, [], $files, $headers, $content - ); - } - - /** - * Call the given URI and return the Response. - * - * @param string $method - * @param string $uri - * @param array $parameters - * @param array $cookies - * @param array $files - * @param array $headers - * @param string|null|FakeStream $content - * @return TestResponse - */ - public function call( - $method, - $uri, - $parameters = [], - $cookies = [], - $files = [], - $headers = [], - $content = null - ): TestResponse { - $server['REQUEST_METHOD'] = $method; - $server['REQUEST_URI'] = rawurldecode(parse_url($uri, PHP_URL_PATH)); - - /* @var \Psr\Http\Server\RequestHandlerInterface $handler */ - $handler = $this->container->get(\MakiseCo\Http\HttpServiceProvider::REQUEST_HANDLER); - - $files = array_merge($files, $this->extractFilesFromDataArray($parameters)); - - $query = []; - parse_str(parse_url($uri, PHP_URL_QUERY) ?? '', $query); - - $request = new ServerRequest( - array_replace($this->serverVariables, $server), - $files, - $server['REQUEST_URI'], - $server['REQUEST_METHOD'], - $content ?? new FakeStream(''), - $headers, - $cookies, - $query, - $parameters - ); - - $response = $handler->handle($request); - - return $this->createTestResponse($response); - } - - /** - * Transform headers array to array of $_SERVER vars with HTTP_* format. - * - * @param array $headers - * @return string[] - */ - protected function transformHeadersToServerVars(array $headers): array - { - $result = []; - - foreach ($headers as $key => $header) { - $result[$this->formatServerHeaderKey($key)] = $header; - } - - return $result; - } - - /** - * Format the header name for the server array. - * - * @param string $name - * @return string - */ - protected function formatServerHeaderKey(string $name): string - { - if (false === strpos($name, 'HTTP_') && $name !== 'CONTENT_TYPE' && $name !== 'REMOTE_ADDR') { - return 'HTTP_' . $name; - } - - return $name; - } - - /** - * Extract the file uploads from the given data array. - * - * @param array $data - * @return array - */ - protected function extractFilesFromDataArray(&$data): array - { - $files = []; - - foreach ($data as $key => $value) { - if ($value instanceof SymfonyUploadedFile) { - $files[$key] = $value; - - unset($data[$key]); - } - - if (is_array($value)) { - $files[$key] = $this->extractFilesFromDataArray($value); - - $data[$key] = $value; - } - } - - return $files; - } - - /** - * Create the test response instance from the given response. - * @param ResponseInterface $response - * @return TestResponse - */ - protected function createTestResponse(ResponseInterface $response): TestResponse - { - return new TestResponse($response); - } -} diff --git a/src/Testing/CoroutineTestCase.php b/src/Testing/CoroutineTestCase.php new file mode 100644 index 0000000..d77558b --- /dev/null +++ b/src/Testing/CoroutineTestCase.php @@ -0,0 +1,69 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\Testing; + +use Closure; +use PHPUnit\Framework\TestCase as PHPUnitTestCase; +use PHPUnit\Framework\TestResult; +use Swoole\Coroutine; +use Swoole\Event; +use Swoole\Timer; +use Throwable; + +use function Swoole\Coroutine\run; + +class CoroutineTestCase extends PHPUnitTestCase +{ + /** + * Run test cases in the coroutine + * + * @param TestResult|null $result + * @return TestResult + * @throws Throwable + */ + public function run(?TestResult $result = null): TestResult + { + $coroResult = new CoroutineTestResult(); + + run( + Closure::fromCallable([$this, 'runCoro']), + $result, + $coroResult + ); + + if (null !== $coroResult->ex) { + throw $coroResult->ex; + } + + return $coroResult->result; + } + + protected function runCoro(?TestResult $result, CoroutineTestResult $coroTestResult): void + { + Coroutine::defer( + static function () { + // do not block command coroutine exit if programmer have forgotten to release event loop + if (Coroutine::stats()['event_num'] > 0) { + // force exit event loop + Event::exit(); + } + + Timer::clearAll(); + } + ); + + try { + $coroTestResult->result = parent::run($result); + } catch (Throwable $e) { + $coroTestResult->ex = $e; + } + } +} diff --git a/src/Testing/CoroutineTestResult.php b/src/Testing/CoroutineTestResult.php new file mode 100644 index 0000000..ec4e550 --- /dev/null +++ b/src/Testing/CoroutineTestResult.php @@ -0,0 +1,20 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\Testing; + +use PHPUnit\Framework\TestResult; +use Throwable; + +final class CoroutineTestResult +{ + public TestResult $result; + public ?Throwable $ex = null; +} diff --git a/src/Testing/Http/TestResponse.php b/src/Testing/Http/TestResponse.php deleted file mode 100644 index f786164..0000000 --- a/src/Testing/Http/TestResponse.php +++ /dev/null @@ -1,740 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Testing\Http; - -use MakiseCo\Testing\Assert\Assert as PHPUnit; -use MakiseCo\Testing\Assert\SeeInOrder; -use MakiseCo\Util\Arr; -use MakiseCo\Util\PropertyAccessorHelper; -use MakiseCo\Util\Str; -use Psr\Http\Message\ResponseInterface; -use Symfony\Component\PropertyAccess\PropertyAccess; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; - -use function time; - -class TestResponse -{ - /** - * @var ResponseInterface|\Laminas\Diactoros\Response - */ - protected ResponseInterface $response; - - protected PropertyAccessorInterface $propertyAccessor; - - public function __construct(ResponseInterface $response) - { - $this->response = $response; - $this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() - ->disableExceptionOnInvalidIndex() - ->disableExceptionOnInvalidPropertyPath() - ->getPropertyAccessor(); - } - - private function getResponseContent(): string - { - $body = $this->response->getBody(); - $content = $body->getContents(); - - $body->rewind(); - - return $content; - } - - /** - * Assert that the response has a successful status code. - * - * @return $this - */ - public function assertSuccessful(): self - { - $code = $this->response->getStatusCode(); - - PHPUnit::assertTrue( - 200 >= $code && $code < 400, - 'Response status code [' . $code . '] is not a successful status code.' - ); - - return $this; - } - - /** - * Assert that the response has a 200 status code. - * - * @return $this - */ - public function assertOk(): self - { - PHPUnit::assertSame( - 200, $this->response->getStatusCode(), - 'Response status code [' . $this->response->getStatusCode() . '] does not match expected 200 status code.' - ); - - return $this; - } - - /** - * Assert that the response has a 201 status code. - * - * @return $this - */ - public function assertCreated(): self - { - $actual = $this->response->getStatusCode(); - - PHPUnit::assertSame( - 201, $actual, - "Response status code [{$actual}] does not match expected 201 status code." - ); - - return $this; - } - - /** - * Assert that the response has the given status code and no content. - * - * @param int $status - * @return $this - */ - public function assertNoContent($status = 204): self - { - $this->assertStatus($status); - - PHPUnit::assertEmpty($this->getResponseContent(), 'Response content is not empty.'); - - return $this; - } - - /** - * Assert that the response has a not found status code. - * - * @return $this - */ - public function assertNotFound(): self - { - PHPUnit::assertSame( - 404, $this->response->getStatusCode(), - 'Response status code [' . $this->response->getStatusCode() . '] is not a not found status code.' - ); - - return $this; - } - - /** - * Assert that the response has a forbidden status code. - * - * @return $this - */ - public function assertForbidden(): self - { - PHPUnit::assertSame( - 403, $this->response->getStatusCode(), - 'Response status code [' . $this->response->getStatusCode() . '] is not a forbidden status code.' - ); - - return $this; - } - - /** - * Assert that the response has an unauthorized status code. - * - * @return $this - */ - public function assertUnauthorized(): self - { - $actual = $this->response->getStatusCode(); - - PHPUnit::assertSame( - 401, $actual, - "Response status code [{$actual}] is not an unauthorized status code." - ); - - return $this; - } - - /** - * Assert that the response has the given status code. - * - * @param int $status - * @return $this - */ - public function assertStatus($status): self - { - $actual = $this->response->getStatusCode(); - - PHPUnit::assertSame( - $actual, $status, - "Expected status code {$status} but received {$actual}." - ); - - return $this; - } - - /** - * Asserts that the response contains the given header and equals the optional value. - * - * @param string $headerName - * @param mixed $value - * @return $this - */ - public function assertHeader($headerName, $value = null): self - { - PHPUnit::assertTrue( - $this->response->hasHeader($headerName), "Header [{$headerName}] not present on response." - ); - - $actual = $this->response->getHeader($headerName); - - if (null !== $value) { - PHPUnit::assertEquals( - $value, $actual, - "Header [{$headerName}] was found, but value [{$actual}] does not match [{$value}]." - ); - } - - return $this; - } - - /** - * Asserts that the response does not contains the given header. - * - * @param string $headerName - * @return $this - */ - public function assertHeaderMissing($headerName): self - { - PHPUnit::assertFalse( - $this->response->hasHeader($headerName), "Unexpected header [{$headerName}] is present on response." - ); - - return $this; - } - - /** - * Assert that the current location header matches the given URI. - * - * @param string $uri - * @return $this - */ - public function assertLocation($uri): self - { -// PHPUnit::assertEquals( -// app('url')->to($uri), app('url')->to($this->headers->get('Location')) -// ); - - return $this; - } - - /** - * Asserts that the response contains the given cookie and equals the optional value. - * - * @param string $cookieName - * @param mixed $value - * @return $this - */ - public function assertCookie($cookieName, $value = null): self - { - PHPUnit::assertNotNull( - $cookie = $this->getCookie($cookieName), - "Cookie [{$cookieName}] not present on response." - ); - - if (!$cookie || null === $value) { - return $this; - } - - $cookieValue = $cookie->getValue(); - - PHPUnit::assertEquals( - $value, $cookieValue, - "Cookie [{$cookieName}] was found, but value [{$cookieValue}] does not match [{$value}]." - ); - - return $this; - } - - /** - * Asserts that the response contains the given cookie and is expired. - * - * @param string $cookieName - * @return $this - */ - public function assertCookieExpired($cookieName): self - { - PHPUnit::assertNotNull( - $cookie = $this->getCookie($cookieName), - "Cookie [{$cookieName}] not present on response." - ); - - $expiresAt = $cookie->getExpiresTime(); - - PHPUnit::assertTrue( - $expiresAt < time(), - "Cookie [{$cookieName}] is not expired, it expires at [{$expiresAt}]." - ); - - return $this; - } - - /** - * Asserts that the response contains the given cookie and is not expired. - * - * @param string $cookieName - * @return $this - */ - public function assertCookieNotExpired($cookieName): self - { - PHPUnit::assertNotNull( - $cookie = $this->getCookie($cookieName), - "Cookie [{$cookieName}] not present on response." - ); - - $expiresAt = $cookie->getExpiresTime(); - - PHPUnit::assertTrue( - $expiresAt > time(), - "Cookie [{$cookieName}] is expired, it expired at [{$expiresAt}]." - ); - - return $this; - } - - /** - * Asserts that the response does not contains the given cookie. - * - * @param string $cookieName - * @return $this - */ - public function assertCookieMissing($cookieName): self - { - PHPUnit::assertNull( - $this->getCookie($cookieName), - "Cookie [{$cookieName}] is present on response." - ); - - return $this; - } - - /** - * Get the given cookie from the response. - * - * @param string $cookieName - * @return \Symfony\Component\HttpFoundation\Cookie|null - */ - protected function getCookie($cookieName): ?\Symfony\Component\HttpFoundation\Cookie - { - foreach ($this->response->headers->getCookies() as $cookie) { - if ($cookie->getName() === $cookieName) { - return $cookie; - } - } - - return null; - } - - /** - * Assert that the given string is contained within the response. - * - * @param string $value - * @param bool $escaped - * @return $this - */ - public function assertSee($value, $escaped = true): self - { - $value = $escaped ? htmlspecialchars($value) : $value; - - PHPUnit::assertStringContainsString((string)$value, $this->getResponseContent()); - - return $this; - } - - /** - * Assert that the given strings are contained in order within the response. - * - * @param array $values - * @param bool $escaped - * @return $this - */ - public function assertSeeInOrder(array $values, $escaped = true): self - { - $values = $escaped ? array_map('htmlspecialchars', ($values)) : $values; - - PHPUnit::assertThat($values, new SeeInOrder($this->getResponseContent())); - - return $this; - } - - /** - * Assert that the given string is contained within the response text. - * - * @param string $value - * @param bool $escaped - * @return $this - */ - public function assertSeeText($value, $escaped = true): self - { - $value = $escaped ? htmlspecialchars($value) : $value; - - PHPUnit::assertStringContainsString((string)$value, strip_tags($this->getResponseContent())); - - return $this; - } - - /** - * Assert that the given strings are contained in order within the response text. - * - * @param array $values - * @param bool $escaped - * @return $this - */ - public function assertSeeTextInOrder(array $values, $escaped = true): self - { - $values = $escaped ? array_map('htmlspecialchars', ($values)) : $values; - - PHPUnit::assertThat($values, new SeeInOrder(strip_tags($this->getResponseContent()))); - - return $this; - } - - /** - * Assert that the given string is not contained within the response. - * - * @param string $value - * @param bool $escaped - * @return $this - */ - public function assertDontSee($value, $escaped = true): self - { - $value = $escaped ? htmlspecialchars($value) : $value; - - PHPUnit::assertStringNotContainsString((string)$value, $this->getResponseContent()); - - return $this; - } - - /** - * Assert that the given string is not contained within the response text. - * - * @param string $value - * @param bool $escaped - * @return $this - */ - public function assertDontSeeText($value, $escaped = true): self - { - $value = $escaped ? htmlspecialchars($value) : $value; - - PHPUnit::assertStringNotContainsString((string)$value, strip_tags($this->getResponseContent())); - - return $this; - } - - /** - * Assert that the response is a superset of the given JSON. - * - * @param array $data - * @param bool $strict - * @return $this - */ - public function assertJson(array $data, $strict = false): self - { - PHPUnit::assertArraySubset( - $data, $this->decodeResponseJson(), $strict, $this->assertJsonMessage($data) - ); - - return $this; - } - - /** - * Get the assertion message for assertJson. - * - * @param array $data - * @return string - */ - protected function assertJsonMessage(array $data): string - { - $expected = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - $actual = json_encode($this->decodeResponseJson(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - return 'Unable to find JSON: ' . PHP_EOL . PHP_EOL . - "[{$expected}]" . PHP_EOL . PHP_EOL . - 'within response JSON:' . PHP_EOL . PHP_EOL . - "[{$actual}]." . PHP_EOL . PHP_EOL; - } - - /** - * Assert that the expected value and type exists at the given path in the response. - * - * @param string $path - * @param mixed $expect - * @return $this - */ - public function assertJsonPath($path, $expect): self - { - PHPUnit::assertSame($expect, $this->json($path)); - - return $this; - } - - /** - * Assert that the response has the exact given JSON. - * - * @param array $data - * @return $this - */ - public function assertExactJson(array $data): self - { - $actual = json_encode(Arr::sortRecursive( - (array)$this->decodeResponseJson() - )); - - PHPUnit::assertEquals(json_encode(Arr::sortRecursive($data)), $actual); - - return $this; - } - - /** - * Assert that the response contains the given JSON fragment. - * - * @param array $data - * @return $this - */ - public function assertJsonFragment(array $data): self - { - $actual = json_encode(Arr::sortRecursive( - (array)$this->decodeResponseJson() - )); - - foreach (Arr::sortRecursive($data) as $key => $value) { - $expected = $this->jsonSearchStrings($key, $value); - - PHPUnit::assertTrue( - Str::contains($actual, $expected), - 'Unable to find JSON fragment: ' . PHP_EOL . PHP_EOL . - '[' . json_encode([$key => $value]) . ']' . PHP_EOL . PHP_EOL . - 'within' . PHP_EOL . PHP_EOL . - "[{$actual}]." - ); - } - - return $this; - } - - /** - * Assert that the response does not contain the given JSON fragment. - * - * @param array $data - * @param bool $exact - * @return $this - */ - public function assertJsonMissing(array $data, $exact = false): self - { - if ($exact) { - return $this->assertJsonMissingExact($data); - } - - $actual = json_encode(Arr::sortRecursive( - (array)$this->decodeResponseJson() - )); - - foreach (Arr::sortRecursive($data) as $key => $value) { - $unexpected = $this->jsonSearchStrings($key, $value); - - PHPUnit::assertFalse( - Str::contains($actual, $unexpected), - 'Found unexpected JSON fragment: ' . PHP_EOL . PHP_EOL . - '[' . json_encode([$key => $value]) . ']' . PHP_EOL . PHP_EOL . - 'within' . PHP_EOL . PHP_EOL . - "[{$actual}]." - ); - } - - return $this; - } - - /** - * Assert that the response does not contain the exact JSON fragment. - * - * @param array $data - * @return $this - */ - public function assertJsonMissingExact(array $data): self - { - $actual = json_encode(Arr::sortRecursive( - (array)$this->decodeResponseJson() - )); - - foreach (Arr::sortRecursive($data) as $key => $value) { - $unexpected = $this->jsonSearchStrings($key, $value); - - if (!Str::contains($actual, $unexpected)) { - return $this; - } - } - - PHPUnit::fail( - 'Found unexpected JSON fragment: ' . PHP_EOL . PHP_EOL . - '[' . json_encode($data) . ']' . PHP_EOL . PHP_EOL . - 'within' . PHP_EOL . PHP_EOL . - "[{$actual}]." - ); - - return $this; - } - - /** - * Get the strings we need to search for when examining the JSON. - * - * @param string $key - * @param string $value - * @return array - */ - protected function jsonSearchStrings($key, $value): array - { - $needle = substr(json_encode([$key => $value]), 1, -1); - - return [ - $needle . ']', - $needle . '}', - $needle . ',', - ]; - } - - /** - * Assert that the response has a given JSON structure. - * - * @param array|null $structure - * @param array|null $responseData - * @return $this - */ - public function assertJsonStructure(array $structure = null, $responseData = null): self - { - if (null === $structure) { - return $this->assertExactJson($this->json()); - } - - if (null === $responseData) { - $responseData = $this->decodeResponseJson(); - } - - foreach ($structure as $key => $value) { - if (is_array($value) && $key === '*') { - PHPUnit::assertIsArray($responseData); - - foreach ($responseData as $responseDataItem) { - $this->assertJsonStructure($structure['*'], $responseDataItem); - } - } elseif (is_array($value)) { - PHPUnit::assertArrayHasKey($key, $responseData); - - $this->assertJsonStructure($structure[$key], $responseData[$key]); - } else { - PHPUnit::assertArrayHasKey($value, $responseData); - } - } - - return $this; - } - - /** - * Assert that the response JSON has the expected count of items at the given key. - * - * @param int $count - * @param string|null $key - * @return $this - */ - public function assertJsonCount(int $count, $key = null): self - { - if ($key) { - $key = PropertyAccessorHelper::fromDotNotation($key); - - PHPUnit::assertCount( - $count, $this->propertyAccessor->getValue($this->json(), $key), - "Failed to assert that the response count matched the expected {$count}" - ); - - return $this; - } - - PHPUnit::assertCount($count, - $this->json(), - "Failed to assert that the response count matched the expected {$count}" - ); - - return $this; - } - - /** - * Validate and return the decoded response JSON. - * - * @param string|null $key - * @return mixed - */ - public function decodeResponseJson($key = null) - { - $decodedResponse = json_decode($this->getResponseContent(), true); - - if (null === $decodedResponse || $decodedResponse === false) { - PHPUnit::fail('Invalid JSON was returned from the route.'); - } - - if (null === $key) { - return $decodedResponse; - } - - $key = PropertyAccessorHelper::fromDotNotation($key); - - return $this->propertyAccessor->getValue($decodedResponse, $key); - } - - /** - * Validate and return the decoded response JSON. - * - * @param string|null $key - * @return mixed - */ - public function json($key = null) - { - return $this->decodeResponseJson($key); - } - - /** - * Dump the content from the response. - * - * @return $this - */ - public function dump(): self - { - $content = $this->getResponseContent(); - - $json = json_decode($content); - - if (json_last_error() === JSON_ERROR_NONE) { - $content = $json; - } - - dump($content); - - return $this; - } - - /** - * Dump the headers from the response. - * - * @return $this - */ - public function dumpHeaders(): self - { - dump($this->response->getHeaders()); - - return $this; - } -} diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index b8dbfef..93b045d 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -13,58 +13,56 @@ use DI\Container; use MakiseCo\ApplicationInterface; +use MakiseCo\Bootstrapper; use MakiseCo\Util\TraitsCollector; -use PHPUnit\Framework\TestCase as PHPUnitTestCase; +use ReflectionClass; +use Throwable; +use function gc_collect_cycles; use function method_exists; -abstract class TestCase extends PHPUnitTestCase +abstract class TestCase extends CoroutineTestCase { protected ApplicationInterface $app; protected Container $container; /** - * @var \ReflectionClass[] + * @var ReflectionClass[] */ protected array $traits = []; + abstract protected function createApplication(): ApplicationInterface; + protected function setUp(): void { $this->app = $this->createApplication(); $this->container = $this->app->getContainer(); - $this->setUpTraits(); + // setup traits only once + if (empty($this->traits)) { + $this->setUpTraits(); + } + + $this->bootServices(); + $this->bootTraits(); } protected function tearDown(): void { + $this->cleanupTraits(); + $this->stopServices(); + $this->app->terminate(); unset($this->app, $this->container); - \gc_collect_cycles(); + gc_collect_cycles(); } - /** - * Bootstrap your services inside coroutine context - */ - protected function coroSetUp(): void - { - } - - /** - * Teardown your services inside coroutine context - */ - protected function coroTearDown(): void - { - } - - abstract protected function createApplication(): ApplicationInterface; - /** * Boot the testing helper traits. */ protected function setUpTraits(): void { - $this->traits = TraitsCollector::getTraits(new \ReflectionClass($this)); + $this->traits = TraitsCollector::getTraits(new ReflectionClass($this)); } protected function bootTraits(): void @@ -88,7 +86,7 @@ protected function cleanupTraits(): void if (method_exists($this, $cleanupMethod)) { try { $this->{$cleanupMethod}(); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->addWarning("Trait {$traitName} cleanup failed"); $this->addWarning($e->getMessage()); } @@ -96,39 +94,30 @@ protected function cleanupTraits(): void } } + protected function bootServices(): void + { + /** @var Bootstrapper $bootstrapper */ + $bootstrapper = $this->container->get(Bootstrapper::class); + $bootstrapper->init($this->getServices()); + } + + protected function stopServices(): void + { + /** @var Bootstrapper $bootstrapper */ + $bootstrapper = $this->container->get(Bootstrapper::class); + $bootstrapper->stop($this->getServices()); + } + /** - * Run test cases in the coroutine + * Returns list of services that should be initialized before test case starts and stopped after test case finished + * @see \MakiseCo\Console\Commands\AbstractCommand::getServices() * - * @return mixed|null - * @throws \Throwable + * @return string[]|null[] empty list means that the all services should be initialized/stopped, + * [null] means that the no services will be initialized/stopped */ - protected function runTest() + protected function getServices(): array { - $result = null; - /* @var \Throwable|null $ex */ - $ex = null; - - \Swoole\Coroutine\run(function () use (&$result, &$ex) { - try { - $this->bootTraits(); - $this->coroSetUp(); - - $result = parent::runTest(); - } catch (\Throwable $e) { - $ex = $e; - } - - try { - $this->coroTearDown(); - } finally { - $this->cleanupTraits(); - } - }); - - if (null !== $ex) { - throw $ex; - } - - return $result; + // booting all services by default + return []; } } diff --git a/tests/Application/ApplicationTest.php b/tests/Application/ApplicationTest.php index 2f280ff..2a5c3e5 100644 --- a/tests/Application/ApplicationTest.php +++ b/tests/Application/ApplicationTest.php @@ -11,27 +11,83 @@ namespace MakiseCo\Tests\Application; use MakiseCo\Application; +use MakiseCo\Bootstrapper; use MakiseCo\Config\ConfigRepositoryInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application as ConsoleApplication; use function MakiseCo\Env\env; -use function putenv; class ApplicationTest extends TestCase { public function testEnvLoaded(): void { - putenv('APP_NAME=MakiseTest'); + $_ENV['APP_NAME'] = 'MakiseTest'; $app = new Application(__DIR__, __DIR__ . '/stubs/config'); $env = env('APP_NAME'); - $this->assertEquals('MakiseTest', $env); + self::assertEquals('MakiseTest', $env); + } + + public function testEnvFileLoaded(): void + { + file_put_contents(__DIR__ . '/.env', "APP_NAME=Makise-Env"); + + $app = new Application(__DIR__, __DIR__ . '/stubs/config'); + + $env = env('APP_NAME'); + + try { + self::assertEquals('Makise-Env', $env); + } finally { + unlink(__DIR__ . '/.env'); + } + } + + public function testEnvFileOverloaded(): void + { + file_put_contents(__DIR__ . '/.env', "APP_NAME=Makise-Env"); + file_put_contents(__DIR__ . '/.env.testing', "APP_NAME=Makise-EnvTesting"); + + $_ENV['APP_ENV'] = 'testing'; + + $app = new Application(__DIR__, __DIR__ . '/stubs/config'); + + $env = env('APP_NAME'); + + try { + self::assertEquals('Makise-EnvTesting', $env); + } finally { + unlink(__DIR__ . '/.env'); + unlink(__DIR__ . '/.env.testing'); + } + } + + public function testEnvFileOverloadedByCliArg(): void + { + file_put_contents(__DIR__ . '/.env', "APP_NAME=Makise-Env"); + file_put_contents(__DIR__ . '/.env.testing', "APP_NAME=Makise-EnvTestingCliArg"); + + global $argv; + $argv[] = '--env=testing'; + + $app = new Application(__DIR__, __DIR__ . '/stubs/config'); + + $env = env('APP_NAME'); + + try { + self::assertEquals('Makise-EnvTestingCliArg', $env); + } finally { + unlink(__DIR__ . '/.env'); + unlink(__DIR__ . '/.env.testing'); + } } public function testConfigLoaded(): void { + $_ENV['APP_NAME'] = $_SERVER['APP_NAME'] = 'Makise-Co'; + $app = new Application(__DIR__, __DIR__ . '/stubs/config'); $val = $app @@ -39,7 +95,7 @@ public function testConfigLoaded(): void ->get(ConfigRepositoryInterface::class) ->get('app.name'); - $this->assertEquals('Makise-Co', $val); + self::assertEquals('Makise-Co', $val); } public function testProvidersLoaded(): void @@ -51,7 +107,7 @@ public function testProvidersLoaded(): void ->get(ConfigRepositoryInterface::class) ->get('some'); - $this->assertEquals('it works', $val); + self::assertEquals('it works', $val); } public function testCommandsLoaded(): void @@ -63,6 +119,41 @@ public function testCommandsLoaded(): void ->get(ConsoleApplication::class) ->has('some'); - $this->assertTrue($hasCommand); + self::assertTrue($hasCommand); + } + + public function testCommandExecuted(): void + { + $app = new Application(__DIR__, __DIR__ . '/stubs/config'); + + $code = $app->run(['', 'some']); + + self::assertSame(2, $code); + } + + public function testCommandBootstrapper(): void + { + $app = new Application(__DIR__, __DIR__ . '/stubs/config'); + /** @var Bootstrapper $bootstrapper */ + $bootstrapper = $app->getContainer()->get(Bootstrapper::class); + + $initTriggered = false; + $stopTriggered = false; + + $bootstrapper->addService( + 'test', + function () use (&$initTriggered) { + $initTriggered = true; + }, + function () use (&$stopTriggered) { + $stopTriggered = true; + } + ); + + $code = $app->run(['', 'some']); + + self::assertSame(2, $code); + self::assertTrue($initTriggered); + self::assertTrue($stopTriggered); } } diff --git a/tests/Application/SomeCommand.php b/tests/Application/SomeCommand.php index 903bdf5..7abfff0 100644 --- a/tests/Application/SomeCommand.php +++ b/tests/Application/SomeCommand.php @@ -10,19 +10,17 @@ namespace MakiseCo\Tests\Application; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use MakiseCo\Console\Commands\AbstractCommand; -class SomeCommand extends Command +class SomeCommand extends AbstractCommand { public function configure(): void { $this->setName('some'); } - public function execute(InputInterface $input, OutputInterface $output): int + public function handle(): int { - return 0; + return 2; } } diff --git a/tests/Application/stubs/config/app.php b/tests/Application/stubs/config/app.php index da4032e..260049b 100644 --- a/tests/Application/stubs/config/app.php +++ b/tests/Application/stubs/config/app.php @@ -8,12 +8,28 @@ declare(strict_types=1); +use function MakiseCo\Env\env; + return [ - 'name' => 'Makise-Co', + 'name' => env('APP_NAME', 'Makise-Co'), + 'env' => env('APP_ENV', 'local'), + 'debug' => (bool)env('APP_DEBUG', true), + 'url' => env('APP_URL', 'http://localhost'), + 'timezone' => env('APP_TIMEZONE', 'UTC'), + 'locale' => env('APP_LOCALE', 'en'), + 'providers' => [ + \MakiseCo\Log\LoggerServiceProvider::class, + \MakiseCo\Event\EventDispatcherServiceProvider::class, + \MakiseCo\Console\ConsoleServiceProvider::class, + // App service providers \MakiseCo\Tests\Application\SomeProvider::class, ], + 'commands' => [ + \MakiseCo\Console\Commands\MakiseCommand::class, + \MakiseCo\Console\Commands\DumpEnvCommand::class, + \MakiseCo\Console\Commands\DumpConfigCommand::class, \MakiseCo\Tests\Application\SomeCommand::class, - ] + ], ]; diff --git a/tests/Application/stubs/config/logging.php b/tests/Application/stubs/config/logging.php new file mode 100644 index 0000000..6946a1d --- /dev/null +++ b/tests/Application/stubs/config/logging.php @@ -0,0 +1,24 @@ + + */ + +declare(strict_types=1); + +use function MakiseCo\Env\env; + +return [ + [ + 'handler' => \MakiseCo\Log\Handler\StreamHandler::class, + 'formatter' => \MakiseCo\Log\Formatter\JsonFormatter::class, + // parameters passed to the handler constructor + 'handler_with' => [ + 'stream' => env('LOG_CHANNEL', 'php://stdout'), + ], + // parameters passed to the formatter constructor + 'formatter_with' => [], + ], +]; diff --git a/tests/Auth/AuthManagerTest.php b/tests/Auth/AuthManagerTest.php deleted file mode 100644 index ffefcd4..0000000 --- a/tests/Auth/AuthManagerTest.php +++ /dev/null @@ -1,71 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth; - -use MakiseCo\Auth\AuthManager; -use MakiseCo\Tests\Auth\Http\Stubs\CustomGuard; -use MakiseCo\Tests\Auth\Http\Stubs\CustomUserProvider; -use PHPUnit\Framework\TestCase; - -class AuthManagerTest extends TestCase -{ - public function testAddProvider(): void - { - $authManager = $this->getAuthManager(); - - $authManager->addProvider( - 'some', - CustomUserProvider::class, - [ - 'cacheTtl' => 120, - ], - ); - - /* @var CustomUserProvider $provider */ - $provider = $authManager->getProvider('some'); - - $this->assertInstanceOf(CustomUserProvider::class, $provider); - $this->assertEquals(120, $provider->getCacheTtl()); - } - - public function testAddGuard(): void - { - $authManager = $this->getAuthManager(); - - $authManager->addProvider( - 'some', - CustomUserProvider::class, - [ - 'cacheTtl' => 120, - ], - ); - $authManager->addGuard( - 'sso', - CustomGuard::class, - 'some', - [ - 'ban' => true, - ], - ); - - /* @var CustomGuard $guard */ - $guard = $authManager->getGuard('sso'); - - $this->assertInstanceOf(CustomGuard::class, $guard); - } - - protected function getAuthManager(): AuthManager - { - $container = (new \DI\ContainerBuilder)->build(); - - return new AuthManager($container); - } -} diff --git a/tests/Auth/Guard/BasicAuthGuardTest.php b/tests/Auth/Guard/BasicAuthGuardTest.php deleted file mode 100644 index fef11aa..0000000 --- a/tests/Auth/Guard/BasicAuthGuardTest.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Guard; - -use Laminas\Diactoros\ServerRequest; -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\Guard\BasicAuthGuard; -use MakiseCo\Http\FakeStream; -use MakiseCo\Tests\Auth\Http\Stubs\EmptyUserProvider; -use PHPUnit\Framework\TestCase; - -class BasicAuthGuardTest extends TestCase -{ - public function testItWorks(): void - { - $mock = $this->createMock(EmptyUserProvider::class); - $mock - ->expects(self::once()) - ->method('retrieveByCredentials') - ->with(['username' => 'username123', 'password' => 'password123']) - ->willReturn(new class implements AuthenticatableInterface { - public function getAuthIdentifier(): int - { - return 2; - } - }); - - $request = new ServerRequest( - [], - [], - '/', - 'GET', - new FakeStream(''), - ['Authorization' => \base64_encode('username123:password123')] - ); - - $guard = new BasicAuthGuard($mock, 'username', 'password'); - $user = $guard->authenticate($request); - - self::assertNotNull($user); - self::assertEquals(2, $user->getAuthIdentifier()); - } -} diff --git a/tests/Auth/Guard/BearerTokenGuardTest.php b/tests/Auth/Guard/BearerTokenGuardTest.php deleted file mode 100644 index a7f15f0..0000000 --- a/tests/Auth/Guard/BearerTokenGuardTest.php +++ /dev/null @@ -1,52 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Guard; - -use Laminas\Diactoros\ServerRequest; -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\Guard\BearerTokenGuard; -use MakiseCo\Http\FakeStream; -use MakiseCo\Http\Request; -use MakiseCo\Tests\Auth\Http\Stubs\EmptyUserProvider; -use PHPUnit\Framework\TestCase; - -class BearerTokenGuardTest extends TestCase -{ - public function testItWorks(): void - { - $mock = $this->createMock(EmptyUserProvider::class); - $mock - ->expects(self::once()) - ->method('retrieveByCredentials') - ->with(['bearer_token' => 'someSmartToken123']) - ->willReturn(new class implements AuthenticatableInterface { - public function getAuthIdentifier(): int - { - return 2; - } - }); - - $request = new ServerRequest( - [], - [], - '/', - 'GET', - new FakeStream(''), - ['Authorization' => 'Bearer someSmartToken123'] - ); - - $guard = new BearerTokenGuard($mock, 'bearer_token'); - $user = $guard->authenticate($request); - - self::assertNotNull($user); - self::assertEquals(2, $user->getAuthIdentifier()); - } -} diff --git a/tests/Auth/Http/Middleware/AuthenticationMiddlewareTest.php b/tests/Auth/Http/Middleware/AuthenticationMiddlewareTest.php deleted file mode 100644 index bc656fd..0000000 --- a/tests/Auth/Http/Middleware/AuthenticationMiddlewareTest.php +++ /dev/null @@ -1,161 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Http\Middleware; - -use Laminas\Diactoros\Response\TextResponse; -use Laminas\Diactoros\ServerRequest; -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\AuthManager; -use MakiseCo\Auth\Exceptions\UnauthenticatedException; -use MakiseCo\Auth\Guard\GuardInterface; -use MakiseCo\Auth\Http\Middleware\AuthenticationMiddleware; -use MakiseCo\Http\Router\Route; -use MakiseCo\Http\Router\RouteInterface; -use MakiseCo\Tests\Auth\Http\Stubs\AuthFailedGuard; -use MakiseCo\Tests\Auth\Http\Stubs\AuthSuccessGuard; -use MakiseCo\Tests\Auth\Http\Stubs\EmptyUserProvider; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; - -class AuthenticationMiddlewareTest extends TestCase -{ - public function testAuthSuccessful(): void - { - $middleware = new AuthenticationMiddleware($this->getAuthManager()); - - $request = new ServerRequest( - [], - [], - '/', - 'GET' - ); - - $route = new Route(['GET'], '/', fn() => 1); - $route - ->withAttribute(GuardInterface::class, 'success'); - - $request = $request - ->withAttribute(RouteInterface::class, $route) - ->withAttribute('test', $this); - - $handler = new class implements RequestHandlerInterface { - public function handle(ServerRequestInterface $request): ResponseInterface - { - /* @var AuthenticationMiddlewareTest $test */ - $test = $request->getAttribute('test'); - - /* @var AuthenticatableInterface $user */ - $user = $request->getAttribute(AuthenticatableInterface::class); - - $test::assertNotNull($user); - $test::assertInstanceOf(AuthenticatableInterface::class, $user); - $test::assertEquals(1, $user->getAuthIdentifier()); - - return new TextResponse(''); - } - }; - - $middleware->process($request, $handler); - } - - public function testUnauthorized(): void - { - $middleware = new AuthenticationMiddleware($this->getAuthManager()); - - $request = new ServerRequest( - [], - [], - '/', - 'GET' - ); - - $route = new Route(['GET'], '/', fn() => 1); - $route - ->withAttribute(GuardInterface::class, 'fail'); - - $request = $request - ->withAttribute(RouteInterface::class, $route) - ->withAttribute('test', $this); - - $handler = new class implements RequestHandlerInterface { - public function handle(ServerRequestInterface $request): ResponseInterface - { - return new TextResponse(''); - } - }; - - $this->expectException(UnauthenticatedException::class); - $middleware->process($request, $handler); - } - - public function testMultipleGuardsSuccess(): void - { - $authManager = $this->getAuthManager(); - $middleware = new AuthenticationMiddleware($authManager); - - $request = new ServerRequest( - [], - [], - '/', - 'GET' - ); - - $route = new Route(['GET'], '/', fn() => 1); - $route - ->withAttribute(GuardInterface::class, ['fail', 'success']); - - $request = $request - ->withAttribute(RouteInterface::class, $route) - ->withAttribute('test', $this); - - $handler = new class implements RequestHandlerInterface { - public function handle(ServerRequestInterface $request): ResponseInterface - { - /* @var AuthenticationMiddlewareTest $test */ - $test = $request->getAttribute('test'); - - /* @var AuthenticatableInterface $user */ - $user = $request->getAttribute(AuthenticatableInterface::class); - - $test::assertNotNull($user); - $test::assertInstanceOf(AuthenticatableInterface::class, $user); - $test::assertEquals(1, $user->getAuthIdentifier()); - - return new TextResponse(''); - } - }; - - $middleware->process($request, $handler); - - /* @var AuthFailedGuard $authFailedGuard */ - $authFailedGuard = $authManager->getGuard('fail'); - /* @var AuthSuccessGuard $authSuccessGuard */ - $authSuccessGuard = $authManager->getGuard('success'); - - self::assertTrue($authFailedGuard->isCalled()); - self::assertTrue($authSuccessGuard->isCalled()); - } - - protected function getAuthManager(): AuthManager - { - $container = (new \DI\ContainerBuilder)->build(); - - $authManager = new AuthManager($container); - $authManager->addProvider('test', EmptyUserProvider::class, []); - - $authManager->addGuard('success', AuthSuccessGuard::class, 'test', []); - $authManager->addGuard('fail', AuthFailedGuard::class, 'test', []); - - return $authManager; - } -} diff --git a/tests/Auth/Http/Middleware/AuthorizationMiddlewareTest.php b/tests/Auth/Http/Middleware/AuthorizationMiddlewareTest.php deleted file mode 100644 index 3b3e0e1..0000000 --- a/tests/Auth/Http/Middleware/AuthorizationMiddlewareTest.php +++ /dev/null @@ -1,274 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Http\Middleware; - -use Laminas\Diactoros\Response\TextResponse; -use Laminas\Diactoros\ServerRequest; -use MakiseCo\Auth\AuthorizableInterface; -use MakiseCo\Auth\Exceptions\AccessDeniedException; -use MakiseCo\Auth\Http\Middleware\AuthorizationMiddleware; -use MakiseCo\Http\Request; -use MakiseCo\Http\Response; -use MakiseCo\Http\Router\Route; -use MakiseCo\Http\Router\RouteInterface; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; - -class AuthorizationMiddlewareTest extends TestCase -{ - /** - * @param bool $authModeAll - * - * @doesNotPerformAssertions - * @testWith [true] - * [false] - */ - public function testAuthorizationByPermissionsSuccess(bool $authModeAll): void - { - $middleware = new AuthorizationMiddleware(); - - $request = new ServerRequest( - [], - [], - '/', - 'GET' - ); - - $route = new Route(['GET'], '/', fn() => 1); - $route - ->withAttribute( - 'auth_mode', - $authModeAll ? AuthorizationMiddleware::MODE_ALL : AuthorizationMiddleware::MODE_ANY - ) - ->withAttribute('permissions', ['test']); - - $request = $request - ->withAttribute(AuthorizableInterface::class, $this->getAuthorizableByPermissions($authModeAll)) - ->withAttribute('test', $this) - ->withAttribute(RouteInterface::class, $route); - - $middleware->process($request, $this->getEmptyHandler()); - } - - /** - * @param bool $authModeAll - * - * @testWith [true] - * [false] - */ - public function testAuthorizationByPermissionsFailed(bool $authModeAll): void - { - $middleware = new AuthorizationMiddleware(); - - $request = new ServerRequest( - [], - [], - '/', - 'GET' - ); - - $route = new Route(['GET'], '/', fn() => 1); - $route - ->withAttribute( - 'auth_mode', - $authModeAll ? AuthorizationMiddleware::MODE_ALL : AuthorizationMiddleware::MODE_ANY - ) - ->withAttribute('permissions', ['bad']); - - $request = $request - ->withAttribute(AuthorizableInterface::class, $this->getAuthorizableByPermissions($authModeAll)) - ->withAttribute('test', $this) - ->withAttribute(RouteInterface::class, $route); - - $this->expectException(AccessDeniedException::class); - $middleware->process($request, $this->getEmptyHandler()); - } - - /** - * @param bool $authModeAll - * - * @doesNotPerformAssertions - * @testWith [true] - * [false] - */ - public function testAuthorizationByRolesSuccess(bool $authModeAll): void - { - $middleware = new AuthorizationMiddleware(); - - $request = new ServerRequest( - [], - [], - '/', - 'GET' - ); - - $route = new Route(['GET'], '/', fn() => 1); - $route - ->withAttribute( - 'auth_mode', - $authModeAll ? AuthorizationMiddleware::MODE_ALL : AuthorizationMiddleware::MODE_ANY - ) - ->withAttribute('roles', ['test']); - - $request = $request - ->withAttribute(AuthorizableInterface::class, $this->getAuthorizableByRoles($authModeAll)) - ->withAttribute('test', $this) - ->withAttribute(RouteInterface::class, $route); - - $middleware->process($request, $this->getEmptyHandler()); - } - - /** - * @param bool $authModeAll - * - * @testWith [true] - * [false] - */ - public function testAuthorizationByRolesFailed(bool $authModeAll): void - { - $middleware = new AuthorizationMiddleware(); - - $request = new ServerRequest( - [], - [], - '/', - 'GET' - ); - - $route = new Route(['GET'], '/', fn() => 1); - $route - ->withAttribute( - 'auth_mode', - $authModeAll ? AuthorizationMiddleware::MODE_ALL : AuthorizationMiddleware::MODE_ANY - ) - ->withAttribute('roles', ['bad']); - - $request = $request - ->withAttribute(AuthorizableInterface::class, $this->getAuthorizableByRoles($authModeAll)) - ->withAttribute('test', $this) - ->withAttribute(RouteInterface::class, $route); - - $this->expectException(AccessDeniedException::class); - $middleware->process($request, $this->getEmptyHandler()); - } - - protected function getEmptyHandler(): RequestHandlerInterface - { - return new class implements RequestHandlerInterface { - public function handle(ServerRequestInterface $request): ResponseInterface - { - return new TextResponse(''); - } - }; - } - - protected function getAuthorizableByPermissions(bool $all): AuthorizableInterface - { - return new class($all) implements AuthorizableInterface { - private bool $all; - - public function __construct(bool $all) - { - $this->all = $all; - } - - public function hasAllRoles(array $roles): bool - { - return false; - } - - public function hasAllPermissions(array $permissions): bool - { - if (!$this->all) { - return false; - } - - return ['test'] === $permissions; - } - - public function hasRole(string $role): bool - { - return false; - } - - public function hasPermission(string $permission): bool - { - return false; - } - - public function hasAnyRoles(array $roles): bool - { - return false; - } - - public function hasAnyPermissions(array $permissions): bool - { - if (!$this->all) { - return ['test'] === $permissions; - } - - return false; - } - }; - } - - protected function getAuthorizableByRoles(bool $all): AuthorizableInterface - { - return new class($all) implements AuthorizableInterface { - private bool $all; - - public function __construct(bool $all) - { - $this->all = $all; - } - - public function hasAllRoles(array $roles): bool - { - if (!$this->all) { - return false; - } - - return ['test'] === $roles; - } - - public function hasAllPermissions(array $permissions): bool - { - return false; - } - - public function hasRole(string $role): bool - { - return false; - } - - public function hasPermission(string $permission): bool - { - return false; - } - - public function hasAnyRoles(array $roles): bool - { - if (!$this->all) { - return ['test'] === $roles; - } - - return false; - } - - public function hasAnyPermissions(array $permissions): bool - { - return false; - } - }; - } -} diff --git a/tests/Auth/Http/Stubs/AuthFailedGuard.php b/tests/Auth/Http/Stubs/AuthFailedGuard.php deleted file mode 100644 index 06ad38c..0000000 --- a/tests/Auth/Http/Stubs/AuthFailedGuard.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Http\Stubs; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\Guard\GuardInterface; -use Psr\Http\Message\ServerRequestInterface; - -class AuthFailedGuard implements GuardInterface -{ - private bool $isCalled = false; - - public function authenticate(ServerRequestInterface $request): ?AuthenticatableInterface - { - $this->isCalled = true; - - return null; - } - - public function isCalled(): bool - { - return $this->isCalled; - } -} diff --git a/tests/Auth/Http/Stubs/AuthSuccessGuard.php b/tests/Auth/Http/Stubs/AuthSuccessGuard.php deleted file mode 100644 index 9cd7492..0000000 --- a/tests/Auth/Http/Stubs/AuthSuccessGuard.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Http\Stubs; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\Guard\GuardInterface; -use Psr\Http\Message\ServerRequestInterface; - -class AuthSuccessGuard implements GuardInterface -{ - private bool $isCalled = false; - - public function authenticate(ServerRequestInterface $request): AuthenticatableInterface - { - $this->isCalled = true; - - return new class implements AuthenticatableInterface - { - public function getAuthIdentifier(): int - { - return 1; - } - }; - } - - public function isCalled(): bool - { - return $this->isCalled; - } -} diff --git a/tests/Auth/Http/Stubs/CustomGuard.php b/tests/Auth/Http/Stubs/CustomGuard.php deleted file mode 100644 index e0116bb..0000000 --- a/tests/Auth/Http/Stubs/CustomGuard.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Http\Stubs; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\Guard\GuardInterface; -use Psr\Http\Message\ServerRequestInterface; - -class CustomGuard implements GuardInterface -{ - private CustomUserProvider $provider; - private bool $ban; - - public function __construct(CustomUserProvider $provider, bool $ban) - { - $this->provider = $provider; - $this->ban = $ban; - } - - public function authenticate(ServerRequestInterface $request): ?AuthenticatableInterface - { - return null; - } -} diff --git a/tests/Auth/Http/Stubs/CustomUserProvider.php b/tests/Auth/Http/Stubs/CustomUserProvider.php deleted file mode 100644 index e9a52b4..0000000 --- a/tests/Auth/Http/Stubs/CustomUserProvider.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Http\Stubs; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\UserProviderInterface; - -class CustomUserProvider implements UserProviderInterface -{ - private int $cacheTtl; - - public function __construct(int $cacheTtl) - { - $this->cacheTtl = $cacheTtl; - } - - public function getCacheTtl(): int - { - return $this->cacheTtl; - } - - public function retrieveById($id): ?AuthenticatableInterface - { - return null; - } - - public function retrieveByCredentials(array $credentials): ?AuthenticatableInterface - { - return null; - } -} diff --git a/tests/Auth/Http/Stubs/EmptyUserProvider.php b/tests/Auth/Http/Stubs/EmptyUserProvider.php deleted file mode 100644 index ad29404..0000000 --- a/tests/Auth/Http/Stubs/EmptyUserProvider.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Auth\Http\Stubs; - -use MakiseCo\Auth\AuthenticatableInterface; -use MakiseCo\Auth\UserProviderInterface; - -class EmptyUserProvider implements UserProviderInterface -{ - public function retrieveById($id): ?AuthenticatableInterface - { - return null; - } - - public function retrieveByCredentials(array $credentials): ?AuthenticatableInterface - { - return null; - } -} diff --git a/tests/Config/RepositoryTest.php b/tests/Config/RepositoryTest.php index 0ffc6a3..0456040 100644 --- a/tests/Config/RepositoryTest.php +++ b/tests/Config/RepositoryTest.php @@ -21,7 +21,7 @@ public function testRead(): void 'app' => ['name' => 'Makise'] ]); - $this->assertEquals( + self::assertEquals( 'Makise', $repo->get('app.name') ); @@ -33,7 +33,7 @@ public function testReadArray(): void 'app' => ['name' => 'Makise'] ]); - $this->assertEquals( + self::assertEquals( 'Makise', $repo->get('app')['name'] ); @@ -47,7 +47,7 @@ public function testWrite(): void $items = $repo->toArray(); - $this->assertEquals('Makise', $items['app']['name']); + self::assertEquals('Makise', $items['app']['name']); } public function testWriteArray(): void @@ -58,7 +58,7 @@ public function testWriteArray(): void $items = $repo->toArray(); - $this->assertEquals('Makise', $items['app']['name']); + self::assertEquals('Makise', $items['app']['name']); } public function testUnset(): void @@ -68,7 +68,7 @@ public function testUnset(): void $repo->set('app.name', 'Makise'); unset($repo['app.name']); - $this->assertNull($repo->get('app.name')); + self::assertNull($repo->get('app.name')); } public function testUnsetArray(): void @@ -80,6 +80,6 @@ public function testUnsetArray(): void $repo->set('app.name', 'Makise'); unset($repo['app']); - $this->assertNull($repo->get('app')); + self::assertNull($repo->get('app')); } } diff --git a/tests/Console/AbstractCommandTest.php b/tests/Console/AbstractCommandTest.php index 17f0f9d..3e4ddd3 100644 --- a/tests/Console/AbstractCommandTest.php +++ b/tests/Console/AbstractCommandTest.php @@ -27,17 +27,17 @@ public function testRun(): void $consoleApp->setAutoExit(false); $consoleApp->add($command); - $this->assertTrue($consoleApp->has('hello')); + self::assertTrue($consoleApp->has('hello')); $output = $this->createMock(ConsoleOutput::class); $output - ->expects($this->once()) + ->expects(self::once()) ->method('write') ->with('Hello, Okabe'); $exitCode = $consoleApp->run(new ArrayInput(['command' => 'hello']), $output); - $this->assertSame(0, $exitCode); + self::assertSame(0, $exitCode); } private function makeCommand(): AbstractCommand @@ -47,7 +47,7 @@ private function makeCommand(): AbstractCommand ->method('getContainer') ->willReturn(new Container()); - return new class($appMock) extends AbstractCommand { + $cmd = new class() extends AbstractCommand { protected string $name = 'hello'; public function handle(): void @@ -55,5 +55,8 @@ public function handle(): void $this->write('Hello, Okabe'); } }; + $cmd->setMakise($appMock); + + return $cmd; } } diff --git a/tests/Console/Commands/DumpConfigCommandTest.php b/tests/Console/Commands/DumpConfigCommandTest.php new file mode 100644 index 0000000..1f26238 --- /dev/null +++ b/tests/Console/Commands/DumpConfigCommandTest.php @@ -0,0 +1,30 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\Tests\Console\Commands; + +use MakiseCo\Application; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; + +class DumpConfigCommandTest extends TestCase +{ + public function testConfigDump(): void + { + $app = new Application(__DIR__, dirname(__DIR__) . '/../Application/stubs/config'); + $cmd = $app + ->getContainer() + ->get(\Symfony\Component\Console\Application::class) + ->find('config:dump'); + + $tester = new CommandTester($cmd); + $tester->execute([]); + } +} diff --git a/tests/Console/Commands/DumpEnvCommandTest.php b/tests/Console/Commands/DumpEnvCommandTest.php new file mode 100644 index 0000000..af09f8b --- /dev/null +++ b/tests/Console/Commands/DumpEnvCommandTest.php @@ -0,0 +1,84 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\Tests\Console\Commands; + +use MakiseCo\Application; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; + +class DumpEnvCommandTest extends TestCase +{ + protected function setUp(): void + { + unset($_ENV['APP_NAME'], $_SERVER['APP_NAME']); + unset($_ENV['APP_ENV'], $_SERVER['APP_ENV']); + } + + protected function tearDown(): void + { + unset($_ENV['APP_NAME'], $_SERVER['APP_NAME']); + unset($_ENV['APP_ENV'], $_SERVER['APP_ENV']); + } + + public function testEnvDump(): void + { + $_ENV['APP_NAME'] = $_SERVER['APP_NAME'] = 'EnvDump'; + + $app = new Application(__DIR__, dirname(__DIR__) . '/../Application/stubs/config'); + $cmd = $app + ->getContainer() + ->get(\Symfony\Component\Console\Application::class) + ->find('env:dump'); + + $tester = new CommandTester($cmd); + $tester->execute([]); + + $output = $tester->getDisplay(true); + + self::assertStringContainsString('APP_NAME=EnvDump', $output); + } + + public function testEnvDumpWithEnvFile(): void + { + file_put_contents(__DIR__ . '/.env', 'APP_NAME=EnvDumpFile'); + + $app = new Application(__DIR__, dirname(__DIR__) . '/../Application/stubs/config'); + $cmd = $app + ->getContainer() + ->get(\Symfony\Component\Console\Application::class) + ->find('env:dump'); + + $tester = new CommandTester($cmd); + $tester->execute([]); + + $output = $tester->getDisplay(true); + + try { + self::assertStringContainsString('APP_NAME=EnvDumpFile', $output); + } finally { + unlink(__DIR__ . '/.env'); + } + } + + public function testEnvDumpWithEnvFileAndCliArg(): void + { + file_put_contents(__DIR__ . '/.env', 'APP_NAME=EnvDumpFile'); + file_put_contents(__DIR__ . '/.env.testing', 'APP_NAME=EnvDumpFileOverload'); + + $app = new Application(__DIR__, dirname(__DIR__) . '/../Application/stubs/config'); + $code = $app->run(['makise', 'env:dump', '--env=testing']); + + self::assertSame(0, $code); + + unlink(__DIR__ . '/.env'); + unlink(__DIR__ . '/.env.testing'); + } +} diff --git a/tests/Http/Exceptions/ExceptionHandlerTest.php b/tests/Http/Exceptions/ExceptionHandlerTest.php deleted file mode 100644 index 79ed586..0000000 --- a/tests/Http/Exceptions/ExceptionHandlerTest.php +++ /dev/null @@ -1,104 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Http\Exceptions; - -use InvalidArgumentException; -use Laminas\Diactoros\ServerRequest; -use MakiseCo\Config\ConfigRepositoryInterface; -use MakiseCo\Config\Repository; -use MakiseCo\Http\Exceptions\JsonExceptionHandler; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; - -use function array_key_exists; - -class ExceptionHandlerTest extends TestCase -{ - /** - * @return LoggerInterface|\PHPUnit\Framework\MockObject\MockObject - */ - protected function getFakeLogger(): LoggerInterface - { - return $this->createMock(NullLogger::class); - } - - /** - * @return ConfigRepositoryInterface|\PHPUnit\Framework\MockObject\MockObject - */ - protected function getFakeConfig(): ConfigRepositoryInterface - { - return $this->createMock(Repository::class); - } - - protected function getFakeRequest(string $method, string $uri): ServerRequestInterface - { - return new ServerRequest( - [ - 'REQUEST_METHOD' => $method, - 'REQUEST_URI' => $uri - ], - [], - $uri, - $method - ); - } - - /** - * @testdox Check that the ExceptionHandler is logging request method and request URI - */ - public function testLoggingRequestInfo(): void - { - $config = $this->getFakeConfig(); - $config - ->method('get') - ->with('app.debug') - ->willReturn(true); - - $method = 'GET'; - $uri = '/makise?some=1'; - $message = 'Something went wrong'; - - $logger = $this->getFakeLogger(); - $logger - ->expects(self::once()) - ->method('error') - ->with( - $message, - self::callback(static function (array $args) use ($method, $uri) { - if (!array_key_exists('extra', $args)) { - return false; - } - - $extra = $args['extra']; - - if (!array_key_exists('uri', $extra) || $uri !== $extra['uri']) { - return false; - } - - if (!array_key_exists('method', $extra) || $method !== $extra['method']) { - return false; - } - - return true; - }) - ); - - $handler = new JsonExceptionHandler($config, $logger); - - $request = $this->getFakeRequest($method, $uri); - - $exception = new InvalidArgumentException($message); - - $handler->handle($exception, $request); - } -} diff --git a/tests/Http/FakeStreamTest.php b/tests/Http/FakeStreamTest.php deleted file mode 100644 index b4f804f..0000000 --- a/tests/Http/FakeStreamTest.php +++ /dev/null @@ -1,61 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Http; - -use MakiseCo\Http\FakeStream; -use PHPUnit\Framework\TestCase; - -class FakeStreamTest extends TestCase -{ - private const DEFAULT_CONTENT = 'This is a test!'; - - public function testEmptyBody(): void - { - $stream = new FakeStream(''); - - $this->assertTrue($stream->eof()); - $this->assertSame('', $stream->read(2)); - } - - public function testCrossCase(): void - { - $chunkedString = str_pad('', 8192 + 1, '0'); - - $stream = new FakeStream($chunkedString); - $read = 0; - - while (!$stream->eof()) { - $content = $stream->read(8192); - $read += strlen($content); - } - - $this->assertSame(8192 + 1, $read); - } - - public function testGetContents(): void - { - $stream = new FakeStream(self::DEFAULT_CONTENT); - $content = $stream->getContents(); - - $this->assertSame(self::DEFAULT_CONTENT, $content); - $this->assertTrue($stream->eof()); - } - - public function testGetContentsReturnsOnlyFromIndexForward(): void - { - $stream = new FakeStream(self::DEFAULT_CONTENT); - - $index = 10; - $stream->seek($index); - - $this->assertSame(substr(self::DEFAULT_CONTENT, $index), $stream->getContents()); - } -} diff --git a/tests/Testing/MakesRequestTraitTest.php b/tests/Testing/MakesRequestTraitTest.php deleted file mode 100644 index 188340d..0000000 --- a/tests/Testing/MakesRequestTraitTest.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Testing; - -use DI\Container; -use MakiseCo\Testing\Concerns\MakesHttpRequests; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; - -class MakesRequestTraitTest extends TestCase -{ - use MakesHttpRequests; - - protected Container $container; - /** - * @var RequestHandlerInterface|\PHPUnit\Framework\MockObject\MockObject - */ - protected RequestHandlerInterface $requestHandlerMock; - - protected function setUp(): void - { - $this->container = new Container(); - - $this->requestHandlerMock = $this->createMock(RequestHandlerInterface::class); - $this->container->set('http.request_handler', $this->requestHandlerMock); - } - - public function testGet(): void - { - $this - ->requestHandlerMock - ->expects(self::once()) - ->method('handle') - ->with(self::callback(function (ServerRequestInterface $request) { - return '/some' === $request->getRequestTarget() && 'GET' === $request->getMethod() - && count($request->getHeaders()) === 1 && $request->getHeader('Authorization') === ['Bearer 123']; - })); - - $this->get('/some', ['Authorization' => 'Bearer 123']); - } - - public function testPost(): void - { - $this - ->requestHandlerMock - ->expects(self::once()) - ->method('handle') - ->with(self::callback(function (ServerRequestInterface $request) { - return '/some' === $request->getRequestTarget() - && 'POST' === $request->getMethod() - && 1 === $request->getParsedBody()['some']; - })); - - $this->post('/some', ['some' => 1]); - } - - public function testJson(): void - { - $this - ->requestHandlerMock - ->expects(self::once()) - ->method('handle') - ->with(self::callback(function (ServerRequestInterface $request) { - return '/some' === $request->getRequestTarget() - && 'POST' === $request->getMethod() - && \json_encode(['some' => 1]) === $request->getBody()->__toString() - && ['some' => 1] === $request->getParsedBody(); - })); - - $this->postJson('/some', ['some' => 1]); - } -} diff --git a/tests/Testing/SomeHelper.php b/tests/Testing/SomeHelper.php new file mode 100644 index 0000000..cb213b3 --- /dev/null +++ b/tests/Testing/SomeHelper.php @@ -0,0 +1,27 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\Tests\Testing; + +trait SomeHelper +{ + private bool $someHelperBooted = false; + private bool $someHelperStopped = false; + + public function bootSomeHelper(): void + { + $this->someHelperBooted = true; + } + + public function cleanupSomeHelper(): void + { + $this->someHelperStopped = true; + } +} diff --git a/tests/Testing/TestCaseTest.php b/tests/Testing/TestCaseTest.php new file mode 100644 index 0000000..5ba44fc --- /dev/null +++ b/tests/Testing/TestCaseTest.php @@ -0,0 +1,85 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\Tests\Testing; + +use MakiseCo\Application; +use MakiseCo\ApplicationInterface; +use MakiseCo\Bootstrapper; +use MakiseCo\Testing\TestCase; +use Swoole\Coroutine; + +class TestCaseTest extends TestCase +{ + use SomeHelper; + + private bool $serviceBooted = false; + private bool $serviceStopped = false; + + protected function createApplication(): ApplicationInterface + { + $app = new Application( + dirname(__DIR__) . '/Application/stubs/', + dirname(__DIR__) . '/Application/stubs/config/' + ); + + $app->getContainer()->get(Bootstrapper::class)->addService( + 'test', + function () { + $this->serviceBooted = true; + }, + function () { + $this->serviceStopped = true; + } + ); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + // setUp should run in the coroutine + self::assertGreaterThan(0, Coroutine::getCid()); + } + + protected function tearDown(): void + { + // tearDown should run in the coroutine + self::assertGreaterThan(0, Coroutine::getCid()); + + parent::tearDown(); + } + + public function testTraitBooted(): void + { + self::assertTrue($this->someHelperBooted); + } + + public function testTraitCleanedUp(): void + { + Coroutine::defer(function () { + self::assertTrue($this->someHelperStopped); + }); + } + + public function testServiceBootstrapped(): void + { + self::assertTrue($this->serviceBooted); + } + + public function testServiceStopped(): void + { + Coroutine::defer(function () { + self::assertTrue($this->serviceStopped); + }); + } +} diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php deleted file mode 100644 index a9f7672..0000000 --- a/tests/Testing/TestResponseTest.php +++ /dev/null @@ -1,90 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\Tests\Testing; - -use Laminas\Diactoros\Response; -use MakiseCo\Testing\Http\TestResponse; -use PHPUnit\Framework\TestCase; - -class TestResponseTest extends TestCase -{ - protected const COMPLICATED_DATA = [ - 'data' => [ - [ - 'id' => 1, - 'name' => 2, - 'object' => [ - 'some' => 3, - ] - ] - ] - ]; - - public function testAssertStatusCode(): void - { - $makiseResponse = new Response\TextResponse('Hello world', 200); - $response = new TestResponse($makiseResponse); - - $response->assertStatus(200); - } - - public function testAssertJsonStructure(): void - { - $complicatedStructure = [ - 'data' => [ - [ - 'id', - 'name', - 'object' => [ - 'some' - ] - ] - ] - ]; - - $makiseResponse = new Response\JsonResponse(self::COMPLICATED_DATA, 200); - $response = new TestResponse($makiseResponse); - - $response->assertJsonStructure($complicatedStructure); - } - - public function testAssertJson(): void - { - $makiseResponse = new Response\JsonResponse(self::COMPLICATED_DATA, 200); - $response = new TestResponse($makiseResponse); - - $response->assertJson(self::COMPLICATED_DATA); - } - - public function testAssertJsonFragment(): void - { - $makiseResponse = new Response\JsonResponse(self::COMPLICATED_DATA, 200); - $response = new TestResponse($makiseResponse); - - $response->assertJsonFragment(['some' => 3]); - } - - public function testAssertJsonCount(): void - { - $makiseResponse = new Response\JsonResponse(self::COMPLICATED_DATA, 200); - $response = new TestResponse($makiseResponse); - - $response->assertJsonCount(1, 'data'); - } - - public function testAssertSee(): void - { - $makiseResponse = new Response\TextResponse('

Some value


Bla'); - $response = new TestResponse($makiseResponse); - - $response->assertSee('Bla'); - } -}