Issues (16)

src/Client/HttpClient.php (6 issues)

1
<?php
2
3
/**
4
 * Platine HTTP
5
 *
6
 * Platine HTTP Message is the implementation of PSR 7
7
 *
8
 * This content is released under the MIT License (MIT)
9
 *
10
 * Copyright (c) 2020 Platine HTTP
11
 * Copyright (c) 2011 - 2017 rehyved.com
12
 *
13
 * Permission is hereby granted, free of charge, to any person obtaining a copy
14
 * of this software and associated documentation files (the "Software"), to deal
15
 * in the Software without restriction, including without limitation the rights
16
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
 * copies of the Software, and to permit persons to whom the Software is
18
 * furnished to do so, subject to the following conditions:
19
 *
20
 * The above copyright notice and this permission notice shall be included in all
21
 * copies or substantial portions of the Software.
22
 *
23
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
 * SOFTWARE.
30
 */
31
32
/**
33
 *  @file HttpClient.php
34
 *
35
 *  The Http Client class
36
 *
37
 *  @package    Platine\Http\Client
38
 *  @author Platine Developers team
39
 *  @copyright  Copyright (c) 2020
40
 *  @license    http://opensource.org/licenses/MIT  MIT License
41
 *  @link   https://www.platine-php.com
42
 *  @version 1.0.0
43
 *  @filesource
44
 */
45
46
declare(strict_types=1);
47
48
namespace Platine\Http\Client;
49
50
use CurlHandle;
51
use InvalidArgumentException;
52
use Platine\Http\Client\Exception\HttpClientException;
53
use Platine\Stdlib\Helper\Json;
54
55
/**
56
 * @class HttpClient
57
 * @package Platine\Http\Client
58
 */
