Completed
Push — master ( 38eb66...1c8944 )
by Yaro
01:37
created

ApiDocs::fillParamsWithRequestRules()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 17
rs 9.4285
cc 3
eloc 10
nc 3
nop 2
1
<?php 
2
3
namespace Yaro\ApiDocs;
4
5
use ReflectionClass;
6
use Illuminate\Routing\Router;
7
use Illuminate\Contracts\Config\Repository as Config;
8
use Illuminate\Http\Request;
9
use Yaro\ApiDocs\Blueprint;
10
11
class ApiDocs
12
{
13
    
14
    private $router;
15
    private $config;
16
    private $request;
17
    
18
    public function __construct(Router $router, Config $config, Request $request)
19
    {
20
        $this->router  = $router;
21
        $this->config  = $config;
22
        $this->request = $request;
23
    } // end __construct
24
25
    public function show($routePrefix = null)
26
    {
27
        $currentPrefix = $this->request->get('prefix', $this->getRoutePrefix($routePrefix));
28
        $endpoints = $this->getEndpoints($currentPrefix);
29
        $endpoints = $this->getSortedEndpoints($endpoints);
30
        $prefixes  = $this->getRoutePrefixes();
31
        
32
        return view('apidocs::docs', compact('endpoints', 'prefixes', 'currentPrefix'));
33
    } // end show
34
35
    public function blueprint($routePrefix = null)
36
    {
37
        $routePrefix = $this->request->get('prefix', $this->getRoutePrefix($routePrefix));
38
        
39
        $blueprint = app()->make(Blueprint::class);
40
        $blueprint->setRoutePrefix($routePrefix);
41
        $blueprint->setEndpoints($this->getEndpoints($routePrefix));
42
        
43
        return $blueprint;
44
    } // end blueprint
45
    
46
    private function getEndpoints($routePrefix)
47
    {
48
        $endpoints = [];
49
50
        foreach ($this->router->getRoutes() as $route) {
51
            if (!$this->isPrefixedRoute($route, $routePrefix) || $this->isClosureRoute($route) || $this->isExcluded($route)) {
52
                continue;
53
            }
54
            
55
            $actionController = explode("@", $this->getRouteParam($route, 'action.controller'));
56
            $class  = $actionController[0];
57
            $method = $actionController[1];
58
            
59
            if (!class_exists($class) || !method_exists($class, $method)) {
60
                continue;
61
            }
62
63
64
            list($title, $description, $params) = $this->getRouteDocBlock($class, $method);
65
            $key = $this->generateEndpointKey($class);
66
            
67
            $endpoints[$key][] = [
68
                'hash'    => $this->generateHashForUrl($key, $route, $method),
69
                'uri'     => $this->getRouteParam($route, 'uri'),
70
                'name'    => $method,
71
                'methods' => $this->getRouteParam($route, 'methods'),
72
                'docs' => [
73
                    'title'       => $title, 
74
                    'description' => trim($description), 
75
                    'params'      => $params,
76
                    'uri_params'  => $this->getUriParams($route),
77
                ],
78
            ];
79
        }
80
81
        return $endpoints;
82
    } // end getEndpoints
83
    
84
    private function isExcluded($route)
85
    {
86
        $uri = $this->getRouteParam($route, 'uri');
87
        $actionController = $this->getRouteParam($route, 'action.controller');
88
        
89
        return $this->isExcludedClass($actionController) || $this->isExcludedRoute($uri);
90
    } // end isExcluded
91
    
92
    private function isExcludedRoute($uri)
93
    {
94
        foreach ($this->config->get('yaro.apidocs.exclude.routes', []) as $pattern) {
95
            if (str_is($pattern, $uri)) {
96
                return true;
97
            }
98
        }
99
        
100
        return false;
101
    } // end isExcludedRoute
102
    
103
    private function isExcludedClass($actionController)
104
    {
105
        foreach ($this->config->get('yaro.apidocs.exclude.classes', []) as $pattern) {
106
            if (str_is($pattern, $actionController)) {
107
                return true;
108
            }
109
        }
110
        
111
        return false;
112
    } // end isExcludedClass
113
    
114
    private function isPrefixedRoute($route, $routePrefix)
115
    {
116
        $regexp = '~^'. preg_quote($routePrefix) .'~';
117
        
118
        return preg_match($regexp, $this->getRouteParam($route, 'uri'));
119
    } // end isPrefixedRoute
120
    
121
    private function getRoutePrefix($routePrefix)
122
    {
123
        $prefixes = $this->getRoutePrefixes();
124
        
125
        return in_array($routePrefix, $prefixes) ? $routePrefix : array_shift($prefixes);
126
    }
127
    
128
    private function getRoutePrefixes()
129
    {
130
        $prefixes = $this->config->get('yaro.apidocs.prefix', 'api');
131
        if (!is_array($prefixes)) {
132
            $prefixes = [$prefixes];
133
        }
134
        
135
        return $prefixes;
136
    }
137
    
138
    private function isClosureRoute($route)
139
    {
140
        $action = $this->getRouteParam($route, 'action.uses');
141
        
142
        return is_object($action);
143
    } // end isClosureRoute
144
    
145
    private function getRouteDocBlock($class, $method)
146
    {
147
        $reflector = new ReflectionClass($class);
148
149
        $title = implode(' ', $this->splitCamelCaseToWords($method));
150
        $title = ucfirst(strtolower($title));
151
        $description = '';
152
        $params = [];
153
154
        $reflectorMethod = $reflector->getMethod($method);
155
        $docs = explode("\n", $reflectorMethod->getDocComment());
156
        $docs = array_filter($docs);
157
        if (!$docs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $docs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
158
            return [$title, $description, $params];
159
        }
160
        
161
        $docs = $this->filterDocBlock($docs);
162
        
163
        $title = array_shift($docs);
164
        
165
        $checkForLongDescription = true;
166
        foreach ($docs as $line) {
167
            if ($checkForLongDescription && !preg_match('~^@\w+~', $line)) {
168
                $description .= trim($line) .' ';
169
            } elseif (preg_match('~^@\w+~', $line)) {
170
                $checkForLongDescription = false;
171
                if (preg_match('~^@param~', $line)) {
172
                    $paramChunks = $this->getParamChunksFromLine($line);
173
                    
174
                    $paramType = array_shift($paramChunks);
175
                    $paramName = substr(array_shift($paramChunks), 1);
176
                    $params[$paramName] = [
177
                        'type'        => $paramType,
178
                        'name'        => $paramName,
179
                        'description' => implode(' ', $paramChunks),
180
                        'template'    => $this->getParamTemplateByType($paramType),
181
                    ];
182
                }
183
            }
184
        }
185
186
        // TODO:
187
        $rules = [];
188
        foreach($reflectorMethod->getParameters() as $reflectorParam) {
189
            $paramClass = $reflectorParam->getClass();
190
            if ($paramClass instanceof ReflectionClass) {
191
                $name = $paramClass->getName();
0 ignored issues
show
Bug introduced by
Consider using $paramClass->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
192
                $paramClassInstance = new $name;
193
                if (is_a($paramClassInstance, Request::class) && method_exists($paramClassInstance, 'rules')) {
194
                    $paramClassInstance->__apidocs = true;
195
                    $rules = $paramClassInstance->rules();
196
                }
197
            }
198
        }
199
200
        $params = $this->fillParamsWithRequestRules($params, $rules);
201
202
        foreach ($params as $name => $param) {
203
            $params[$name]['rules'] = $rules[$name] ?? [];
204
            if (is_string($params[$name]['rules'])) {
205
                $params[$name]['rules'] = explode('|', $params[$name]['rules']);
206
            } elseif (is_array($params[$name]['rules'])) {
207
                $params[$name]['rules'] = $params[$name]['rules'];
208
            }
209
        }
210
211
        return [$title, $description, $params];
212
    } // end getRouteDocBlock
213
214
    private function fillParamsWithRequestRules($params, $rules)
215
    {
216
        foreach ($rules as $paramName => $rule) {
217
            if (isset($params[$paramName])) {
218
                continue;
219
            }
220
221
            $params[$paramName] = [
222
                'type'        => 'string',
223
                'name'        => $paramName,
224
                'description' => '',
225
                'template'    => $this->getParamTemplateByType('string'),
226
            ];
227
        }
228
229
        return $params;
230
    }
231
    
232
    private function getParamTemplateByType($paramType)
233
    {
234
        switch ($paramType) {
235
            case 'file':
0 ignored issues
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
236
                return 'file';
237
                
238
            case 'bool':
0 ignored issues
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
239
            case 'boolean':
240
                return 'boolean';
241
                
242
            case 'int':
243
                return 'integer';
244
                
245
            case 'text':
246
            case 'string':
247
            default:
248
                return 'string';
249
        }
250
    } // end getParamTemplateByType
251
    
252
    private function getParamChunksFromLine($line)
253
    {
254
        $paramChunks = explode(' ', $line);
255
        $paramChunks = array_filter($paramChunks, function($val) {
256
            return $val !== '';
257
        });
258
        unset($paramChunks[0]);
259
        
260
        return $paramChunks;
261
    } // end getParamChunksFromLine
262
    
263
    private function filterDocBlock($docs)
264
    {
265
        foreach ($docs as &$line) {
266
            $line = preg_replace('~\s*\*\s*~', '', $line);
267
            $line = preg_replace('~^/$~', '', $line);
268
        }
269
        $docs = array_values(array_filter($docs));
270
        
271
        return $docs;
272
    } // end filterDocBlock
273
    
274
    private function generateEndpointKey($class)
275
    {
276
        $disabledNamespaces = $this->config->get('yaro.apidocs.disabled_namespaces', []);
277
        
278
        $chunks = explode('\\', $class);
279
        foreach ($chunks as $index => $chunk) {
280
            if (in_array($chunk, $disabledNamespaces)) {
281
                unset($chunks[$index]);
282
                continue;
283
            }
284
            
285
            $chunk = preg_replace('~Controller$~', '', $chunk);
286
            if ($chunk) {
287
                $chunk = $this->splitCamelCaseToWords($chunk);
288
                $chunks[$index] = implode(' ', $chunk);
289
            }
290
        }
291
           
292
        return implode('.', $chunks);
293
    } // end generateEndpointKey
294
    
295
    private function getSortedEndpoints($endpoints)
296
    {
297
        ksort($endpoints);
298
299
        $sorted = array();
300
        foreach ($endpoints as $key => $val) {
301
            $this->ins($sorted, explode('.', $key), $val);
302
        }
303
        
304
        return $sorted;
305
    } // end getSortedEndpoints
306
    
307
    private function getUriParams($route)
308
    {
309
        preg_match_all('~{(\w+)}~', $this->getRouteParam($route, 'uri'), $matches);
310
        
311
        return isset($matches[1]) ? $matches[1] : [];
312
    } // end getUriParams
313
    
314
    private function generateHashForUrl($key, $route, $method)
315
    {
316
        $path = preg_replace('~\s+~', '-', $key);
317
        $httpMethod = $this->getRouteParam($route, 'methods.0');
318
        $classMethod = implode('-', $this->splitCamelCaseToWords($method));
319
        
320
        $hash = $path .'::'. $httpMethod .'::'. $classMethod;
321
        
322
        return strtolower($hash);
323
    } // end generateHashForUrl
324
    
325
    private function splitCamelCaseToWords($chunk)
326
    {
327
        $splitCamelCaseRegexp = '/(?#! splitCamelCase Rev:20140412)
328
            # Split camelCase "words". Two global alternatives. Either g1of2:
329
              (?<=[a-z])      # Position is after a lowercase,
330
              (?=[A-Z])       # and before an uppercase letter.
331
            | (?<=[A-Z])      # Or g2of2; Position is after uppercase,
332
              (?=[A-Z][a-z])  # and before upper-then-lower case.
333
            /x';
334
            
335
        return preg_split($splitCamelCaseRegexp, $chunk);
336
    } // end splitCamelCaseToWords
337
    
338
    private function getRouteParam($route, $param)
339
    {
340
        $route = (array) $route;
341
        $prefix = chr(0).'*'.chr(0);
342
        
343
        return $this->arrayGet(
344
            $route, 
345
            $prefix.$param, 
346
            $this->arrayGet($route, $param)
347
        );
348
    } // end getRouteParam
349
    
350
    private function ins(&$ary, $keys, $val) 
351
    {
352
        $keys ? 
353
            $this->ins($ary[array_shift($keys)], $keys, $val) :
354
            $ary = $val;
355
    } // end ins
356
    
357
    private function arrayGet($array, $key, $default = null)
358
    {
359
        if (is_null($key)) {
360
            return $array;
361
        }
362
        
363
        if (isset($array[$key])) {
364
            return $array[$key];
365
        }
366
        
367
        foreach (explode('.', $key) as $segment) {
368
            if (!is_array($array) || ! array_key_exists($segment, $array)) {
369
                return $default;
370
            }
371
            $array = $array[$segment];
372
        }
373
        
374
        return $array;
375
    }
376
    
377
}
378