From 82c93e2d6e429702ee52af812783a5368a8d970d Mon Sep 17 00:00:00 2001
From: Mateusz Charytoniuk <mateusz.charytoniuk@protonmail.com>
Date: Tue, 13 Feb 2024 04:32:54 +0100
Subject: [PATCH] feat: RequiresPhpExtension attribute

---
 src/Attribute/RequiresPhpExtension.php        | 17 +++++++++++
 src/Command/GrpcGenerate.php                  |  2 ++
 src/Command/PostfixBounce.php                 |  7 ++---
 src/Command/Watch.php                         |  9 ++----
 src/DependencyInjectionContainer.php          | 13 ++++++++
 .../MissingPhpExtension.php                   | 30 +++++++++++++++++++
 src/DoctrineMySQLDriver.php                   |  2 ++
 src/DoctrinePostgreSQLDriver.php              |  2 ++
 src/DoctrineSQLiteDriver.php                  |  2 ++
 src/IntlFormatter.php                         |  2 ++
 src/LlamaCppClient.php                        |  2 ++
 src/SQLiteVSSConnectionBuilder.php            |  2 ++
 ...tabaseConnectionPoolRepositoryProvider.php |  2 ++
 ...octrineEntityManagerRepositoryProvider.php |  2 ++
 ...oolConnectionBuilderCollectionProvider.php |  2 ++
 15 files changed, 86 insertions(+), 10 deletions(-)
 create mode 100644 src/Attribute/RequiresPhpExtension.php
 create mode 100644 src/DependencyInjectionContainerException/MissingPhpExtension.php

diff --git a/src/Attribute/RequiresPhpExtension.php b/src/Attribute/RequiresPhpExtension.php
new file mode 100644
index 00000000..8cff848e
--- /dev/null
+++ b/src/Attribute/RequiresPhpExtension.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Attribute;
+
+use Attribute;
+use Distantmagic\Resonance\Attribute as BaseAttribute;
+
+#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS)]
+final readonly class RequiresPhpExtension extends BaseAttribute
+{
+    /**
+     * @param non-empty-string $name
+     */
+    public function __construct(public string $name) {}
+}
diff --git a/src/Command/GrpcGenerate.php b/src/Command/GrpcGenerate.php
index 6078bd36..3d200a94 100644
--- a/src/Command/GrpcGenerate.php
+++ b/src/Command/GrpcGenerate.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance\Command;
 
 use Distantmagic\Resonance\Attribute\ConsoleCommand;
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\WantsFeature;
 use Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\Feature;
@@ -16,6 +17,7 @@ use Symfony\Component\Console\Output\OutputInterface;
     name: 'grpc:generate',
     description: 'Generate GRPC stubs'
 )]
+#[RequiresPhpExtension('grpc')]
 #[WantsFeature(Feature::Grpc)]
 final class GrpcGenerate extends Command
 {
diff --git a/src/Command/PostfixBounce.php b/src/Command/PostfixBounce.php
index 6db07429..19c79966 100644
--- a/src/Command/PostfixBounce.php
+++ b/src/Command/PostfixBounce.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance\Command;
 
 use Distantmagic\Resonance\Attribute\ConsoleCommand;
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\WantsFeature;
 use Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\CoroutineCommand;
@@ -22,6 +23,8 @@ use Symfony\Component\Console\Output\OutputInterface;
     name: 'postfix:bounce',
     description: 'Handles email bounces (requires mailparse)'
 )]
