Completed
Push — master ( f63f47...49e847 )
by Indra
05:36
created

Builder.php (5 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace IndraGunawan\RestService;
4
5
use GuzzleHttp;
6
use GuzzleHttp\Command\CommandInterface;
7
use GuzzleHttp\Exception\BadResponseException as GuzzleBadResponseException;
8
use GuzzleHttp\Psr7\Request;
9
use IndraGunawan\RestService\Exception\BadRequestException;
10
use IndraGunawan\RestService\Exception\BadResponseException;
11
use IndraGunawan\RestService\Exception\CommandException;
12
use IndraGunawan\RestService\Exception\ValidatorException;
13
use IndraGunawan\RestService\Validator\Validator;
14
use Psr\Http\Message\RequestInterface;
15
use Psr\Http\Message\ResponseInterface;
16
17
class Builder
18
{
19
    /**
20
     * @var ServiceInterface
21
     */
22
    private $service;
23
24
    /**
25
     * @param ServiceInterface $service
26
     */
27
    public function __construct(ServiceInterface $service)
28
    {
29
        $this->service = $service;
30
    }
31
32
    /**
33
     * Transform command to request.
34
     *
35
     * @return \Closure
36
     */
37
    public function commandToRequestTransformer()
38
    {
39
        return function (CommandInterface $command) {
40
            if (!$this->service->hasOperation($command->getName())) {
41
                throw new CommandException(
42
                    sprintf(
43
                        'Command "%s" not found',
44
                        $command->getName()
45
                    ),
46
                    $command
47
                );
48
            }
49
50
            $operation = $this->service->getOperation($command->getName());
51
52
            $result = $this->transformData($command, $command->toArray(), $operation ?: [], 'request');
53
54
            $uri = ltrim($operation['requestUri'], '/');
55
            if ($result['uri']) {
56
                // replace uri
57
                $patterns = [];
58
                $replacements = [];
59
                foreach ($result['uri'] as $key => $value) {
60
                    $patterns[] = '/{'.$key.'}/';
61
                    $replacements[] = $value;
62
                }
63
64
                $uri = preg_replace($patterns, $replacements, $uri);
65
            }
66
67
            $body = null;
68
            if ('rest_json' === $operation['requestProtocol']) {
69
                $body = GuzzleHttp\json_encode($result['body']);
70
                $result['header']['Content-Type'] = 'application/json';
71
            } elseif ('form_params' === $operation['requestProtocol']) {
72
                $body = http_build_query($result['body'], '', '&');
73
                $result['header']['Content-Type'] = 'application/x-www-form-urlencoded';
74
            }
75
76
            if ($result['query']) {
77
                $uri .= sprintf('?%s', http_build_query($result['query'], null, '&', PHP_QUERY_RFC3986));
78
            }
79
80
            // GET method has no body, concat to uri
81
            if (in_array($operation['httpMethod'], ['GET'])) {
82
                // if uri has no query string, append '?' else '&'
83
                $uri .= (false === strpos($uri, '?')) ? '?' : '&';
84
85
                $uri .= http_build_query($result['body'], null, '&', PHP_QUERY_RFC3986);
86
                $result['body'] = null;
87
            }
88
89
            return new Request(
90
                $operation['httpMethod'],
91
                $this->service->getEndpoint().$uri,
92
                $result['header'],
93
                $body
94
            );
95
        };
96
    }
97
98
    /**
99
     * Transform response to result.
100
     *
101
     * @return \Closure
102
     */
103
    public function responseToResultTransformer()
104
    {
105
        return function (
106
            ResponseInterface $response,
107
            RequestInterface $request,
108
            CommandInterface $command
109
        ) {
110
            $operation = $this->service->getOperation($command->getName());
111
            $this->processResponseError($operation ?: [], $request, $response);
112
113
            $body = [];
114
            if ('rest_json' === $operation['responseProtocol']) {
115
                $body = GuzzleHttp\json_decode($response->getBody(), true);
116
            }
117
118
            $result = $this->transformData($command, $body, $operation, 'response');
119
120
            foreach ($response->getHeaders() as $name => $header) {
121
                $result['header'][$name] = is_array($header) ? array_pop($header) : null;
122
            }
123
124
            return new Result($result['body'], $result['header']);
125
        };
126
    }
127
128
    public function badResponseExceptionParser()
129
    {
130
        return function (CommandInterface $command, GuzzleBadResponseException $e) {
131
            $operation = $this->service->getOperation($command->getName());
132
133
            $this->processResponseError(
134
                $operation ?: [],
135
                $e->getRequest(),
136
                $e->getResponse(),
137
                $e
138
            );
139
        };
140
    }
141
142
    /**
143
     * Process response to check is error or not.
144
     *
145
     * @param array                           $operation
146
     * @param RequestInterface                $request
147
     * @param ResponseInterface               $response
148
     * @param GuzzleBadResponseException|null $e
149
     *
150
     * @throws \IndraGunawan\RestService\Exception\BadResponseException|null
151
     */
152
    private function processResponseError(
153
        array $operation,
154
        RequestInterface $request,
155
        ResponseInterface $response = null,
156
        GuzzleBadResponseException $e = null
157
    ) {
158
        $body = null;
159
        if ('rest_json' === $operation['responseProtocol']) {
160
            try {
161
                $body = GuzzleHttp\json_decode((!is_null($response)) ? $response->getBody() : '', true);
0 ignored issues
show
It seems like !is_null($response) ? $response->getBody() : '' can also be of type object<Psr\Http\Message\StreamInterface>; however, GuzzleHttp\json_decode() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
162
            } catch (\InvalidArgumentException $ex) {
163
                throw new BadResponseException(
164
                    '',
165
                    $ex->getMessage(),
166
                    ($e ? $e->getMessage() : ''),
167
                    $request,
168
                    $response,
169
                    $e ? $e->getPrevious() : null
170
                );
171
            }
172
        }
173
174
        if ($body) {
175
            foreach ($operation['errors'] as $name => $error) {
176
                if ('field' === $error['type']) {
177
                    $responseCode = $this->parseError($body, $error['codeField']);
178
179
                    if (!$responseCode) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $responseCode of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
180
                        continue;
181
                    }
182
183
                    // if no ifCode property then return exception
184
                    if (!$error['ifCode']) {
185
                        $responseMessage = $this->getErrorMessage($body, $error, $response);
186
187
                        throw new BadResponseException(
188
                            $responseCode,
189
                            $responseMessage,
190
                            ($e ? $e->getMessage() : '').' code: '.$responseCode.', message: '.$responseMessage,
191
                            $request,
192
                            $response,
193
                            $e ? $e->getPrevious() : null
194
                        );
195
                    }
196
                } else {
197
                    $responseCode = $response->getStatusCode();
0 ignored issues
show
It seems like $response is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
198
                }
199
200
                if ($this->checkResponseCode($responseCode, $error['operator'], $error['ifCode'])) {
201
                    $responseMessage = $this->getErrorMessage($body, $error, $response);
202
203
                    throw new BadResponseException(
204
                        $responseCode,
205
                        $responseMessage,
206
                        ($e ? $e->getMessage() : '').'. code: '.$responseCode.', message: '.$responseMessage,
207
                        $request,
208
                        $response,
209
                        $e ? $e->getPrevious() : null
210
                    );
211
                }
212
            }
213
        }
214
215
        return;
216
    }
217
218
    /**
219
     * Parse error codeField from body.
220
     *
221
     * @param array  $body
222
     * @param string $path
223
     *
224
     * @return string|null
225
     */
226
    private function parseError(array $body, $path)
227
    {
228
        $tmp = $body;
229
        foreach (explode('.', $path) as $key) {
230
            if (!isset($tmp[$key])) {
231
                return;
232
            }
233
234
            $tmp = $tmp[$key];
235
        }
236
237
        return $tmp;
238
    }
239
240
    /**
241
     * Get error message from posible field.
242
     *
243
     * @param array                  $body
244
     * @param array                  $error
245
     * @param ResponseInterface|null $response
246
     *
247
     * @return string
248
     */
249
    private function getErrorMessage(array $body, array $error, ResponseInterface $response = null)
250
    {
251
        $message = $this->parseError($body, $error['messageField']);
252
        if (!$message) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $message of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
253
            $message = $error['defaultMessage'];
254
        }
255
        if (!$message) {
256
            $message = $response->getReasonPhrase();
0 ignored issues
show
It seems like $response is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
257
        }
258
259
        return $message;
260
    }
