Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.26% covered (success)
93.26%
83 / 89
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
AttributeProcessor
93.26% covered (success)
93.26%
83 / 89
78.57% covered (warning)
78.57%
11 / 14
55.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
n/a
0 / 0
n/a
0 / 0
1
 add
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addNamespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 loadNamespacesFromConfig
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 discoverClassesFromNamespaces
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
14
 scanDirectory
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
8.30
 createHandlersPerTarget
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 processAll
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isProcessAllowed
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 process
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 processClass
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 processProperties
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 processMethods
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 processSubject
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Dynart\Micro\Middleware;
4
5use Dynart\Micro\ConfigInterface;
6use Dynart\Micro\Micro;
7use Dynart\Micro\MiddlewareInterface;
8use Dynart\Micro\AttributeHandlerInterface;
9use Dynart\Micro\MicroException;
10use RecursiveDirectoryIterator;
11use RecursiveIteratorIterator;
12use ReflectionClass;
13
14/**
15 * Processes PHP 8 attributes on registered classes
16 */
17class 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}