Passed
Push — main ( ebe3be...5a020f )
by Dimitri
22:02 queued 12s
created

RestController::_remap()   B

Complexity

Conditions 6
Paths 18

Size

Total Lines 44
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 6
eloc 26
c 3
b 0
f 0
nc 18
nop 2
dl 0
loc 44
rs 8.8817
1
<?php
2
3
/**
4
 * This file is part of Blitz PHP framework.
5
 *
6
 * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace BlitzPHP\Controllers;
13
14
use BlitzPHP\Annotations\AnnotationReader;
15
use BlitzPHP\Annotations\Http\AjaxOnlyAnnotation;
16
use BlitzPHP\Annotations\Http\RequestMappingAnnotation;
17
use BlitzPHP\Contracts\Http\StatusCode;
18
use BlitzPHP\Formatter\Formatter;
19
use BlitzPHP\Loader\Services;
20
use BlitzPHP\Traits\ApiResponseTrait;
21
use BlitzPHP\Utilities\Jwt;
22
use mindplay\annotations\IAnnotation;
23
use Psr\Http\Message\ResponseInterface;
24
use stdClass;
25
use Throwable;
26
27
/**
28
 * Le contrôleur de base pour les API REST
29
 */
30
class RestController extends BaseController
31
{
32
    use ApiResponseTrait;
33
34
    /**
35
     * Configurations
36
     *
37
     * @var stdClass
38
     */
39
    protected $config;
40
41
    /**
42
     * Langue à utiliser
43
     *
44
     * @var string
45
     */
46
    private $locale;
47
48
    /**
49
     * Type mime associé à chaque format de sortie
50
     *
51
     * Répertoriez tous les formats pris en charge, le première sera le format par défaut.
52
     */
53
    protected $mimes = [
54
        'json' => 'application/json',
55
        'csv'  => 'application/csv',
56
        // 'html'       => 'text/html',
57
        'jsonp'      => 'application/javascript',
58
        'php'        => 'text/plain',
59
        'serialized' => 'application/vnd.php.serialized',
60
        'xml'        => 'application/xml',
61
62
        'array' => 'php/array',
63
    ];
64
65
    /**
66
     * @var array|object Payload provenant du token jwt
67
     */
68
    protected $payload;
69
70
    public function __construct()
71
    {
72
        $this->config = (object) config('rest');
73
74
        $locale       = $this->config->language ?? null;
75
        $this->locale = ! empty($locale) ? $locale : $this->request->getLocale();
76
    }
77
78
    public function _remap(string $method, array $params = [])
79
    {
80
        $class = static::class;
81
82
        // Bien sûr qu'il existe, mais peuvent-ils en faire quelque chose ?
83
        if (! method_exists($class, $method)) {
84
            return $this->respondNotImplemented($this->_translate('notImplemented', [$class, $method]));
85
        }
86
87
        // Appel de la méthode du contrôleur et passage des arguments
88
        try {
89
            $instance = Services::injector()->get($class);
90
            $instance->initialize($this->request, $this->response, $this->logger);
91
92
            $instance = $this->_execAnnotations($instance, AnnotationReader::fromClass($instance));
93
            $instance = $this->_execAnnotations($instance, AnnotationReader::fromMethod($instance, $method));
94
95
            $checkProcess = $this->checkProcess();
96
            if ($checkProcess instanceof ResponseInterface) {
0 ignored issues
show
introduced by
$checkProcess is always a sub-type of Psr\Http\Message\ResponseInterface.
Loading history...
97
                return $checkProcess;
98
            }
99
100
            $instance->payload = $this->payload;
101
102
            $response = Services::injector()->call([$instance, $method], (array) $params);
103
104
            if ($response instanceof ResponseInterface) {
105
                return $response;
106
            }
107
108
            return $this->respondOk($response);
109
        } catch (Throwable $ex) {
110
            if (! on_dev()) {
111
                $url = explode('?', $this->request->getRequestTarget())[0];
112
113
                return $this->respondBadRequest($this->_translate('badUsed', [$url]));
114
            }
115
116
            return $this->respondInternalError('Internal Server Error', [
117
                'type'    => get_class($ex),
118
                'message' => $ex->getMessage(),
119
                'code'    => $ex->getCode(),
120
                'file'    => $ex->getFile(),
121
                'line'    => $ex->getLine(),
122
            ]);
123
        }
124
    }
125
126
    /**
127
     * Fournit une méthode simple et unique pour renvoyer une réponse d'API, formatée
128
     * pour correspondre au format demandé, avec le type de contenu et le code d'état appropriés.
129
     *
130
     * @param mixed    $data   Les donnees a renvoyer
131
     * @param int|null $status Le statut de la reponse
132
     */
133
    final protected function respond($data, ?int $status = StatusCode::OK)
134
    {
135
        // Si les données sont NULL et qu'aucun code d'état HTTP n'est fourni, affichage, erreur et sortie
136
        if ($data === null && $status === null) {
137
            $status = StatusCode::NOT_FOUND;
138
        }
139
140
        $this->response = $this->response->withStatus($status)->withCharset(strtolower(config('app.charset') ?? 'utf-8'));
141
142
        $this->_parseResponse($data);
143
144
        return $this->response;
145
    }
146
147
    /**
148
     * Utilisé pour les échecs génériques pour lesquels aucune méthode personnalisée n'existe.
149
     *
150
     * @param string          $message Le message décrivant l'erreur
151
     * @param int|string|null $code    Code d'erreur personnalisé, spécifique à l'API
152
     * @param array           $errors  La liste des erreurs rencontrées
153
     *
154
     * @return \Psr\Http\Message\ResponseInterface
155
     */
156
    final protected function respondFail(?string $message = "Une erreur s'est produite", ?int $status = StatusCode::INTERNAL_ERROR, int|string|null $code = null, array $errors = [])
157
    {
158
        $message = $message ?: "Une erreur s'est produite";
159
        $code    = ! empty($code) ? $code : $status;
160
161
        $response = [
162
            $this->config->field['message'] ?? 'message' => $message,
163
        ];
164
        if (! empty($this->config->field['status'])) {
165
            $response[$this->config->field['status']] = false;
166
        }
167
        if (! empty($this->config->field['code'])) {
168
            $response[$this->config->field['code']] = $code;
169
        }
170
        if (! empty($errors)) {
171
            $response[$this->config->field['errors'] ?? 'errors'] = $errors;
172
        }
173
174
        if ($this->config->strict !== true) {
175
            $status = StatusCode::OK;
176
        }
177
178
        return $this->respond($response, $status);
179
    }
180
181
    /**
182
     * Utilisé pour les succès génériques pour lesquels aucune méthode personnalisée n'existe.
183
     *
184
     * @param mixed|null $result Les données renvoyées par l'API
185
     *
186
     * @return \Psr\Http\Message\ResponseInterface
187
     */
188
    final protected function respondSuccess(?string $message = 'Resultat', $result = null, ?int $status = StatusCode::OK)
189
    {
190
        $message = $message ?: 'Resultat';
191
        $status  = ! empty($status) ? $status : StatusCode::OK;
192
193
        $response = [
194
            $this->config->field['message'] ?? 'message' => $message,
195
        ];
196
        if (! empty($this->config->field['status'])) {
197
            $response[$this->config->field['status']] = true;
198
        }
199
        if (is_array($result)) {
200
            $result = array_map(fn ($element) => $this->formatEntity($element), $result);
201
        }
202
203
        $response[$this->config->field['result'] ?? 'result'] = $this->formatEntity($result);
204
205
        return $this->respond($response, $status);
206
    }
207
208
    /**
209
     * Formatte les données à renvoyer lorsqu'il s'agit des objets de la classe Entity
210
     *
211
     * @param mixed $element
212
     *
213
     * @return mixed
214
     */
215
    protected function formatEntity($element)
216
    {
217
        /*
218
        if ($element instanceof Entity) {
219
            if (method_exists($element, 'format')) {
220
                return Services::injector()->call([$element, 'format']);
221
            }
222
223
            return call_user_func([$element, 'toArray']);
224
        }
225
        */
226
        return $element;
227
    }
228
229
    /**
230
     * Genere un token d'authentification
231
     */
232
    protected function generateToken(array $data = [], array $config = []): string
233
    {
234
        $config = array_merge(['base_url' => base_url()], $this->config->jwt ?? [], $config);
235
236
        try {
237
            return Jwt::encode($data, $config);
238
        } catch (\Exception $e) {
239
            return $this->respondInternalError($e->getMessage());
240
        }
241
    }
242
243
    /**
244
     * Decode un token d'autorisation
245
     *
246
     * @return mixed
247
     */
248
    protected function decodeToken(string $token, string $authType = 'bearer', array $config = [])
249
    {
250
        $config = array_merge(['base_url' => base_url()], $this->config->jwt ?? [], $config);
251
252
        if ('bearer' === $authType) {
253
            try {
254
                return JWT::decode($token, $config);
255
            } catch (Throwable $e) {
256
                return $e;
257
            }
258
        }
259
260
        return null;
261
    }
262
263
    /**
264
     * Recupere le token d'acces a partier des headers
265
     */
266
    protected function getBearerToken(): ?string
267
    {
268
        return Jwt::getToken();
269
    }
270
271
    /**
272
     * Recupere le header "Authorization"
273
     */
274
    protected function getAuthorizationHeader(): ?string
275
    {
276
        return Jwt::getAuthorization();
277
    }
278
279
    /**
280
     * Une méthode pratique pour traduire une chaîne ou un tableau d'entrées et
281
     * formater le résultat avec le MessageFormatter de l'extension intl.
282
     */
283
    protected function lang(string $line, ?array $args = null): string
284
    {
285
        return lang($line, $args, $this->locale);
286
    }
287
288
    private function _translate(string $line, ?array $args = null): string
289
    {
290
        return $this->lang('Rest.' . $line, $args);
291
    }
292
293
    /**
294
     * Specifie que seules les requetes ajax sont acceptees
295
     */
296
    final protected function ajaxOnly(): self
297
    {
298
        $this->config->ajax_only = true;
299
300
        return $this;
301
    }
302
303
    /**
304
     * Definit les methodes authorisees par le web service
305
     */
306
    final protected function allowedMethods(string ...$methods): self
307
    {
308
        if (! empty($methods)) {
309
            $this->config->allowed_methods = array_map(static fn ($str) => strtoupper($str), $methods);
310
        }
311
312
        return $this;
313
    }
314
315
    /**
316
     * Definit le format de donnees a renvoyer au client
317
     */
318
    final protected function returnFormat(string $format): self
319
    {
320
        $this->config->format = $format;
321
322
        return $this;
323
    }
324
325
    /**
326
     * N'autorise que les acces pas https
327
     */
328
    final protected function requireHttps(): self
329
    {
330
        $this->config->force_https = true;
331
332
        return $this;
333
    }
334
335
    /**
336
     * auth
337
     *
338
     * @param false|string $type
339
     */
340
    final protected function auth($type): self
341
    {
342
        $this->config->auth = $type;
343
344
        return $this;
345
    }
346
347
    /**
348
     * Definit la liste des adresses IP a bannir
349
     * Si le premier argument vaut "false", la suite ne sert plus a rien
350
     */
351
    final protected function ipBlacklist(...$params): self
352
    {
353
        $params = func_get_args();
354
        $enable = array_shift($params);
355
356
        if (false === $enable) {
357
            $params = [];
358
        } else {
359
            array_unshift($params, $enable);
360
            $params = array_merge($this->config->ip_blacklist ?? [], $params);
361
        }
362
363
        $this->config->ip_blacklist = $params;
364
365
        return $this;
366
    }
367
368
    /**
369
     * Definit la liste des adresses IP qui sont autorisees a acceder a la ressources
370
     * Si le premier argument vaut "false", la suite ne sert plus a rien
371
     */
372
    final protected function ipWhitelist(...$params): self
373
    {
374
        $params = func_get_args();
375
        $enable = array_shift($params);
376
377
        if (false === $enable) {
378
            $params = [];
379
        } else {
380
            array_unshift($params, $enable);
381
            $params = array_merge($this->config->ip_whitelist ?? [], $params);
382
        }
383
384
        $this->config->ip_whitelist = $params;
385
386
        return $this;
387
    }
388
389
    /**
390
     * Formatte les donnees a envoyer au bon format
391
     *
392
     * @param mixed $data Les donnees a envoyer
393
     */
394
    private function _parseResponse($data)
395
    {
396
        $format = strtolower($this->config->format);
397
        $mime   = null;
398
399
        if (array_key_exists($format, $this->mimes)) {
400
            $mime = $this->mimes[$format];
401
        } elseif (in_array($format, $this->mimes, true)) {
402
            $mime = $format;
403
        }
404
405
        // Si la méthode de format existe, appelle et renvoie la sortie dans ce format
406
        if (! empty($mime)) {
407
            $output = Formatter::type($mime)->format($data);
408
409
            // Définit l'en-tête du format
410
            // Ensuite, vérifiez si le client a demandé un rappel, et si la sortie contient ce rappel :
411
            $callback = $this->request->getQuery('callback');
412
            if (! empty($callback) && $mime === $this->mimes['json'] && preg_match('/^' . $callback . '/', $output)) {
0 ignored issues
show
Bug introduced by
Are you sure $callback of type array|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

412
            if (! empty($callback) && $mime === $this->mimes['json'] && preg_match('/^' . /** @scrutinizer ignore-type */ $callback . '/', $output)) {
Loading history...
413
                $this->response = $this->response->withType($this->mimes['jsonp']);
414
            } else {
415
                $this->response = $this->response->withType($mime === $this->mimes['array'] ? $this->mimes['json'] : $mime);
416
            }
417
418
            // Un tableau doit être analysé comme une chaîne, afin de ne pas provoquer d'erreur de tableau en chaîne
419
            // Json est la forme la plus appropriée pour un tel type de données
420
            if ($mime === $this->mimes['array']) {
421
                $output = Formatter::type($this->mimes['json'])->format($output);
422
            }
423
        } else {
424
            // S'il s'agit d'un tableau ou d'un objet, analysez-le comme un json, de manière à être une 'chaîne'
425
            if (is_array($data) || is_object($data)) {
426
                $data = Formatter::type($this->mimes['json'])->format($data);
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type object; however, parameter $data of BlitzPHP\Formatter\FormatterInterface::format() does only seem to accept array|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

426
                $data = Formatter::type($this->mimes['json'])->format(/** @scrutinizer ignore-type */ $data);
Loading history...
427
            }
428
            // Le format n'est pas pris en charge, sortez les données brutes sous forme de chaîne
429
            $output = $data;
430
        }
431
432
        $this->response = $this->response->withStringBody($output);
433
    }
434
435
    /**
436
     * Execute les annotations definies dans le contrôleur
437
     *
438
     * @param IAnnotation[] $annotations Liste des annotations d'un contrôleur/méthode
439
     */
440
    protected function _execAnnotations(self $instance, array $annotations): self
441
    {
442
        foreach ($annotations as $annotation) {
443
            switch (get_type_name($annotation)) {
444
                case RequestMappingAnnotation::class:
445
                    $this->allowedMethods(...(array) $annotation->method);
0 ignored issues
show
Bug introduced by
Accessing method on the interface mindplay\annotations\IAnnotation suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
446
                    break;
447
448
                case AjaxOnlyAnnotation::class:
449
                    $this->ajaxOnly();
450
                    break;
451
452
                default:
453
                    break;
454
            }
455
        }
456
457
        return $instance;
458
    }
459
460
    /**
461
     * Verifie si les informations fournis par le client du ws sont conforme aux attentes du developpeur
462
     *
463
     * @throws Exception
464
     */
465
    private function checkProcess(): bool|ResponseInterface
466
    {
467
        // Verifie si la requete est en ajax
468
        if (! $this->request->is('ajax') && $this->config->ajax_only) {
469
            return $this->respondNotAcceptable($this->_translate('ajaxOnly'));
470
        }
471
472
        // Verifie si la requete est en https
473
        if (! $this->request->is('https') && $this->config->force_https) {
474
            return $this->respondForbidden($this->_translate('unsupported'));
475
        }
476
477
        // Verifie si la methode utilisee pour la requete est autorisee
478
        if (! in_array(strtoupper($this->request->getMethod()), $this->config->allowed_methods, true)) {
479
            return $this->respondNotAcceptable($this->_translate('unknownMethod'));
480
        }
481
482
        // Verifie que l'ip qui emet la requete n'est pas dans la blacklist
483
        if (! empty($this->config->ip_blacklis)) {
484
            $this->config->ip_blacklist = implode(',', $this->config->ip_blacklist);
485
486
            // Correspond à une adresse IP dans une liste noire, par ex. 127.0.0.0, 0.0.0.0
487
            $pattern = sprintf('/(?:,\s*|^)\Q%s\E(?=,\s*|$)/m', $this->request->clientIp());
488
489
            // Renvoie 1, 0 ou FALSE (en cas d'erreur uniquement). Donc convertir implicitement 1 en TRUE
490
            if (preg_match($pattern, $this->config->ip_blacklist)) {
491
                return $this->respondUnauthorized($this->_translate('ipDenied'));
492
            }
493
        }
494
495
        // Verifie que l'ip qui emet la requete est dans la whitelist
496
        if (! empty($this->config->ip_whitelist)) {
497
            $whitelist = $this->config->ip_whitelist;
498
            array_push($whitelist, '127.0.0.1', '0.0.0.0');
499
500
            // coupez les espaces de début et de fin des ip
501
            $whitelist = array_map('trim', $whitelist);
502
503
            if (! in_array($this->request->clientIp(), $whitelist, true)) {
504
                return $this->respondUnauthorized($this->_translate('ipUnauthorized'));
505
            }
506
        }
507
508
        // Verifie l'authentification du client
509
        if (false !== $this->config->auth && ! $this->request->is('options')) {
510
            if ('bearer' === strtolower($this->config->auth)) {
511
                $token = $this->getBearerToken();
512
                if (empty($token)) {
513
                    return $this->respondInvalidToken($this->_translate('tokenNotFound'));
514
                }
515
516
                $payload = $this->decodeToken($token, 'bearer');
517
                if ($payload instanceof Throwable) {
518
                    return $this->respondInvalidToken($payload->getMessage());
519
                }
520
                $this->payload = $payload;
521
            }
522
        }
523
524
        return true;
525
    }
526
}
527