59
class HttpClient
60
{
61
    /**
62
     * The base URL
63
     * @var string
64
     */
65
    protected string $baseUrl;
66
67
    /**
68
     * The request headers
69
     * @var array<string, array<int, mixed>>
70
     */
71
    protected array $headers = [];
72
73
    /**
74
     * The request parameters
75
     * @var array<string, mixed>
76
     */
77
    protected array $parameters = [];
78
79
    /**
80
     * The request cookies
81
     * @var array<string, mixed>
82
     */
83
    protected array $cookies = [];
84
85
    /**
86
     * Indicating the number of seconds to use as a timeout for HTTP requests
87
     * @var int
88
     */
89
    protected int $timeout = 30;
90
91
    /**
92
     * Indicating if the validity of SSL certificates should be enforced in HTTP requests
93
     * @var bool
94
     */
95
    protected bool $verifySslCertificate = true;
96
97
    /**
98
     * The username to use for basic authentication
99
     * @var string
100
     */
101
    protected string $username = '';
102
103
    /**
104
     * The password to use for basic authentication
105
     * @var string
106
     */
107
    protected string $password = '';
108
109
    /**
110
     * Whether to enable debugging
111
     * @var bool
112
     */
113
    protected bool $debug = false;
114
115
    /**
116
     * The debug stream to use. If null will use STDERR
117
     * @var resource|null
118
     */
119
    protected $debugStream = null;
120
121
    /**
122
     * Create new instance
123
     * @param string $baseUrl
124
     */
125
    public function __construct(string $baseUrl = '')
126
    {
127
        $this->baseUrl = $baseUrl;
128
    }
129
130
    /**
131
     * Enable debug
132
     * @param bool $status
133
     * @param resource|null $stream
134
     * @return $this
135
     */
136
    public function debug(bool $status, $stream = null): self
137
    {
138
        $this->debug = $status;
139
        $this->debugStream = $stream;
140
141
        return $this;
142
    }
143
144
    /**
145
     * Set the base URL
146
     * @param string $baseUrl
147
     * @return $this
148
     */
149
    public function setBaseUrl(string $baseUrl): self
150
    {
151
        $this->baseUrl = $baseUrl;
152
        return $this;
153
    }
154
155
    /**
156
     * Return the base URL
157
     * @return string
158
     */
159
    public function getBaseUrl(): string
160
    {
161
        return $this->baseUrl;
162
    }
163
164
165
    /**
166
     * Add request header
167
     * @param string $name
168
     * @param mixed $value
169
     * @return $this
170
     */
171
    public function header(string $name, mixed $value): self
172
    {
173
        if (array_key_exists($name, $this->headers) === false) {
174
            $this->headers[$name] = [];
175
        }
176
        $this->headers[$name][] = $value;
177
178
        // Remove duplicate value
179
        $this->headers[$name] = array_unique($this->headers[$name]);
180
181
        return $this;
182
    }
183
184
    /**
185
     * Add multiple request headers
186
     * @param array<string, mixed> $headers
187
     * @return $this
188
     */
189
    public function headers(array $headers): self
190
    {
191
        foreach ($headers as $name => $value) {
192
            $this->header($name, $value);
193
        }
194
195
        return $this;
196
    }
197
198
    /**
199
     * Add request query parameter
200
     * @param string $name
201
     * @param mixed $value
202
     * @return $this
203
     */
204
    public function parameter(string $name, mixed $value): self
205
    {
206
        $this->parameters[$name] = $value;
207
208
        return $this;
209
    }
210
211
212
    /**
213
     * Add multiple request parameter
214
     * @param array<string, mixed> $parameters
215
     * @return $this
216
     */
217
    public function parameters(array $parameters): self
218
    {
219
        foreach ($parameters as $name => $value) {
220
            $this->parameter($name, $value);
221
        }
222
223
        return $this;
224
    }
225
226
    /**
227
     * Add request cookie
228
     * @param string $name
229
     * @param mixed $value
230
     * @return $this
231
     */
232
    public function cookie(string $name, mixed $value): self
233
    {
234
        $this->cookies[$name] = $value;
235
236
        return $this;
237
    }
238
239
    /**
240
     * Add multiple request cookie
241
     * @param array<string, mixed>|null $cookies
242
     * @return $this
243
     */
244
    public function cookies(?array $cookies = null): self
245
    {
246
        if ($cookies === null) {
247
            $cookies = $_COOKIE;
248
        }
249
250
        foreach ($cookies as $name => $value) {
251
            $this->cookie($name, $value);
252
        }
253
254
        return $this;
255
    }
256
257
    /**
258
     * Set the basic authentication to use on the request
259
     * @param string $usename
260
     * @param string $password
261
     * @return $this
262
     */
263
    public function basicAuthentication(string $usename, string $password = ''): self
264
    {
265
        $this->username = $usename;
266
        $this->password = $password;
267
268
        return $this;
269
    }
270
271
    /**
272
     * Set the request timeout
273
     * @param int $timeout
274
     * @return $this
275
     */
276
    public function timeout(int $timeout): self
277
    {
278
        $this->timeout = $timeout;
279
280
        return $this;
281
    }
282
283
    /**
284
     * Controls if the validity of SSL certificates should be verified.
285
     * WARNING: This should never be done in a production setup and should be used for debugging only.
286
     * @param bool $verifySslCertificate
287
     * @return self
288
     */
289
    public function verifySslCertificate(bool $verifySslCertificate): self
290
    {
291
        $this->verifySslCertificate = $verifySslCertificate;
292
293
        return $this;
294
    }
295
296
    /**
297
     * Set request content type
298
     * @param string $contentType
299
     * @return $this
300
     */
301
    public function contentType(string $contentType): self
302
    {
303
       // If this is a multipart request and boundary was not defined,
304
       // we define a boundary as this is required for multipart requests.
305
        if (stripos($contentType, 'multipart/') !== false) {
306
            if (stripos($contentType, 'boundary') === false) {
307
                $contentType .= sprintf('; boundary="%s"', uniqid((string) time()));
308
                // remove double semi-colon, except after scheme
309
                $contentType = preg_replace('/(.)(;{2,})/', '$1;', $contentType);
310
            }
311
        }
312
313
        return $this->header('Content-Type', $contentType);
314
    }
315
316
    /**
317
     * Set the request content type as JSON
318
     * @return $this
319
     */
320
    public function json(): self
321
    {
322
        $this->contentType('application/json');
323
324
        return $this;
325
    }
326
327
    /**
328
     * Set the request content type as form
329
     * @return $this
330
     */
331
    public function form(): self
332
    {
333
        $this->contentType('application/x-www-form-urlencoded');
334
335
        return $this;
336
    }
337
338
    /**
339
     * Set the request content type as multipart
340
     * @return $this
341
     */
342
    public function multipart(): self
343
    {
344
        $this->contentType('multipart/form-data');
345
346
        return $this;
347
    }
348
349
    /**
350
     * Set request accept content type
351
     * @param string $contentType
352
     * @return $this
353
     */
354
    public function accept(string $contentType): self
355
    {
356
        return $this->header('Accept', $contentType);
357
    }
358
359
    /**
360
     * Set request authorization header
361
     * @param string $scheme the scheme to use in the value of the Authorization header (e.g. Bearer)
362
     * @param string $value the value to set for the the Authorization header
363
     * @return $this
364
     */
365
    public function authorization(string $scheme, string $value): self
366
    {
367
        return $this->header('Authorization', sprintf('%s %s', $scheme, $value));
368
    }
369
370
    /**
371
     * Return the headers
372
     * @return array<string, array<int, mixed>>
373
     */
374
    public function getHeaders(): array
375
    {
376
        return $this->headers;
377
    }
378
379
    /**
380
     * Return the parameters
381
     * @return array<string, mixed>
382
     */
383
    public function getParameters(): array
384
    {
385
        return $this->parameters;
386
    }
387
388
    /**
389
     * Return the cookies
390
     * @return array<string, mixed>
391
     */
392
    public function getCookies(): array
393
    {
394
        return $this->cookies;
395
    }
396
397
    /**
398
     * Return the timeout
399
     * @return int
400
     */
401
    public function getTimeout(): int
402
    {
403
        return $this->timeout;
404
    }
405
406
    /**
407
     * Whether to verify SSL certificate
408
     * @return bool
409
     */
410
    public function isVerifySslCertificate(): bool
411
    {
412
        return $this->verifySslCertificate;
413
    }
414
415
    /**
416
     * Return the username for basic authentication
417
     * @return string
418
     */
419
    public function getUsername(): string
420
    {
421
        return $this->username;
422
    }
423
424
    /**
425
     * Return the password for basic authentication
426
     * @return string
427
     */
428
    public function getPassword(): string
429
    {
430
        return $this->password;
431
    }
432
433
    /**
434
     * Execute the request as a GET request to the specified path
435
     * @param string $path
436
     * @return HttpResponse
437
     */
438
    public function get(string $path = ''): HttpResponse
439
    {
440
        return $this->request($path, HttpMethod::GET);
441
    }
442
443
    /**
444
     * Execute the request as a POST request to the specified path
445
     * @param string $path
446
     * @param mixed $body the request body
447
     * @return HttpResponse
448
     */
449
    public function post(string $path = '', mixed $body = null): HttpResponse
450
    {
451
        return $this->request($path, HttpMethod::POST, $body);
452
    }
453
454
    /**
455
     * Execute the request as a PUT request to the specified path
456
     * @param string $path
457
     * @param mixed $body the request body
458
     * @return HttpResponse
459
     */
460
    public function put(string $path = '', mixed $body = null): HttpResponse
461
    {
462
        return $this->request($path, HttpMethod::PUT, $body);
463
    }
464
465
    /**
466
     * Execute the request as a DELETE request to the specified path
467
     * @param string $path
468
     * @param mixed $body the request body
469
     * @return HttpResponse
470
     */
471
    public function delete(string $path = '', mixed $body = null): HttpResponse
472
    {
473
        return $this->request($path, HttpMethod::DELETE, $body);
474
    }
475
476
    /**
477
     * Execute the request as a HEAD request to the specified path
478
     * @param string $path
479
     * @param mixed $body the request body
480
     * @return HttpResponse
481
     */
482
    public function head(string $path = '', mixed $body = null): HttpResponse
483
    {
484
        return $this->request($path, HttpMethod::HEAD, $body);
485
    }
486
487
    /**
488
     * Execute the request as a TRACE request to the specified path
489
     * @param string $path
490
     * @param mixed $body the request body
491
     * @return HttpResponse
492
     */
493
    public function trace(string $path = '', mixed $body = null): HttpResponse
494
    {
495
        return $this->request($path, HttpMethod::TRACE, $body);
496
    }
497
498
    /**
499
     * Execute the request as a OPTIONS request to the specified path
500
     * @param string $path
501
     * @param mixed $body the request body
502
     * @return HttpResponse
503
     */
504
    public function options(string $path = '', mixed $body = null): HttpResponse
505
    {
506
        return $this->request($path, HttpMethod::OPTIONS, $body);
507
    }
508
509
    /**
510
     * Execute the request as a CONNECT request to the specified path
511
     * @param string $path
512
     * @param mixed $body the request body
513
     * @return HttpResponse
514
     */
515
    public function connect(string $path = '', mixed $body = null): HttpResponse
516
    {
517
        return $this->request($path, HttpMethod::CONNECT, $body);
518
    }
519
520
    /**
521
     * Construct the HTTP request and sends it using the provided method and request body
522
     * @param string $path
523
     * @param non-empty-string $method
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string.
Loading history...
524
     * @param mixed $body
525
     * @return HttpResponse
526
     */
527
    public function request(
528
        string $path,
529
        string $method = HttpMethod::GET,
530
        mixed $body = null
531
    ): HttpResponse {
532
        $ch = curl_init();
533
534
        $this->processUrl($path, $ch);
0 ignored issues
show
It seems like $ch can also be of type resource; however, parameter $ch of Platine\Http\Client\HttpClient::processUrl() does only seem to accept CurlHandle, 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

534
        $this->processUrl($path, /** @scrutinizer ignore-type */ $ch);
Loading history...
535
536
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
537
538
        // Do body first as this might add additional headers
539
        $this->processBody($ch, $body);
0 ignored issues
show
It seems like $ch can also be of type resource; however, parameter $ch of Platine\Http\Client\HttpClient::processBody() does only seem to accept CurlHandle, 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

539
        $this->processBody(/** @scrutinizer ignore-type */ $ch, $body);
Loading history...
540
        $this->processHeaders($ch);
0 ignored issues
show
It seems like $ch can also be of type resource; however, parameter $ch of Platine\Http\Client\HttpClient::processHeaders() does only seem to accept CurlHandle, 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

540
        $this->processHeaders(/** @scrutinizer ignore-type */ $ch);
Loading history...
541
        $this->processCookies($ch);
0 ignored issues
show
It seems like $ch can also be of type resource; however, parameter $ch of Platine\Http\Client\HttpClient::processCookies() does only seem to accept CurlHandle, 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

541
        $this->processCookies(/** @scrutinizer ignore-type */ $ch);
Loading history...
542
543
        return $this->send($ch);
0 ignored issues
show
It seems like $ch can also be of type resource; however, parameter $ch of Platine\Http\Client\HttpClient::send() does only seem to accept CurlHandle, 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

543
        return $this->send(/** @scrutinizer ignore-type */ $ch);
Loading history...
544
    }
545
546
    /**
547
     * Send the request
548
     * @param CurlHandle $ch the cURL handle
549
     * @return HttpResponse
550
     */
551
    protected function send(CurlHandle $ch): HttpResponse
552
    {
553
        $responseHeaders = [];
554
        curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) {
555
            if (strpos($header, ':') !== false) {
556
                list($name, $value) = explode(':', $header);
557
                if (array_key_exists($name, $responseHeaders) === false) {
558
                    $responseHeaders[$name] = [];
559
                }
560
                $responseHeaders[$name][] = trim($value);
561
            }
562
563
            return strlen($header);
564
        });
