diff --git a/.gitignore b/.gitignore index c3d0fd16..e3c80c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.idea/* /vendor/* /.env -.phpunit.result.cache +/.phpunit.result.cache +/.vscode diff --git a/bin/console b/bin/console index 7e907f2f..c90901f4 100755 --- a/bin/console +++ b/bin/console @@ -6,46 +6,45 @@ declare(strict_types=1); use App\Command; error_reporting(E_ALL); +ini_set('error_reporting', 'On'); ini_set('display_errors', 'On'); require __DIR__ . '/../pre_init.php'; -set_error_handler(function (int $number, mixed $error, mixed $file, int $line) { +/** + * Throws an exception based on an error code. + * + * @param int $number The error code. + * @param mixed $error The error message. + * @param mixed $file The file where the error occurred. + * @param int $line The line number where the error occurred. + * + * @throws ErrorException When the error code is not suppressed by error_reporting. + */ +$errorHandler = function (int $number, mixed $error, mixed $file, int $line) { $errno = $number & error_reporting(); - static $errorLevels = [ - E_ERROR => 'Error', - E_WARNING => 'Warning', - E_PARSE => 'Parser Error', - E_NOTICE => 'Notice', - E_CORE_ERROR => 'Core Error', - E_CORE_WARNING => 'Core Warning', - E_COMPILE_ERROR => 'Compile Error', - E_COMPILE_WARNING => 'Compile Warning', - E_USER_ERROR => 'User Error', - E_USER_WARNING => 'User Warning', - E_USER_NOTICE => 'User notice', - E_STRICT => 'Strict Notice', - E_RECOVERABLE_ERROR => 'Recoverable Error' - ]; - if (0 === $errno) { return; } - $message = sprintf('%s: %s (%s:%d).', $errorLevels[$number] ?? (string)$number, $error, $file, $line); - fwrite(STDERR, trim($message) . PHP_EOL); - - exit(501); -}); + throw new ErrorException($error, $number, 1, $file, $line); +}; +set_error_handler($errorHandler); set_exception_handler(function (Throwable $e) { - $message = sprintf('%s: %s (%s:%d).', get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()); - fwrite(STDERR, trim($message) . PHP_EOL); + $message = strtr('{kind}: {message} ({file}:{line}).', [ + '{kind}' => $e::class, + '{line}' => $e->getLine(), + '{message}' => $e->getMessage(), + '{file}' => after($e->getFile(), ROOT_PATH), + ]); + + fwrite(STDERR, $message . PHP_EOL); exit(502); }); if (!file_exists(__DIR__ . '/../vendor/autoload.php')) { - fwrite(STDERR, 'Dependencies are missing.' . PHP_EOL); + print 'Dependencies are missing please refer to https://github.com/arabcoders/watchstate/blob/master/FAQ.md'; exit(Command::FAILURE); } @@ -54,14 +53,16 @@ require __DIR__ . '/../vendor/autoload.php'; try { $app = (new App\Libs\Initializer())->boot(); } catch (Throwable $e) { - $message = sprintf( - 'Unhandled Exception [%s] was thrown in CLI boot context. With message [%s] in [%s:%d].', - $e::class, - $e->getMessage(), - array_reverse(explode(ROOT_PATH, $e->getFile(), 2))[0], - $e->getLine() + $message = strtr( + 'CLI: Exception [{kind}] was thrown unhandled during CLI boot context. Error [{message} @ {file}:{line}].', + [ + '{kind}' => $e::class, + '{line}' => $e->getLine(), + '{message}' => $e->getMessage(), + '{file}' => array_reverse(explode(ROOT_PATH, $e->getFile(), 2))[0], + ] ); - fwrite(STDERR, trim($message) . PHP_EOL); + fwrite(STDERR, $message . PHP_EOL); exit(503); } diff --git a/config/services.php b/config/services.php index 4b6f5dfb..0c484b78 100644 --- a/config/services.php +++ b/config/services.php @@ -8,6 +8,7 @@ use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\PDO\PDOAdapter; use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface; +use App\Libs\Exceptions\RuntimeException; use App\Libs\Extends\ConsoleOutput; use App\Libs\Extends\HttpClient; use App\Libs\Extends\LogMessageProcessor; diff --git a/public/index.php b/public/index.php index 89fe1ff6..f9e1b0cf 100644 --- a/public/index.php +++ b/public/index.php @@ -17,23 +17,35 @@ if (!file_exists(__DIR__ . '/../vendor/autoload.php')) { require __DIR__ . '/../vendor/autoload.php'; -set_error_handler(function (int $number, mixed $error, mixed $file, int $line) { +/** + * Throws an exception based on an error code. + * + * @param int $number The error code. + * @param mixed $error The error message. + * @param mixed $file The file where the error occurred. + * @param int $line The line number where the error occurred. + * + * @throws ErrorException When the error code is not suppressed by error_reporting. + */ +$errorHandler = function (int $number, mixed $error, mixed $file, int $line) { $errno = $number & error_reporting(); if (0 === $errno) { return; } - $message = trim(sprintf('%s: %s (%s:%d)', $number, $error, $file, $line)); - $out = fn($message) => inContainer() ? fwrite(STDERR, $message) : syslog(LOG_ERR, $message); - $out($message); + throw new ErrorException($error, $number, 1, $file, $line); +}; - exit(Command::FAILURE); -}); +set_error_handler($errorHandler); set_exception_handler(function (Throwable $e) { - $message = trim(sprintf("%s: %s (%s:%d).", get_class($e), $e->getMessage(), $e->getFile(), $e->getLine())); $out = fn($message) => inContainer() ? fwrite(STDERR, $message) : syslog(LOG_ERR, $message); - $out($message); + $out(r(text: '{kind}: {message} ({file}:{line}).', context: [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ])); exit(Command::FAILURE); }); @@ -45,16 +57,17 @@ try { $app = (new App\Libs\Initializer())->boot(); } catch (Throwable $e) { - fwrite( - STDERR, - trim( - sprintf( - 'Unhandled Exception [%s] was thrown at HTTP boot context. With message [%s] in [%s:%d].', - $e::class, - $e->getMessage(), - array_reverse(explode(ROOT_PATH, $e->getFile(), 2))[0], - $e->getLine() - ) + $out = fn($message) => inContainer() ? fwrite(STDERR, $message) : syslog(LOG_ERR, $message); + + $out( + r( + text: 'HTTP: Exception [{kind}] was thrown unhandled during HTTP boot context. Error [{message} @ {file}:{line}].', + context: [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ] ) ); diff --git a/src/Backends/Emby/EmbyClient.php b/src/Backends/Emby/EmbyClient.php index ff2880db..6e817c49 100644 --- a/src/Backends/Emby/EmbyClient.php +++ b/src/Backends/Emby/EmbyClient.php @@ -29,7 +29,7 @@ use App\Backends\Jellyfin\JellyfinClient; use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface as iState; -use App\Libs\HttpException; +use App\Libs\Exceptions\HttpException; use App\Libs\Mappers\ImportInterface as iImport; use App\Libs\Options; use App\Libs\QueueRequests; diff --git a/src/Backends/Jellyfin/JellyfinClient.php b/src/Backends/Jellyfin/JellyfinClient.php index d7f4f1a3..d35e1b34 100644 --- a/src/Backends/Jellyfin/JellyfinClient.php +++ b/src/Backends/Jellyfin/JellyfinClient.php @@ -27,7 +27,7 @@ use App\Backends\Jellyfin\Action\SearchQuery; use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface as iState; -use App\Libs\HttpException; +use App\Libs\Exceptions\HttpException; use App\Libs\Mappers\ImportInterface as iImport; use App\Libs\Options; use App\Libs\QueueRequests; diff --git a/src/Backends/Plex/PlexClient.php b/src/Backends/Plex/PlexClient.php index 6be1ad0e..5cadbe0e 100644 --- a/src/Backends/Plex/PlexClient.php +++ b/src/Backends/Plex/PlexClient.php @@ -29,7 +29,7 @@ use App\Backends\Plex\Action\SearchQuery; use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface as iState; -use App\Libs\HttpException; +use App\Libs\Exceptions\HttpException; use App\Libs\Mappers\ImportInterface as iImport; use App\Libs\Options; use App\Libs\QueueRequests; diff --git a/src/Command.php b/src/Command.php index 61e870ac..f8b68f4a 100644 --- a/src/Command.php +++ b/src/Command.php @@ -6,9 +6,9 @@ namespace App; use App\Backends\Common\ClientInterface as iClient; use App\Libs\Config; +use App\Libs\Exceptions\RuntimeException; use Closure; use DirectoryIterator; -use RuntimeException; use Symfony\Component\Console\Command\Command as BaseCommand; use Symfony\Component\Console\Command\LockableTrait; use Symfony\Component\Console\Completion\CompletionInput; diff --git a/src/Commands/State/ExportCommand.php b/src/Commands/State/ExportCommand.php index 260e5d6c..7a2f3893 100644 --- a/src/Commands/State/ExportCommand.php +++ b/src/Commands/State/ExportCommand.php @@ -14,8 +14,8 @@ use App\Libs\Message; use App\Libs\Options; use App\Libs\QueueRequests; use App\Libs\Routable; +use App\Libs\Stream; use Monolog\Logger; -use Nyholm\Psr7\Stream; use Psr\Log\LoggerInterface as iLogger; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; diff --git a/src/Commands/State/ImportCommand.php b/src/Commands/State/ImportCommand.php index 7cc9bf1b..e1d1eb2f 100644 --- a/src/Commands/State/ImportCommand.php +++ b/src/Commands/State/ImportCommand.php @@ -17,8 +17,8 @@ use App\Libs\Mappers\ImportInterface as iImport; use App\Libs\Message; use App\Libs\Options; use App\Libs\Routable; +use App\Libs\Stream; use Monolog\Logger; -use Nyholm\Psr7\Stream; use Psr\Log\LoggerInterface as iLogger; use RuntimeException; use Symfony\Component\Console\Helper\Table; diff --git a/src/Libs/Container.php b/src/Libs/Container.php index 32120112..0b0bcf03 100644 --- a/src/Libs/Container.php +++ b/src/Libs/Container.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Libs; +use App\Libs\Exceptions\RuntimeException; use App\Libs\Extends\PSRContainer as BaseContainer; use League\Container\ReflectionContainer; -use RuntimeException; /** * Container class provides a dependency injection container implementation. diff --git a/src/Libs/Exceptions/ErrorException.php b/src/Libs/Exceptions/ErrorException.php new file mode 100644 index 00000000..a7a7961f --- /dev/null +++ b/src/Libs/Exceptions/ErrorException.php @@ -0,0 +1,12 @@ + $db, 'db_list' => implode(', ', array_map(fn($f) => after($f, 'guid_'), array_keys(self::SUPPORTED))), @@ -232,7 +231,7 @@ final class Guid implements JsonSerializable, Stringable return true; } - if (1 !== preg_match(self::VALIDATE_GUID[$lookup]['pattern'], $id)) { + if (1 !== @preg_match(self::VALIDATE_GUID[$lookup]['pattern'], $id)) { throw new InvalidArgumentException( r('Invalid [{value}] value for [{db}]. Expecting [{example}].', [ 'db' => $db, diff --git a/src/Libs/Initializer.php b/src/Libs/Initializer.php index 636c5c5c..424fc7f7 100644 --- a/src/Libs/Initializer.php +++ b/src/Libs/Initializer.php @@ -6,6 +6,9 @@ namespace App\Libs; use App\Cli; use App\Libs\Entity\StateInterface as iState; +use App\Libs\Exceptions\Backends\RuntimeException; +use App\Libs\Exceptions\HttpException; +use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\Extends\ConsoleHandler; use App\Libs\Extends\ConsoleOutput; use Closure; @@ -24,8 +27,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; -use Psr\SimpleCache\InvalidArgumentException; -use RuntimeException; use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Yaml\Yaml; @@ -129,7 +130,6 @@ final class Initializer if (!(error_reporting() & $severity)) { return; } - /** @noinspection PhpUnhandledExceptionInspection */ throw new ErrorException($message, 0, $severity, $file, $line); } ); @@ -222,7 +222,8 @@ final class Initializer * @param iRequest $realRequest The incoming HTTP request. * * @return ResponseInterface The HTTP response. - * @throws InvalidArgumentException If an error occurs. + * + * @throws \Psr\SimpleCache\InvalidArgumentException If an error occurs. */ private function defaultHttpServer(iRequest $realRequest): ResponseInterface { @@ -262,7 +263,7 @@ final class Initializer try { $class = makeBackend($info, $name); - } catch (RuntimeException $e) { + } catch (InvalidArgumentException $e) { $this->write( request: $request, level: Level::Error, diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php index 667ab825..669627f1 100644 --- a/src/Libs/Mappers/Import/DirectMapper.php +++ b/src/Libs/Mappers/Import/DirectMapper.php @@ -15,8 +15,7 @@ use DateTimeInterface as iDate; use Exception; use PDOException; use Psr\Log\LoggerInterface as iLogger; -use Psr\SimpleCache\CacheInterface; -use Psr\SimpleCache\InvalidArgumentException; +use Psr\SimpleCache\CacheInterface as iCache; /** * DirectMapper Class. @@ -73,9 +72,9 @@ final class DirectMapper implements iImport * * @param iLogger $logger The logger instance. * @param iDB $db The database instance. - * @param CacheInterface $cache The cache instance. + * @param iCache $cache The cache instance. */ - public function __construct(protected iLogger $logger, protected iDB $db, protected CacheInterface $cache) + public function __construct(protected iLogger $logger, protected iDB $db, protected iCache $cache) { } @@ -632,7 +631,7 @@ final class DirectMapper implements iImport $progress[$itemId] = $entity; } $this->cache->set('progress', $progress, new DateInterval('P1D')); - } catch (InvalidArgumentException) { + } catch (\Psr\SimpleCache\InvalidArgumentException) { } } diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php index 42a7cafa..cfde9c8d 100644 --- a/src/Libs/Mappers/Import/MemoryMapper.php +++ b/src/Libs/Mappers/Import/MemoryMapper.php @@ -13,8 +13,7 @@ use DateInterval; use DateTimeInterface as iDate; use PDOException; use Psr\Log\LoggerInterface as iLogger; -use Psr\SimpleCache\CacheInterface; -use Psr\SimpleCache\InvalidArgumentException; +use Psr\SimpleCache\CacheInterface as iCache; /** * MemoryMapper Class @@ -63,9 +62,9 @@ final class MemoryMapper implements iImport * * @param iLogger $logger The instance of the logger interface. * @param iDB $db The instance of the database interface. - * @param CacheInterface $cache The instance of the cache interface. + * @param iCache $cache The instance of the cache interface. */ - public function __construct(protected iLogger $logger, protected iDB $db, protected CacheInterface $cache) + public function __construct(protected iLogger $logger, protected iDB $db, protected iCache $cache) { } @@ -455,7 +454,7 @@ final class MemoryMapper implements iImport $progress[$itemId] = $entity; } $this->cache->set('progress', $progress, new DateInterval('P1D')); - } catch (InvalidArgumentException) { + } catch (\Psr\SimpleCache\InvalidArgumentException) { } } } diff --git a/src/Libs/Routable.php b/src/Libs/Routable.php index 0685bb01..4a03bded 100644 --- a/src/Libs/Routable.php +++ b/src/Libs/Routable.php @@ -13,16 +13,14 @@ use Attribute; * The attribute can be repeated, and it can target a class. */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] -final class Routable +final readonly class Routable { /** * Class constructor. * * @param string $command The command string. - * - * @return void */ - public function __construct(public readonly string $command) + public function __construct(public string $command) { } } diff --git a/src/Libs/Router.php b/src/Libs/Router.php index 95e109f7..64c49ac9 100644 --- a/src/Libs/Router.php +++ b/src/Libs/Router.php @@ -4,27 +4,30 @@ declare(strict_types=1); namespace App\Libs; +use App\Libs\Exceptions\RuntimeException; use FilesystemIterator; use PhpToken; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use ReflectionAttribute; use ReflectionClass; -use RuntimeException; use SplFileInfo; use Throwable; /** - * Router class handles the generation of routes based on scanned directories and class attributes. + * Class Router + * + * The Router class is responsible for generating an array of routes by scanning directories. + * It parses PHP files to extract namespaces and classes, and retrieves routes using reflection. */ -final class Router +final readonly class Router { /** - * Class constructor. + * Class Constructor. * - * @param array $dirs An optional array of directories. + * @param array $dirs An array containing directory names. */ - public function __construct(private readonly array $dirs = []) + public function __construct(private array $dirs) { } @@ -132,14 +135,7 @@ final class Router $classes = []; $namespace = ''; - if (false === ($content = @file_get_contents($file))) { - throw new RuntimeException(r("Unable to read '{file}' - '{message}'.", [ - 'file' => $file, - 'message' => error_get_last()['message'] ?? 'unknown', - ])); - } - - $tokens = PhpToken::tokenize($content); + $tokens = PhpToken::tokenize((string)(new Stream($file, 'r'))); $count = count($tokens); foreach ($tokens as $i => $iValue) { diff --git a/src/Libs/Server.php b/src/Libs/Server.php index 9d988b41..418ad15f 100644 --- a/src/Libs/Server.php +++ b/src/Libs/Server.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Libs; +use App\Libs\Exceptions\RuntimeException; use Closure; -use RuntimeException; use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; diff --git a/src/Libs/Stream.php b/src/Libs/Stream.php new file mode 100644 index 00000000..cf3c7caa --- /dev/null +++ b/src/Libs/Stream.php @@ -0,0 +1,375 @@ + A list of allowed stream resource types that are allowed to instantiate a stream + */ + private const ALLOWED_STREAM_RESOURCE_TYPES = ['gd', 'stream']; + + /** + * @var resource|null The underlying stream resource. + */ + protected $resource; + + /** + * @param string|resource $stream The stream resource or file path. + * @param string $mode The stream mode. Default is 'r'. + * + * @throws RuntimeException If an invalid stream reference is provided. + * @throws InvalidArgumentException If the stream type is unexpected. + */ + public function __construct(mixed $stream, string $mode = 'r') + { + $error = null; + $resource = $stream; + + if (is_string($stream)) { + set_error_handler(function ($e) use (&$error) { + if ($e !== E_WARNING) { + return; + } + + $error = $e; + }); + $resource = fopen($stream, $mode); + restore_error_handler(); + } + + if ($error) { + throw new RuntimeException(r('Stream: Invalid stream reference provided. Error {error}.', [ + 'error' => ag(error_get_last(), 'message', '??'), + ])); + } + + if (!self::isValidStreamResourceType($resource)) { + throw new InvalidArgumentException( + r( + text: 'Stream: Unexpected [{type}] type was given. Stream must be a file path or stream resource.', + context: [ + 'type' => gettype($resource), + ] + ) + ); + } + + $this->resource = $resource; + } + + /** + * Create a new Stream instance. + * + * @param string|resource $stream The stream resource or file path. + * @param string $mode The stream mode. Default is 'r'. + * + * @return StreamInterface The new Stream instance. + * + * @throws RuntimeException If an invalid stream reference is provided. + * @throws InvalidArgumentException If the stream type is unexpected. + */ + public static function make(mixed $stream, string $mode = 'r'): StreamInterface + { + return new self($stream, $mode); + } + + /** + * Create in-memory stream with given contents. + * + * @param string|resource|StreamInterface $body The stream contents. + * + * @throws InvalidArgumentException If the $body arg is not a string, resource or StreamInterface. + */ + public static function create(mixed $body = ''): StreamInterface + { + if ($body instanceof StreamInterface) { + return $body; + } + + if (is_string($body)) { + $resource = \fopen('php://memory', 'r+'); + fwrite($resource, $body); + fseek($resource, 0); + return new self($resource); + } + + if (!self::isValidStreamResourceType($body)) { + throw new InvalidArgumentException( + 'First argument to Stream::create() must be a string, resource or StreamInterface' + ); + } + + return new self($body); + } + + /** + * {@inheritdoc} + */ + public function __toString(): string + { + if (!$this->isReadable()) { + return ''; + } + + try { + if ($this->isSeekable()) { + $this->rewind(); + } + + return $this->getContents(); + } catch (Throwable) { + return ''; + } + } + + /** + * {@inheritdoc} + */ + public function close(): void + { + if (!$this->resource) { + return; + } + + $resource = $this->detach(); + fclose($resource); + } + + /** + * {@inheritdoc} + */ + public function detach() + { + $resource = $this->resource; + $this->resource = null; + return $resource; + } + + /** + * {@inheritdoc} + */ + public function getSize(): ?int + { + if (null === $this->resource) { + return null; + } + + $stats = fstat($this->resource); + if (false !== $stats) { + return $stats['size']; + } + + return null; + } + + /** + * {@inheritdoc} + */ + public function tell(): int + { + if (!$this->resource) { + throw new RuntimeException('Stream: No resource available; cannot tell position'); + } + + $result = ftell($this->resource); + + if (!is_int($result)) { + throw new RuntimeException('Stream: Error occurred during tell operation.'); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function eof(): bool + { + if (!$this->resource) { + return true; + } + + return feof($this->resource); + } + + /** + * {@inheritdoc} + */ + public function isSeekable(): bool + { + if (!$this->resource) { + return false; + } + + $meta = stream_get_meta_data($this->resource); + return $meta['seekable']; + } + + /** + * {@inheritdoc} + */ + public function seek($offset, $whence = SEEK_SET): void + { + if (!$this->resource) { + throw new RuntimeException('Stream: No resource available; cannot seek position'); + } + + if (!$this->isSeekable()) { + throw new RuntimeException('Stream: Stream is not seekable'); + } + + $result = fseek($this->resource, $offset, $whence); + + if (0 !== $result) { + throw new RuntimeException('Stream: Error seeking within stream'); + } + } + + /** + * {@inheritdoc} + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * {@inheritdoc} + */ + public function isWritable(): bool + { + if (!$this->resource) { + return false; + } + + $meta = stream_get_meta_data($this->resource); + $mode = $meta['mode']; + + return (str_contains($mode, 'x') || str_contains($mode, 'w') || + str_contains($mode, 'c') || str_contains($mode, 'a') || str_contains($mode, '+')); + } + + /** + * {@inheritdoc} + */ + public function write(string $string): int + { + if (!$this->resource) { + throw new RuntimeException('Stream: No resource available; cannot write.'); + } + + if (!$this->isWritable()) { + throw new RuntimeException('Stream: Stream is not writable.'); + } + + $result = fwrite($this->resource, $string); + + if (false === $result) { + throw new RuntimeException('Stream: Error writing to stream.'); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function isReadable(): bool + { + if (!$this->resource) { + return false; + } + + $meta = stream_get_meta_data($this->resource); + $mode = $meta['mode']; + + return str_contains($mode, 'r') || str_contains($mode, '+'); + } + + /** + * {@inheritdoc} + */ + public function read(int $length): string + { + if (!$this->resource) { + throw new RuntimeException('Stream: No resource available; cannot read'); + } + + if (!$this->isReadable()) { + throw new RuntimeException('Stream: Stream is not readable'); + } + + $result = fread($this->resource, $length); + + if (false === $result) { + throw new RuntimeException('Stream: Error reading stream'); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function getContents(): string + { + if (!$this->isReadable()) { + throw new RuntimeException('Stream: Stream is not readable.'); + } + + $result = stream_get_contents($this->resource); + + if (false === $result) { + throw new RuntimeException('Stream: Error reading stream'); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function getMetadata(string|null $key = null) + { + $metadata = stream_get_meta_data($this->resource); + + return null !== $key ? ($metadata[$key] ?? null) : $metadata; + } + + /** + * Determine if a resource is one of the resource types allowed to instantiate a Stream + * + * @param resource $resource Stream resource. + * + * @return bool True if the resource is one of the allowed types, false otherwise. + */ + private static function isValidStreamResourceType($resource): bool + { + if (is_resource($resource)) { + return in_array(get_resource_type($resource), self::ALLOWED_STREAM_RESOURCE_TYPES, true); + } + + if (PHP_VERSION_ID >= 80000 && $resource instanceof GdImage) { + return true; + } + + return false; + } +} diff --git a/src/Libs/Uri.php b/src/Libs/Uri.php index 33be512d..397a4368 100644 --- a/src/Libs/Uri.php +++ b/src/Libs/Uri.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Libs; +use App\Libs\Exceptions\InvalidArgumentException; use Psr\Http\Message\UriInterface; use Stringable; @@ -154,7 +155,7 @@ final class Uri implements UriInterface, Stringable public function withScheme($scheme): self { if (!\is_string($scheme)) { - throw new \InvalidArgumentException('Scheme must be a string'); + throw new InvalidArgumentException('Scheme must be a string'); } if ($this->scheme === $scheme = \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) { @@ -188,7 +189,7 @@ final class Uri implements UriInterface, Stringable public function withHost($host): self { if (!\is_string($host)) { - throw new \InvalidArgumentException('Host must be a string'); + throw new InvalidArgumentException('Host must be a string'); } if ($this->host === $host = \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) { @@ -312,7 +313,7 @@ final class Uri implements UriInterface, Stringable $port = (int)$port; if (0 > $port || 0xFFFF < $port) { - throw new \InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port)); + throw new InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port)); } return self::isNonStandardPort($this->scheme, $port) ? $port : null; @@ -321,7 +322,7 @@ final class Uri implements UriInterface, Stringable private function filterPath($path): string { if (!is_string($path)) { - throw new \InvalidArgumentException('Path must be a string'); + throw new InvalidArgumentException('Path must be a string'); } return preg_replace_callback( @@ -334,7 +335,7 @@ final class Uri implements UriInterface, Stringable private function filterQueryAndFragment($str): string { if (!is_string($str)) { - throw new \InvalidArgumentException('Query and fragment must be a string'); + throw new InvalidArgumentException('Query and fragment must be a string'); } return preg_replace_callback( diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index c1ca1498..cef53f4b 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -8,15 +8,18 @@ use App\Backends\Common\Context; use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface as iFace; +use App\Libs\Exceptions\InvalidArgumentException; +use App\Libs\Exceptions\RuntimeException; use App\Libs\Extends\Date; use App\Libs\Options; use App\Libs\Router; +use App\Libs\Stream; use App\Libs\Uri; use Monolog\Utils; use Nyholm\Psr7\Response; -use Nyholm\Psr7\Stream; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; @@ -300,9 +303,9 @@ if (!function_exists('saveWebhookPayload')) { * * @param iFace $entity Entity object. * @param ServerRequestInterface $request Request object. - * @param Stream|null $file When given a stream, it will be used to write payload. + * @param StreamInterface|null $file When given a stream, it will be used to write payload. */ - function saveWebhookPayload(iFace $entity, ServerRequestInterface $request, Stream|null $file = null): void + function saveWebhookPayload(iFace $entity, ServerRequestInterface $request, StreamInterface|null $file = null): void { $content = [ 'request' => [ @@ -315,38 +318,27 @@ if (!function_exists('saveWebhookPayload')) { 'entity' => $entity->getAll(), ]; - $closeStream = false; - if (null === $file) { - $fp = @fopen( - r('{path}/webhooks/' . Config::get('webhook.file_format', 'webhook.{backend}.{event}.{id}.json'), [ - 'path' => Config::get('tmpDir'), - 'time' => (string)time(), - 'backend' => $entity->via, - 'event' => ag($entity->getExtra($entity->via), 'event', 'unknown'), - 'id' => ag($request->getServerParams(), 'X_REQUEST_ID', time()), - 'date' => makeDate('now')->format('Ymd'), - 'context' => $content, - ]), - 'w' - ); + $stream = $file ?? new Stream( + r('{path}/webhooks/' . Config::get('webhook.file_format', 'webhook.{backend}.{event}.{id}.json'), [ + 'path' => Config::get('tmpDir'), + 'time' => (string)time(), + 'backend' => $entity->via, + 'event' => ag($entity->getExtra($entity->via), 'event', 'unknown'), + 'id' => ag($request->getServerParams(), 'X_REQUEST_ID', time()), + 'date' => makeDate('now')->format('Ymd'), + 'context' => $content, + ]), 'w' + ); - if (false === $fp) { - throw new Error(ag(error_get_last(), 'message', '')); - } - - $file = new Stream($fp); - $closeStream = true; - } - - $file->write( + $stream->write( json_encode( value: $content, flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE ) ); - if ($closeStream) { - $file->close(); + if (null === $file) { + $stream->close(); } } } @@ -356,9 +348,9 @@ if (!function_exists('saveRequestPayload')) { * Save request payload to stream. * * @param ServerRequestInterface $request Request object. - * @param Stream|null $file When given a stream, it will be used to write payload. + * @param StreamInterface|null $file When given a stream, it will be used to write payload. */ - function saveRequestPayload(ServerRequestInterface $request, Stream|null $file = null): void + function saveRequestPayload(ServerRequestInterface $request, StreamInterface|null $file = null): void { $content = [ 'query' => $request->getQueryParams(), @@ -368,33 +360,20 @@ if (!function_exists('saveRequestPayload')) { 'attributes' => $request->getAttributes(), ]; - $closeStream = false; - if (null === $file) { - $fp = @fopen( - r('{path}/debug/request.{id}.json', [ - 'path' => Config::get('tmpDir'), - 'id' => ag($request->getServerParams(), 'X_REQUEST_ID', (string)time()), - ]), - 'w' - ); + $stream = $file ?? new Stream(r('{path}/debug/request.{id}.json', [ + 'path' => Config::get('tmpDir'), + 'id' => ag($request->getServerParams(), 'X_REQUEST_ID', (string)time()), + ]), 'w'); - if (false === $fp) { - throw new Error(ag(error_get_last(), 'message', '')); - } - - $file = new Stream($fp); - $closeStream = true; - } - - $file->write( + $stream->write( json_encode( value: $content, flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE ) ); - if ($closeStream) { - $file->close(); + if (null === $file) { + $stream->close(); } } } @@ -554,20 +533,20 @@ if (!function_exists('makeBackend')) { * @param string|null $name server name. * * @return iClient backend client instance. - * @throws RuntimeException if configuration is wrong. + * @throws InvalidArgumentException if configuration is wrong. */ function makeBackend(array $backend, string|null $name = null): iClient { if (null === ($backendType = ag($backend, 'type'))) { - throw new RuntimeException('No backend type was set.'); + throw new InvalidArgumentException('No backend type was set.'); } if (null === ag($backend, 'url')) { - throw new RuntimeException('No backend url was set.'); + throw new InvalidArgumentException('No backend url was set.'); } if (null === ($class = Config::get("supported.{$backendType}", null))) { - throw new RuntimeException( + throw new InvalidArgumentException( r('Unexpected client type [{type}] was given. Expecting [{list}]', [ 'type' => $backendType, 'list' => array_keys(Config::get('supported', [])), @@ -645,7 +624,7 @@ if (!function_exists('commandContext')) { ]); } - return ($_SERVER['argv'][0] ?? 'php console') . ' '; + return ($_SERVER['argv'][0] ?? 'php bin/console') . ' '; } } @@ -801,7 +780,7 @@ if (false === function_exists('isIgnoredId')) { * @param int|string|null $backendId The backend ID (optional). * * @return bool Returns true if the ID is ignored, false otherwise. - * @throws RuntimeException Throws an exception if an invalid context type is given. + * @throws InvalidArgumentException Throws an exception if an invalid context type is given. */ function isIgnoredId( string $backend, @@ -811,7 +790,7 @@ if (false === function_exists('isIgnoredId')) { string|int|null $backendId = null ): bool { if (false === in_array($type, iFace::TYPES_LIST)) { - throw new RuntimeException(sprintf('Invalid context type \'%s\' was given.', $type)); + throw new InvalidArgumentException(sprintf('Invalid context type \'%s\' was given.', $type)); } $list = Config::get('ignore', []); diff --git a/tests/Libs/HelpersTest.php b/tests/Libs/HelpersTest.php index 5b5ac016..74822cf0 100644 --- a/tests/Libs/HelpersTest.php +++ b/tests/Libs/HelpersTest.php @@ -6,6 +6,7 @@ namespace Tests\Libs; use App\Libs\Config; use App\Libs\Entity\StateEntity; +use App\Libs\Exceptions\RuntimeException; use App\Libs\TestCase; use JsonMachine\Items; use JsonMachine\JsonDecoder\ErrorWrappingDecoder; @@ -273,7 +274,7 @@ class HelpersTest extends TestCase 'saveWebhookPayload() should save webhook payload into given stream if it is provided otherwise it should save it into default stream.' ); - $this->expectException(\Error::class); + $this->expectException(RuntimeException::class); saveWebhookPayload(entity: $entity, request: $request); } @@ -311,7 +312,7 @@ class HelpersTest extends TestCase $this->assertSame($request->getAttributes(), $fromFile->getAttributes()); $this->assertSame($request->getParsedBody(), $fromFile->getParsedBody()); - $this->expectException(\Error::class); + $this->expectException(RuntimeException::class); saveRequestPayload(request: $request); }