261
262
    /**
263
     * Transform and match data with shape.
264
     *
265
     * @param CommandInterface $command
266
     * @param array            $datas
267
     * @param array            $operation
268
     * @param string           $action
269
     *
270
     * @return array
271
     */
272
    private function transformData(CommandInterface $command, array $datas, array $operation, $action)
273
    {
274
        $result = [
275
            'header' => [],
276
            'uri' => [],
277
            'query' => [],
278
            'body' => [],
279
        ];
280
281
        $validator = new Validator();
282
283
        if (isset($operation[$action])) {
284
            $shape = $operation[$action];
285
        } else {
286
            $shape = [
287
                'type' => null,
288
                'members' => [],
289
            ];
290
        }
291
292
        $result['body'] = $this->createData(
293
            $validator,
294
            $datas,
295
            $shape,
296
            $command->getName(),
297
            $action,
298
            $result,
299
            ('request' === $action) ? $operation['strictRequest'] : $operation['strictResponse'],
300
            $operation['sentEmptyField']
301
        );
302
303
        if (!$validator->validate([$command->getName() => $datas])) {
304
            // validation failed
305
            throw $validator->createValidatorException();
306
        }
307
308
        return $result;
309
    }
310
311
    /**
312
     * Create request/response data and add validation rule.
313
     *
314
     * @param Validator $validator
315
     * @param array     $datas
316
     * @param array     $shape
317
     * @param string    $path
318
     * @param string    $action
319
     * @param array     &$result
320
     * @param bool      $isStrict
321
     * @param bool      $isSentEmptyField
322
     *
323
     * @return array
324
     */