565
566
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
567
        // Ensure we are coping with 300 (redirect) responses
568
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
569
        curl_setopt($ch, CURLOPT_HEADER, true);
570
571
        // Set request timeout
572
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->timeout);
573
        // Set verification of SSL certificates
574
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->verifySslCertificate);
575
576
        if (!empty($this->username)) {
577
            curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
578
            curl_setopt($ch, CURLOPT_USERPWD, sprintf('%s:%s', $this->username, $this->password));
579
        }
580
581
        if ($this->debug) {
582
            curl_setopt($ch, CURLOPT_VERBOSE, true);
583
        }
584
585
        if ($this->debugStream !== null) {
586
            curl_setopt($ch, CURLOPT_STDERR, $this->debugStream);
587
        }
588
589
        $response = curl_exec($ch);
590
        $requestInfo = curl_getinfo($ch);
591
        $error = curl_error($ch);
592
        $errorCode = curl_errno($ch);
593
        if (!empty($error)) {
594
            throw new HttpClientException($error, $errorCode);
595
        }
596
597
        return new HttpResponse($requestInfo, $responseHeaders, $response, $error);
598
    }
599
600
    /**
601
     * Process URL
602
     * @param string $path
603
     * @param CurlHandle $ch the cURL handle
604
     * @return void
605
     */
