Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.26% |
83 / 89 |
|
78.57% |
11 / 14 |
CRAP | |
0.00% |
0 / 1 |
| AttributeProcessor | |
93.26% |
83 / 89 |
|
78.57% |
11 / 14 |
55.93 | |
0.00% |
0 / 1 |
| __construct | n/a |
0 / 0 |
n/a |
0 / 0 |
1 | |||||
| add | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| addNamespace | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| run | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| loadNamespacesFromConfig | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
| discoverClassesFromNamespaces | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
14 | |||
| scanDirectory | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
8.30 | |||
| createHandlersPerTarget | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| processAll | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| isProcessAllowed | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| process | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
2.15 | |||
| processClass | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| processProperties | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| processMethods | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| processSubject | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Dynart\Micro\Middleware; |
| 4 | |
| 5 | use Dynart\Micro\ConfigInterface; |
| 6 | use Dynart\Micro\Micro; |
| 7 | use Dynart\Micro\MiddlewareInterface; |
| 8 | use Dynart\Micro\AttributeHandlerInterface; |
| 9 | use Dynart\Micro\MicroException; |
| 10 | use RecursiveDirectoryIterator; |
| 11 | use RecursiveIteratorIterator; |
| 12 | use ReflectionClass; |
| 13 | |
| 14 | /** |
| 15 | * Processes PHP 8 attributes on registered classes |
| 16 | */ |
| 17 | class AttributeProcessor implements MiddlewareInterface { |
| 18 | |
| 19 | /** @var string[] */ |
| 20 | protected array $handlerClasses = []; |
| 21 | |
| 22 | /** @var AttributeHandlerInterface[][] */ |
| 23 | protected array $handlers = [ |
| 24 | AttributeHandlerInterface::TARGET_CLASS => [], |
| 25 | AttributeHandlerInterface::TARGET_PROPERTY => [], |
| 26 | AttributeHandlerInterface::TARGET_METHOD => [] |
| 27 | ]; |
| 28 | |
| 29 | /** @var string[] */ |
| 30 | protected array $namespaces = []; |
| 31 | |
| 32 | public function __construct(protected ?ConfigInterface $config = null) {} |
| 33 | |
| 34 | /** |
| 35 | * Adds an attribute handler for processing |
| 36 | * |
| 37 | * The given class name should implement the AttributeHandler interface, otherwise |
| 38 | * it will throw a MicroException. |
| 39 | * |
| 40 | * @throws MicroException if the given class does not implement AttributeHandler |
| 41 | * @param string $className The class name |
| 42 | */ |
| 43 | public function add(string $className): void { |
| 44 | if (!is_subclass_of($className, AttributeHandlerInterface::class)) { |
| 45 | throw new MicroException("$className doesn't implement the AttributeHandlerInterface interface"); |
| 46 | } |
| 47 | $this->handlerClasses[] = $className; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Adds a namespace |
| 52 | * |
| 53 | * If one or more namespace added only those will be processed. The namespace should NOT start with a backslash! |
| 54 | * |
| 55 | * @param string $namespace |
| 56 | */ |
| 57 | public function addNamespace(string $namespace): void { |
| 58 | $this->namespaces[] = $namespace; |
| 59 | } |
| 60 | |
| 61 | public function run(): void { |
| 62 | $this->loadNamespacesFromConfig(); |
| 63 | $this->discoverClassesFromNamespaces(); |
| 64 | $this->createHandlersPerTarget(); |
| 65 | $this->processAll(); |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Reads `app.scan_namespaces` from config and merges with programmatically added namespaces |
| 70 | */ |
| 71 | protected function loadNamespacesFromConfig(): void { |
| 72 | if ($this->config === null) { |
| 73 | return; |
| 74 | } |
| 75 | $value = $this->config->get('app.scan_namespaces', ''); |
| 76 | if ($value) { |
| 77 | foreach (explode(',', $value) as $ns) { |
| 78 | $ns = trim($ns); |
| 79 | if ($ns && !in_array($ns, $this->namespaces)) { |
| 80 | $this->namespaces[] = $ns; |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Discovers classes from Composer PSR-4 autoload map for configured namespaces |
| 88 | * |
| 89 | * Scans directories matching the configured namespaces, derives FQCNs from file paths, |
| 90 | * and registers them with Micro::add(). Skips interfaces, abstract classes, and traits. |
| 91 | */ |
| 92 | protected function discoverClassesFromNamespaces(): void { |
| 93 | if (empty($this->namespaces) || $this->config === null) { |
| 94 | return; |
| 95 | } |
| 96 | $rootPath = $this->config->rootPath(); |
| 97 | $psr4File = $rootPath . '/vendor/composer/autoload_psr4.php'; |
| 98 | if (!file_exists($psr4File)) { |
| 99 | return; |
| 100 | } |
| 101 | $psr4Map = require $psr4File; |
| 102 | foreach ($psr4Map as $prefix => $dirs) { |
| 103 | foreach ($this->namespaces as $namespace) { |
| 104 | // Bidirectional matching: configured namespace starts with PSR-4 prefix |
| 105 | // or PSR-4 prefix starts with configured namespace |
| 106 | $prefixNormalized = rtrim($prefix, '\\'); |
| 107 | if (!str_starts_with($namespace, $prefixNormalized) && !str_starts_with($prefixNormalized, $namespace)) { |
| 108 | continue; |
| 109 | } |
| 110 | foreach ($dirs as $dir) { |
| 111 | // If configured namespace is deeper than PSR-4 prefix, scan subdirectory |
| 112 | $subPath = ''; |
| 113 | if (strlen($namespace) > strlen($prefixNormalized)) { |
| 114 | $subPath = str_replace('\\', '/', substr($namespace, strlen($prefix))); |
| 115 | } |
| 116 | $scanDir = rtrim($dir, '/') . ($subPath ? '/' . $subPath : ''); |
| 117 | if (!is_dir($scanDir)) { |
| 118 | continue; |
| 119 | } |
| 120 | $classes = $this->scanDirectory($scanDir, $prefix, $subPath); |
| 121 | foreach ($classes as $fqcn) { |
| 122 | if (!Micro::hasInterface($fqcn)) { |
| 123 | Micro::add($fqcn); |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Recursively scans a directory for PHP files and derives FQCNs |
| 133 | * |
| 134 | * @param string $dir The directory to scan |
| 135 | * @param string $namespacePrefix The PSR-4 namespace prefix (e.g. "App\\") |
| 136 | * @param string $subPath Additional sub-path within the namespace (e.g. "Controllers") |
| 137 | * @return string[] Array of fully-qualified class names |
| 138 | */ |
| 139 | protected function scanDirectory(string $dir, string $namespacePrefix, string $subPath = ''): array { |
| 140 | $classes = []; |
| 141 | $iterator = new RecursiveIteratorIterator( |
| 142 | new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) |
| 143 | ); |
| 144 | foreach ($iterator as $file) { |
| 145 | if ($file->getExtension() !== 'php') { |
| 146 | continue; |
| 147 | } |
| 148 | $relativePath = substr($file->getPathname(), strlen($dir) + 1); |
| 149 | $relativePath = str_replace('\\', '/', $relativePath); |
| 150 | $relativeClass = str_replace('/', '\\', substr($relativePath, 0, -4)); |
| 151 | $fqcn = $namespacePrefix . ($subPath ? str_replace('/', '\\', $subPath) . '\\' : '') . $relativeClass; |
| 152 | try { |
| 153 | $ref = new ReflectionClass($fqcn); |
| 154 | if ($ref->isInterface() || $ref->isAbstract() || $ref->isTrait()) { |
| 155 | continue; |
| 156 | } |
| 157 | } catch (\ReflectionException $e) { |
| 158 | continue; |
| 159 | } |
| 160 | $classes[] = $fqcn; |
| 161 | } |
| 162 | return $classes; |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Creates the handler instances and puts them into the right `$handlers` array |
| 167 | */ |
| 168 | protected function createHandlersPerTarget(): void { |
| 169 | foreach ($this->handlerClasses as $className) { |
| 170 | $handler = Micro::get($className); |
| 171 | foreach ($handler->targets() as $target) { |
| 172 | $this->handlers[$target][] = $handler; |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Processes all interfaces in the App or those that are in the given namespaces |
| 179 | */ |
| 180 | protected function processAll(): void { |
| 181 | foreach (Micro::interfaces() as $className) { |
| 182 | if ($this->isProcessAllowed($className)) { |
| 183 | $this->process($className); |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | |
| 189 | /** |
| 190 | * If no namespace added returns true, otherwise checks the namespace and returns true if the interface is in it. |
| 191 | * @param string $className The name of the class |
| 192 | * @return bool Should we process this class? |
| 193 | */ |
| 194 | protected function isProcessAllowed(string $className): bool { |
| 195 | if (empty($this->namespaces)) { |
| 196 | return true; |
| 197 | } |
| 198 | foreach ($this->namespaces as $namespace) { |
| 199 | if (substr($className, 0, strlen($namespace)) == $namespace) { |
| 200 | return true; |
| 201 | } |
| 202 | } |
| 203 | return false; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Processes one class with the given name |
| 208 | * @param string $className The name of the class |
| 209 | */ |
| 210 | protected function process(string $className): void { |
| 211 | try { |
| 212 | $refClass = new \ReflectionClass($className); |
| 213 | } catch (\ReflectionException $ignore) { |
| 214 | throw new MicroException("Can't create reflection for: $className"); |
| 215 | } |
| 216 | $this->processClass($refClass); |
| 217 | $this->processProperties($refClass); |
| 218 | $this->processMethods($refClass); |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Processes all class-level attributes for the class |
| 223 | * @param \ReflectionClass $refClass |
| 224 | */ |
| 225 | protected function processClass(\ReflectionClass $refClass): void { |
| 226 | foreach ($this->handlers[AttributeHandlerInterface::TARGET_CLASS] as $handler) { |
| 227 | $this->processSubject($handler, $refClass->getName(), $refClass); |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | /** |
| 232 | * Processes all property-level attributes for all the properties of a class |
| 233 | * @param \ReflectionClass $refClass |
| 234 | */ |
| 235 | protected function processProperties(\ReflectionClass $refClass): void { |
| 236 | $refProperties = $refClass->getProperties(); |
| 237 | foreach ($this->handlers[AttributeHandlerInterface::TARGET_PROPERTY] as $handler) { |
| 238 | foreach ($refProperties as $refProperty) { |
| 239 | $this->processSubject($handler, $refClass->getName(), $refProperty); |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Processes all method-level attributes for all the methods of a class |
| 246 | * @param \ReflectionClass $refClass |
| 247 | */ |
| 248 | protected function processMethods(\ReflectionClass $refClass): void { |
| 249 | $refMethods = $refClass->getMethods(); |
| 250 | foreach ($this->handlers[AttributeHandlerInterface::TARGET_METHOD] as $handler) { |
| 251 | foreach ($refMethods as $refMethod) { |
| 252 | $this->processSubject($handler, $refClass->getName(), $refMethod); |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Processes attributes on a class, property or method |
| 259 | * |
| 260 | * Gets the PHP 8 attributes from the subject that match the handler's attribute class, |
| 261 | * instantiates each and calls the handler's handle() method. |
| 262 | * |
| 263 | * @param AttributeHandler $handler The attribute handler |
| 264 | * @param string $className The class name |
| 265 | * @param \ReflectionClass|\ReflectionProperty|\ReflectionMethod $subject The reflection class, property or method |
| 266 | */ |
| 267 | protected function processSubject(AttributeHandlerInterface $handler, string $className, \ReflectionClass|\ReflectionProperty|\ReflectionMethod $subject): void { |
| 268 | $attributes = $subject->getAttributes($handler->attributeClass()); |
| 269 | foreach ($attributes as $refAttribute) { |
| 270 | $handler->handle($className, $subject, $refAttribute->newInstance()); |
| 271 | } |
| 272 | } |
| 273 | } |