Passed
Push — master ( a8509b...45146e )
by Arnold
03:21
created

Invoker::generateInvocation()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2.351

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 8
c 1
b 0
f 0
nc 2
nop 3
dl 0
loc 13
ccs 5
cts 9
cp 0.5556
crap 2.351
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\SwitchRoute;
6
7
use Jasny\ReflectionFactory\ReflectionFactory;
8
use Jasny\ReflectionFactory\ReflectionFactoryInterface;
9
use ReflectionException;
10
use ReflectionFunction;
11
use ReflectionFunctionAbstract;
12
use ReflectionMethod;
13
use ReflectionNamedType;
14
use Spatie\Regex\Regex;
15
16
/**
17
 * Invoke the action or script specified by the route.
18
 */
19
class Invoker implements InvokerInterface
20
{
21
    /**
22
     * Callback to turn a controller name and action name into a callable.
23
     * @var callable
24
     */
25
    protected $createInvokable;
26
27
    /**
28
     * @var ReflectionFactoryInterface
29
     */
30
    protected $reflection;
31
32
    /**
33
     * Invoker constructor.
34
     *
35
     * @param callable|null                   $createInvokable
36
     * @param ReflectionFactoryInterface|null $reflection
37
     */
38 5
    public function __construct(?callable $createInvokable = null, ?ReflectionFactoryInterface $reflection = null)
39
    {
40 5
        $this->createInvokable = $createInvokable ?? \Closure::fromCallable([__CLASS__, 'createInvokable']);
41 5
        $this->reflection = $reflection ?? new ReflectionFactory();
42 5
    }
43
44
    /**
45
     * Generate code for a function or method class for the route.
46
     *
47
     * The argument template should have two '%s' placeholders. The first is used for the argument name, the second for
48
     *   the default value.
49
     *
50
     * @param array    $route
51
     * @param callable $genArg  Callback to generate code for arguments.
52
     * @param string   $new     PHP code to instantiate class.
53
     * @return string
54
     * @throws ReflectionException
55
     */
56 2
    public function generateInvocation(array $route, callable $genArg, string $new = '(new \\%s)'): string
57
    {
58 2
        ['controller' => $controller, 'action' => $action] = $route + ['controller' => null, 'action' => null];
59
60 2
        $invokable = ($this->createInvokable)($controller, $action);
61 2
        $this->assertInvokable($invokable);
62
63 2
        $reflection = $this->getReflection($invokable);
64
        $call = $reflection instanceof ReflectionFunction
65
            ? '\\' . $invokable
66
            : $this->generateInvocationMethod($invokable, $reflection, $new);
67
68
        return $call . '(' . $this->generateInvocationArgs($reflection, $genArg) . ')';
69
    }
70
71
    /**
72
     * Generate the code for a method call.
73
     *
74
     * @param array|string     $invokable
75
     * @param ReflectionMethod $reflection
76
     * @param string           $new         PHP code to instantiate class.
77
     * @return string
78
     */
79 1
    protected function generateInvocationMethod(
80
        $invokable,
81
        ReflectionMethod $reflection,
82
        string $new = '(new \\%s)'
83
    ): string {
84 1
        if (is_string($invokable) && strpos($invokable, '::') !== false) {
85
            $invokable = explode('::', $invokable) + ['', ''];
86
        }
87
88 1
        return $invokable[1] === '__invoke' || $invokable[1] === ''
89
            ? sprintf($new, $invokable[0])
90 1
            : ($reflection->isStatic() ? "\\{$invokable[0]}::" : sprintf($new, $invokable[0]) . "->") . $invokable[1];
91
    }
92
93
    /**
94
     * Generate code for the arguments when calling the action.
95
     *
96
     * @param ReflectionFunctionAbstract $reflection
97
     * @param callable                   $genArg
98
     * @return string
99
     * @throws ReflectionException
100
     */
101 1
    protected function generateInvocationArgs(ReflectionFunctionAbstract $reflection, callable $genArg): string
102
    {
103 1
        $args = [];
104
105 1
        foreach ($reflection->getParameters() as $param) {
106
            $default = $param->isOptional() ? $param->getDefaultValue() : null;
107
            $type = $param->getType() instanceof ReflectionNamedType ? $param->getType()->getName() : null;
108
            $args[] = $genArg($param->getName(), $type, $default);
109
        }
110
111 1
        return join(', ', $args);
112
    }
113
114
    /**
115
     * Assert that invokable is a function name or array with class and method.
116
     *
117
     * @param mixed $invokable
118
     */
119 3
    protected function assertInvokable($invokable): void
120
    {
121 3
        $valid = is_callable($invokable, true) && (
122
            (
123 3
                is_string($invokable) && Regex::match('/^[a-z_]\w*(\\\\\w+)*(::[a-z_]\w*)?$/i', $invokable)->hasMatch()
124
            ) || (
125 3
                is_array($invokable) &&
126 3
                is_string($invokable[0]) && Regex::match('/^[a-z_]\w*(\\\\\w+)*$/i', $invokable[0])->hasMatch() &&
127 3
                Regex::match('/^[a-z_]\w*$/i', $invokable[1])->hasMatch()
128
            )
129
        );
130
131 3
        if ($valid) {
132 3
            return;
133
        }
134
135
        if (is_array($invokable)) {
136
            $types = array_map(static function ($item) {
137
                return is_object($item) ? get_class($item) : gettype($item);
138
            }, $invokable);
139
            $type = '[' . join(', ', $types) . ']';
140
        } else {
141
            $type = is_object($invokable) ? get_class($invokable) : gettype($invokable);
142
        }
143
144
        throw new \LogicException("Invokable should be a function or array with class name and method, {$type} given");
145
    }
146
147
    /**
148
     * Get reflection of invokable.
149
     *
150
     * @param array|string $invokable
151
     * @return ReflectionFunction|ReflectionMethod
152
     * @throws ReflectionException  if function or method doesn't exist
153
     */
154 3
    protected function getReflection($invokable): ReflectionFunctionAbstract
155
    {
156 3
        if (is_string($invokable) && strpos($invokable, '::') !== false) {
157
            $invokable = explode('::', $invokable);
158
        }
159
160 3
        return is_array($invokable)
161 3
            ? $this->reflection->reflectMethod($invokable[0], $invokable[1])
162 1
            : $this->reflection->reflectFunction($invokable);
163
    }
164
165
    /**
166
     * Generate standard code for when no route matches.
167
     *
168
     * @return string
169
     */
170 2
    public function generateDefault(): string
171
    {
172
        return <<<CODE
173 2
if (\$allowedMethods === []) {
174
    http_response_code(404);
175
    echo "Not Found";
176
} else {
177
    http_response_code(405);
178
    header('Allow: ' . join(', ', \$allowedMethods));
179
    echo "Method Not Allowed";
180
}
181
CODE;
182
    }
183
184
    /**
185
     * Default method to create invokable from controller and action FQCN.
186
     *
187
     * @param string|null $controller
188
     * @param string|null $action
189
     * @return array
190
     */
191 5
    final public static function createInvokable(?string $controller, ?string $action): array
192
    {
193 5
        if ($controller === null && $action === null) {
194
            throw new \BadMethodCallException("Neither controller or action is set");
195
        }
196
197 5
        return $controller !== null
198 4
            ? [$controller, ($action ?? 'defaultAction')]
199 5
            : [$action, '__invoke'];
200
    }
201
}
202