+#[RequiresPhpExtension('http')]
+#[RequiresPhpExtension('mailparse')]
 #[WantsFeature(Feature::Postfix)]
 final class PostfixBounce extends CoroutineCommand
 {
@@ -36,10 +39,6 @@ final class PostfixBounce extends CoroutineCommand
 
     protected function executeInCoroutine(InputInterface $input, OutputInterface $output): int
     {
-        if (!extension_loaded('mailparse') || !extension_loaded('http')) {
-            throw new RuntimeException('You need to install "http" and "mailparse" extensions');
-        }
-
         $content = stream_get_contents(STDIN);
 
         if (false === $content || empty($content)) {
diff --git a/src/Command/Watch.php b/src/Command/Watch.php
index 19bf802c..ab17e8c9 100644
--- a/src/Command/Watch.php
+++ b/src/Command/Watch.php
@@ -6,10 +6,10 @@ namespace Distantmagic\Resonance\Command;
 
 use Distantmagic\Resonance\ApplicationConfiguration;
 use Distantmagic\Resonance\Attribute\ConsoleCommand;
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\InotifyIterator;
 use Psr\Log\LoggerInterface;
-use RuntimeException;
 use Swoole\Process;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
@@ -17,8 +17,9 @@ use Symfony\Component\Console\Output\OutputInterface;
 
 #[ConsoleCommand(
     name: 'watch:command',
-    description: 'Watch project files for changes (requires inotify)'
+    description: 'Watch project files for changes'
 )]
+#[RequiresPhpExtension('inotify')]
 final class Watch extends Command
 {
     private ?Process $process = null;
@@ -37,10 +38,6 @@ final class Watch extends Command
 
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
-        if (!extension_loaded('inotify')) {
-            throw new RuntimeException('You need to install "inotify" extension');
-        }
-
         /**
          * @var string $childCommandName
          */
diff --git a/src/DependencyInjectionContainer.php b/src/DependencyInjectionContainer.php
index 61b47071..eaaa224f 100644
--- a/src/DependencyInjectionContainer.php
+++ b/src/DependencyInjectionContainer.php
@@ -5,8 +5,10 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance;
 
 use Closure;
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\DependencyInjectionContainerException\AmbiguousProvider;
 use Distantmagic\Resonance\DependencyInjectionContainerException\DisabledFeatureProvider;
+use Distantmagic\Resonance\DependencyInjectionContainerException\MissingPhpExtension;
 use Distantmagic\Resonance\DependencyInjectionContainerException\MissingProvider;
 use Ds\Map;
 use Ds\Set;
@@ -224,6 +226,17 @@ readonly class DependencyInjectionContainer
      */
     private function makeClassFromReflection(ReflectionClass $reflectionClass, DependencyStack $stack): object
     {
+        $reflectionClassAttributeManager = new ReflectionClassAttributeManager($reflectionClass);
+        $requiredPhpExtensions = $reflectionClassAttributeManager->findAttributes(RequiresPhpExtension::class);
+
+        if (!$requiredPhpExtensions->isEmpty()) {
+            foreach ($requiredPhpExtensions as $requiredPhpExtension) {
+                if (!extension_loaded($requiredPhpExtension->name)) {
+                    throw new MissingPhpExtension($reflectionClass->name, $requiredPhpExtension->name);
+                }
+            }
+        }
+
         $constructorReflection = $reflectionClass->getConstructor();
 
         if ($constructorReflection) {
diff --git a/src/DependencyInjectionContainerException/MissingPhpExtension.php b/src/DependencyInjectionContainerException/MissingPhpExtension.php
new file mode 100644
index 00000000..35a34078
--- /dev/null
+++ b/src/DependencyInjectionContainerException/MissingPhpExtension.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\DependencyInjectionContainerException;
+
+use Distantmagic\Resonance\DependencyInjectionContainerException;
+use Throwable;
+
+class MissingPhpExtension extends DependencyInjectionContainerException
+{
+    /**
+     * @param class-string     $className
+     * @param non-empty-string $extensionName
+     */
+    public function __construct(
+        string $className,
+        string $extensionName,
+        ?Throwable $previous = null,
+    ) {
+        parent::__construct(
+            sprintf(
+                'To use "%s" you need to install "%s" PHP extension.',
+                $className,
+                $extensionName,
+            ),
+            $previous
+        );
+    }
+}
diff --git a/src/DoctrineMySQLDriver.php b/src/DoctrineMySQLDriver.php
index a74d99db..ac2636f3 100644
--- a/src/DoctrineMySQLDriver.php
+++ b/src/DoctrineMySQLDriver.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Doctrine\DBAL\Driver\AbstractMySQLDriver;
 
@@ -13,6 +14,7 @@ use Doctrine\DBAL\Driver\AbstractMySQLDriver;
  *
  * @psalm-suppress DeprecatedInterface
  */
+#[RequiresPhpExtension('pdo')]
 #[Singleton]
 class DoctrineMySQLDriver extends AbstractMySQLDriver
 {
diff --git a/src/DoctrinePostgreSQLDriver.php b/src/DoctrinePostgreSQLDriver.php
index b0147adf..8c406390 100644
--- a/src/DoctrinePostgreSQLDriver.php
+++ b/src/DoctrinePostgreSQLDriver.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Doctrine\DBAL\Driver\AbstractPostgreSQLDriver;
 
@@ -13,6 +14,7 @@ use Doctrine\DBAL\Driver\AbstractPostgreSQLDriver;
  *
  * @psalm-suppress DeprecatedInterface
  */
+#[RequiresPhpExtension('pdo')]
 #[Singleton]
 class DoctrinePostgreSQLDriver extends AbstractPostgreSQLDriver
 {
diff --git a/src/DoctrineSQLiteDriver.php b/src/DoctrineSQLiteDriver.php
index fa6f0f10..014e6ec2 100644
--- a/src/DoctrineSQLiteDriver.php
+++ b/src/DoctrineSQLiteDriver.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Doctrine\DBAL\Driver\AbstractSQLiteDriver;
 
@@ -13,6 +14,7 @@ use Doctrine\DBAL\Driver\AbstractSQLiteDriver;
  *
  * @psalm-suppress DeprecatedInterface
  */
+#[RequiresPhpExtension('pdo')]
 #[Singleton]
 class DoctrineSQLiteDriver extends AbstractSQLiteDriver
 {
diff --git a/src/IntlFormatter.php b/src/IntlFormatter.php
index 7fbca43c..baa66291 100644
--- a/src/IntlFormatter.php
+++ b/src/IntlFormatter.php
@@ -6,11 +6,13 @@ namespace Distantmagic\Resonance;
 
 use DateTimeImmutable;
 use DateTimeInterface;
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\Singleton;
 use IntlDateFormatter;
 use Swoole\Http\Request;
 
 #[Singleton]
+#[RequiresPhpExtension('intl')]
 final readonly class IntlFormatter
 {
     private IntlDateFormatterRepository $formatters;
diff --git a/src/LlamaCppClient.php b/src/LlamaCppClient.php
index 48b298e9..09b7a3ef 100644
--- a/src/LlamaCppClient.php
+++ b/src/LlamaCppClient.php
@@ -5,12 +5,14 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance;
 
 use CurlHandle;
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Generator;
 use JsonSerializable;
 use RuntimeException;
 use Swoole\Coroutine\Channel;
 
+#[RequiresPhpExtension('curl')]
 #[Singleton]
 readonly class LlamaCppClient
 {
diff --git a/src/SQLiteVSSConnectionBuilder.php b/src/SQLiteVSSConnectionBuilder.php
index be27327a..0523cb5d 100644
--- a/src/SQLiteVSSConnectionBuilder.php
+++ b/src/SQLiteVSSConnectionBuilder.php
@@ -4,9 +4,11 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\Singleton;
 use SQLite3;
 
+#[RequiresPhpExtension('sqlite3')]
 #[Singleton]
 readonly class SQLiteVSSConnectionBuilder
 {
diff --git a/src/SingletonProvider/DatabaseConnectionPoolRepositoryProvider.php b/src/SingletonProvider/DatabaseConnectionPoolRepositoryProvider.php
index a79340aa..dcfa7966 100644
--- a/src/SingletonProvider/DatabaseConnectionPoolRepositoryProvider.php
+++ b/src/SingletonProvider/DatabaseConnectionPoolRepositoryProvider.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance\SingletonProvider;
 
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\DatabaseConfiguration;
 use Distantmagic\Resonance\DatabaseConnectionPoolRepository;
@@ -17,6 +18,7 @@ use Swoole\Database\PDOConfig;
 /**
  * @template-extends SingletonProvider<DatabaseConnectionPoolRepository>
  */
+#[RequiresPhpExtension('pdo')]
 #[Singleton(provides: DatabaseConnectionPoolRepository::class)]
 final readonly class DatabaseConnectionPoolRepositoryProvider extends SingletonProvider
 {
diff --git a/src/SingletonProvider/DoctrineEntityManagerRepositoryProvider.php b/src/SingletonProvider/DoctrineEntityManagerRepositoryProvider.php
index 587ab4f6..41a8fb93 100644
--- a/src/SingletonProvider/DoctrineEntityManagerRepositoryProvider.php
+++ b/src/SingletonProvider/DoctrineEntityManagerRepositoryProvider.php
@@ -6,6 +6,7 @@ namespace Distantmagic\Resonance\SingletonProvider;
 
 use Distantmagic\Resonance\ApplicationConfiguration;
 use Distantmagic\Resonance\Attribute\GrantsFeature;
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\DoctrineConnectionRepository;
 use Distantmagic\Resonance\DoctrineEntityManagerRepository;
@@ -21,6 +22,7 @@ use Symfony\Component\Filesystem\Filesystem;
 /**
  * @template-extends SingletonProvider<DoctrineEntityManagerRepository>
  */
+#[RequiresPhpExtension('pdo')]
 #[GrantsFeature(Feature::Doctrine)]
 #[Singleton(provides: DoctrineEntityManagerRepository::class)]
 final readonly class DoctrineEntityManagerRepositoryProvider extends SingletonProvider
diff --git a/src/SingletonProvider/PDOPoolConnectionBuilderCollectionProvider.php b/src/SingletonProvider/PDOPoolConnectionBuilderCollectionProvider.php
index 74021897..1820af1f 100644
--- a/src/SingletonProvider/PDOPoolConnectionBuilderCollectionProvider.php
+++ b/src/SingletonProvider/PDOPoolConnectionBuilderCollectionProvider.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance\SingletonProvider;
 
 use Distantmagic\Resonance\Attribute\BuildsPDOPoolConnection;
+use Distantmagic\Resonance\Attribute\RequiresPhpExtension;
 use Distantmagic\Resonance\Attribute\RequiresSingletonCollection;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\HttpResponderCollection;
@@ -19,6 +20,7 @@ use Distantmagic\Resonance\SingletonProvider;
 /**
  * @template-extends SingletonProvider<HttpResponderCollection>
  */
+#[RequiresPhpExtension('pdo')]
 #[RequiresSingletonCollection(SingletonCollection::PDOPoolConnectionBuilder)]
 #[Singleton(provides: PDOPoolConnectionBuilderCollection::class)]
 final readonly class PDOPoolConnectionBuilderCollectionProvider extends SingletonProvider
-- 
GitLab