325
    private function createData(
326
        Validator $validator,
327
        array &$datas,
328
        array $shape,
329
        $path,
330
        $action,
331
        array &$result,
332
        $isStrict = false,
333
        $isSentEmptyField = true
334
    ) {
335
        $tmpData = $datas;
336
        $bodyResult = [];
337
        if ('list' === $shape['type']) {
338
            $path .= '[*]';
339
        }
340
341
        foreach ($shape['members'] as $name => $parameter) {
342
            $tmpPath = $path.'['.$name.']';
343
            $value = isset($datas[$name]) ? $datas[$name] : null;
344
            if (!$value) {
345
                $datas[$name] = $parameter['defaultValue'];
346
            }
347
348
            // set validator
349
            $validator->add($tmpPath, $parameter, $value);
350
351
            // if nested children
352
            if (isset($parameter['members']) && count($parameter['members']) > 0) {
353
                if (!is_null($value) && !is_array($value)) {
354
                    throw new ValidatorException($tmpPath, sprintf(
355
                        'Expected "%s", but got "%s"',
356
                        $parameter['type'],
357
                        gettype($value)
358
                    ));
359
                }
360
                if ('list' === $parameter['type']) {
361
                    $children = $value ?: [];
362
                    foreach ($children as $idx => $child) {
363
                        if (!is_array($child)) {
364
                            throw new ValidatorException($tmpPath, 'Expected "list", but got "map"');
365
                        }
366
367
                        $bodyResult[$parameter['locationName']][] = $this->createData(
368
                            $validator,
369
                            $datas[$name][$idx],
370
                            $parameter,
371
                            $tmpPath,
372
                            $action,
373
                            $result,
374
                            $isStrict,
375
                            $isSentEmptyField
376
                        );
377
                    }
378
                } elseif ('map' === $parameter['type']) {
379
                    if (is_null($value)) {
380
                        if (array_key_exists($name, $datas)) {
381
                            unset($tmpData[$name]);
382
                        }
383
                        continue;
384
                    }
385
                    $children = $value ?: [];
386
                    foreach (array_keys($parameter['members']) as $key) {
387
                        if (!array_key_exists($key, $children)) {
388
                            $children[$key] = null;
389
                            $datas[$name][$key] = null;
390
                        }
391
                    }
392
393
                    $bodyResult[$parameter['locationName']] = $this->createData(
394
                        $validator,
395
                        $datas[$name],
396
                        $parameter,
397
                        $tmpPath,
398
                        $action,
399
                        $result,
400
                        $isStrict,
401
                        $isSentEmptyField
402
                    );
403
                }
404
            }
405
406
            $formattedValue = $this->getFormatedValue($value, $parameter, $action);
407
            if ('body' !== $parameter['location']) {
408
                $result[$parameter['location']][$parameter['locationName']] = $formattedValue;
409
            } else {
410
                if (!array_key_exists($parameter['locationName'], $bodyResult)) {
411
                    if (!$value && !is_numeric($value)) {
412
                        $value = $parameter['defaultValue'];
413
                    }
414
                    if ($isSentEmptyField || ($value || is_numeric($value))) {
415
                        $bodyResult[$parameter['locationName']] = $formattedValue;
416
                    }
417
                }
418
            }
419
            unset($tmpData[$name]);
420
        }
421
422
        if (count($tmpData) > 0) {
423
            if ($isStrict) {
424
                throw new BadRequestException(ucwords($action), 'Undefined parameters "'.implode('", "', array_keys($tmpData)).'"');
425
            }
426
            foreach ($tmpData as $name => $child) {
427
                $bodyResult[$name] = $child;
428
            }
429
        }
430
431
        return $bodyResult;
432
    }