606
    protected function processUrl(string $path, CurlHandle $ch): void
607
    {
608
        /** @var non-empty-string $url */
609
        $url = $this->buildUrl($path);
610
        curl_setopt($ch, CURLOPT_URL, $url);
611
    }
612
613
    /**
614
     * Build the request full URL
615
     * @param string $path
616
     * @return string
617
     */
618
    protected function buildUrl(string $path): string
619
    {
620
        if (empty($this->baseUrl)) {
621
            throw new InvalidArgumentException('Base URL can not be empty or null');
622
        }
623
624
        $url = $this->baseUrl;
625
        if (!empty($path)) {
626
            $url .= $path;
627
        }
628
629
        if (count($this->parameters) > 0) {
630
            $url .= '?' . http_build_query($this->parameters);
631
        }
632
633
        // Clean url
634
        // remove double slashes, except after scheme
635
        $cleanUrl = (string) preg_replace('/([^:])(\/{2,})/', '$1/', $url);
636
        // convert arrays with indexes to arrays without
637
        // (i.e. parameter[0]=1 -> parameter[]=1)
638
        $finalUrl = (string) preg_replace('/%5B[0-9]+%5D/simU', '%5B%5D', $cleanUrl);
639
640
        return $finalUrl;
641
    }
642
643
    /**
644
     * Process the request headers
645
     * @param CurlHandle $ch the cURL handle
646
     * @return void
647
     */