433
434
    /**
435
     * Get formatted value.
436
     *
437
     * @param mixed  $value     [description]
438
     * @param array  $parameter [description]
439
     * @param string $action    request/response
440
     *
441
     * @return mixed
442
     */
443
    private function getFormatedValue($value, array $parameter, $action)
444
    {
445
        if (!$value && !is_numeric($value)) {
446
            $value = $parameter['defaultValue'];
447
        }
448
449
        switch ($parameter['type']) {
450
            case 'integer':
451
                return (int) (string) $value;
452
            case 'float':
453
                return (float) (string) $value;
454
            case 'string':
455
                $result = (string) $value;
456
457
                return sprintf($parameter['format'] ?: '%s', $result);
458
            case 'boolean':
459
                return ($value === 'true' || true === $value) ? true : false;
460
            case 'number':
461
                if ($parameter['format']) {
462
                    $format = explode('|', $parameter['format']);
463
                    $decimal = isset($format[0]) ? $format[0] : 0;
464
                    $decimalPoint = isset($format[1]) ? $format[1] : '.';
465
                    $thousandsSeparator = isset($format[2]) ? $format[2] : ',';
466
467
                    return number_format((float) (string) $value, $decimal, $decimalPoint, $thousandsSeparator);
468
                }
469
470
                return (string) $value;
471
            case 'datetime':
472
                if ('request' === $action) {
473
                    if (!$value) {
474
                        return;
475
                    }
476
477
                    if (!($value instanceof \DateTime)) {
478
                        $value = new \DateTime($value);
479
                    }
480
481
                    if ($parameter['format']) {
482
                        return $value->format($parameter['format']);
483
                    }
484
485
                    return $value->format('Y-m-d\TH:i:s\Z');
486
                } elseif ('response' === $action) {
487
                    if ($parameter['format']) {
488
                        return \DateTime::createFromFormat($parameter['format'], $value);
489
                    } else {
490
                        return new \DateTime($value);
491
                    }
492
                }
493
                //
494
            default:
495
                return;
496
        }
497
    }
498
499
    /**
500
     * Check is responsecode is match with code.
501
     *
502
     * @param string $responseCode
503
     * @param string $operator
504
     * @param string $code
505
     *
506
     * @return bool
507
     */
508
    public function checkResponseCode($responseCode, $operator, $code)
509
    {
510
        switch ($operator) {
511
            case '===':
512
                return $responseCode === $code;
513
            case '!==':
514
                return $responseCode !== $code;
515
            case '!=':
516
                return $responseCode != $code;
517
            case '<':
518
                return $responseCode < $code;
519
            case '<=':
520
                return $responseCode <= $code;
521
            case '>=':
522
                return $responseCode >= $code;
523
            case '>':
524
                return $responseCode > $code;
525
            default:
526
                return $responseCode == $code;
527
        }
528
    }
529
}
530