648
    protected function processHeaders(CurlHandle $ch): void
649
    {
650
        $headers = [];
651
        foreach ($this->headers as $name => $values) {
652
            foreach ($values as $value) {
653
                $headers[] = sprintf('%s: %s', $name, $value);
654
            }
655
        }
656
657
        if (count($headers) > 0) {
658
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
659
        }
660
    }
661
662
    /**
663
     * Process the request cookies
664
     * @param CurlHandle $ch the cURL handle
665
     * @return void
666
     */
667
    protected function processCookies(CurlHandle $ch): void
668
    {
669
        $cookies = [];
670
        foreach ($this->cookies as $name => $value) {
671
            $cookies[] = sprintf('%s=%s', $name, $value);
672
        }
673
        if (count($cookies) > 0) {
674
            curl_setopt($ch, CURLOPT_COOKIE, implode(';', $cookies));
675
        }
676
    }
677
678
    /**
679
     * Process the request body
680
     * @param CurlHandle $ch the cURL handle
681
     * @param array<mixed>|object|null $body the request body
682
     * @return void
683
     */
684
    protected function processBody(CurlHandle $ch, array|object|null $body = null): void
685
    {
686
        if ($body === null) {
687
            return;
688
        }
689
690
        if (isset($this->headers['Content-Type'][0])) {
691
            $contentType = $this->headers['Content-Type'][0];
692
            if (stripos($contentType, 'application/json') !== false) {
693
                $body = Json::encode($body);
694
            } elseif (stripos($contentType, 'application/x-www-form-urlencoded') !== false) {
695
                $body = http_build_query($body);
696
            } elseif (stripos($contentType, 'multipart/form-data') !== false) {
697
                $boundary = $this->parseBoundaryFromContentType($contentType);
698
                $body = $this->buildMultipartBody(/** @var array<mixed> $body */ $body, $boundary);
699
            }
700
        }
701
702
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
703
    }
704
705
    /**
706
     * Parse boundary from request content type
707
     * @param string $contentType
708
     * @return string
709
     */
710
    protected function parseBoundaryFromContentType(string $contentType): string
711
    {
712
        $match = [];
713
        if (preg_match('/boundary="([^\"]+)"/is', $contentType, $match) > 0) {
714
            return $match[1];
715
        }
716
717
        throw new InvalidArgumentException(
718
            'The provided Content-Type header contained a "multipart/*" content type but did not '
719
                . 'define a boundary.'
720
        );
721
    }
722
723
    /**
724
     * Build the multipart body
725
     * @param array<string, mixed> $fields
726
     * @param string $boundary
727
     * @return string
728
     */
729
    protected function buildMultipartBody(array $fields, string $boundary): string
730
    {
731
        $body = '';
732
        foreach ($fields as $name => $value) {
733
            if (is_array($value)) {
734
                $data = $value['data'];
735
                $filename = $value['filename'] ?? false;
736
                $mimetype = $value['mimetype'] ?? false;
737
738
                $body .= sprintf("--%s\nContent-Disposition: form-data; name=\"%s\"", $boundary, $name);
739
                if ($filename !== false) {
740
                    $body .= sprintf(";filename=\"%s\"", $filename);
741
                }
742
743
                if ($mimetype !== false) {
744
                    $body .= sprintf("\nContent-Type: \"%s\"", $mimetype);
745
                }
746
747
                $body .= sprintf("\n\n%s\n", $data);
748
            } elseif (!empty($value)) {
749
                $body .= sprintf(
750
                    "--%s\nContent-Disposition: form-data; name=\"%s\"\n\n%s\n",
751
                    $boundary,
752
                    $name,
753
                    $value
754
                );
755
            }
756
        }
757
758
        $body .= sprintf('--%s--', $boundary);
759
760
        return $body;
761
    }
762
}
763