Passed
Push — develop ( b51090...e14bce )
by nguereza
02:11
created

HttpClient::put()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 3
rs 10
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 InvalidArgumentException;
51
use Platine\Http\Client\Exception\HttpClientException;
52
use Platine\Stdlib\Helper\Json;
53
54
/**
55
 * @class HttpClient
56
 * @package Platine\Http\Client
57
 */
58
class HttpClient
59
{
60
    /**
61
     * The base URL
62
     * @var string
63
     */
64
    protected string $baseUrl;
65
66
    /**
67
     * The request headers
68
     * @var array<string, array<int, mixed>>
69
     */
70
    protected array $headers = [];
71
72
    /**
73
     * The request parameters
74
     * @var array<string, mixed>
75
     */
76
    protected array $parameters = [];
77
78
    /**
79
     * The request cookies
80
     * @var array<string, mixed>
81
     */
82
    protected array $cookies = [];
83
84
    /**
85
     * Indicating the number of seconds to use as a timeout for HTTP requests
86
     * @var int
87
     */
88
    protected int $timeout = 30;
89
90
    /**
91
     * Indicating if the validity of SSL certificates should be enforced in HTTP requests
92
     * @var bool
93
     */
94
    protected bool $verifySslCertificate = true;
95
96
    /**
97
     * The username to use for basic authentication
98
     * @var string
99
     */
100
    protected string $username = '';
101
102
    /**
103
     * The password to use for basic authentication
104
     * @var string
105
     */
106
    protected string $password = '';
107
108
    /**
109
     * Whether to enable debugging
110
     * @var bool
111
     */
112
    protected bool $debug = false;
113
114
    /**
115
     * The debug stream to use. If null will use STDERR
116
     * @var resource|null
117
     */
118
    protected $debugStream = null;
119
120
    /**
121
     * Create new instance
122
     * @param string $baseUrl
123
     */
124
    public function __construct(string $baseUrl = '')
125
    {
126
        $this->baseUrl = $baseUrl;
127
    }
128
129
    /**
130
     * Enable debug
131
     * @param bool $status
132
     * @param resource|null $stream
133
     * @return $this
134
     */
135
    public function debug(bool $status, $stream = null): self
136
    {
137
        $this->debug = $status;
138
        $this->debugStream = $stream;
139
140
        return $this;
141
    }
142
143
    /**
144
     * Set the base URL
145
     * @param string $baseUrl
146
     * @return $this
147
     */
148
    public function setBaseUrl(string $baseUrl): self
149
    {
150
        $this->baseUrl = $baseUrl;
151
        return $this;
152
    }
153
154
    /**
155
     * Return the base URL
156
     * @return string
157
     */
158
    public function getBaseUrl(): string
159
    {
160
        return $this->baseUrl;
161
    }
162
163
164
    /**
165
     * Add request header
166
     * @param string $name
167
     * @param mixed $value
168
     * @return $this
169
     */
170
    public function header(string $name, $value): self
171
    {
172
        if (array_key_exists($name, $this->headers) === false) {
173
            $this->headers[$name] = [];
174
        }
175
        $this->headers[$name][] = $value;
176
177
        return $this;
178
    }
179
180
    /**
181
     * Add multiple request headers
182
     * @param array<string, mixed> $headers
183
     * @return $this
184
     */
185
    public function headers(array $headers): self
186
    {
187
        foreach ($headers as $name => $value) {
188
            $this->header($name, $value);
189
        }
190
191
        return $this;
192
    }
193
194
    /**
195
     * Add request query parameter
196
     * @param string $name
197
     * @param mixed $value
198
     * @return $this
199
     */
200
    public function parameter(string $name, $value): self
201
    {
202
        $this->parameters[$name] = $value;
203
204
        return $this;
205
    }
206
207
208
    /**
209
     * Add multiple request parameter
210
     * @param array<string, mixed> $parameters
211
     * @return $this
212
     */
213
    public function parameters(array $parameters): self
214
    {
215
        foreach ($parameters as $name => $value) {
216
            $this->parameter($name, $value);
217
        }
218
219
        return $this;
220
    }
221
222
    /**
223
     * Add request cookie
224
     * @param string $name
225
     * @param mixed $value
226
     * @return $this
227
     */
228
    public function cookie(string $name, $value): self
229
    {
230
        $this->cookies[$name] = $value;
231
232
        return $this;
233
    }
234
235
    /**
236
     * Add multiple request cookie
237
     * @param array<string, mixed> $cookies
238
     * @return $this
239
     */
240
    public function cookies(?array $cookies = null): self
241
    {
242
        if ($cookies === null) {
243
            $cookies = $_COOKIE;
244
        }
245
246
        foreach ($cookies as $name => $value) {
247
            $this->cookie($name, $value);
248
        }
249
250
        return $this;
251
    }
252
253
    /**
254
     * Set the basic authentication to use on the request
255
     * @param string $usename
256
     * @param string $password
257
     * @return $this
258
     */
259
    public function basicAuthentication(string $usename, string $password = ''): self
260
    {
261
        $this->username = $usename;
262
        $this->password = $password;
263
264
        return $this;
265
    }
266
267
    /**
268
     * Set the request timeout
269
     * @param int $timeout
270
     * @return $this
271
     */
272
    public function timeout(int $timeout): self
273
    {
274
        $this->timeout = $timeout;
275
276
        return $this;
277
    }
278
279
    /**
280
     * Controls if the validity of SSL certificates should be verified.
281
     * WARNING: This should never be done in a production setup and should be used for debugging only.
282
     * @param bool $verifySslCertificate
283
     * @return self
284
     */
285
    public function verifySslCertificate(bool $verifySslCertificate): self
286
    {
287
        $this->verifySslCertificate = $verifySslCertificate;
288
289
        return $this;
290
    }
291
292
    /**
293
     * Set request content type
294
     * @param string $contentType
295
     * @return $this
296
     */
297
    public function contentType(string $contentType): self
298
    {
299
       // If this is a multipart request and boundary was not defined,
300
       // we define a boundary as this is required for multipart requests.
301
        if (stripos($contentType, 'multipart/') !== false) {
302
            if (stripos($contentType, 'boundary') === false) {
303
                $contentType .= sprintf('; boundary="%s"', uniqid((string) time()));
304
                // remove double semi-colon, except after scheme
305
                $contentType = preg_replace('/(.)(;{2,})/', '$1;', $contentType);
306
            }
307
        }
308
309
        return $this->header('Content-Type', $contentType);
310
    }
311
312
    /**
313
     * Set the request content type as JSON
314
     * @return $this
315
     */
316
    public function json(): self
317
    {
318
        $this->contentType('application/json');
319
320
        return $this;
321
    }
322
323
    /**
324
     * Set the request content type as form
325
     * @return $this
326
     */
327
    public function form(): self
328
    {
329
        $this->contentType('application/x-www-form-urlencoded');
330
331
        return $this;
332
    }
333
334
    /**
335
     * Set the request content type as multipart
336
     * @return $this
337
     */
338
    public function mutlipart(): self
339
    {
340
        $this->contentType('multipart/form-data');
341
342
        return $this;
343
    }
344
345
    /**
346
     * Set request accept content type
347
     * @param string $contentType
348
     * @return $this
349
     */
350
    public function accept(string $contentType): self
351
    {
352
        return $this->header('Accept', $contentType);
353
    }
354
355
    /**
356
     * Set request authorization header
357
     * @param string $scheme the scheme to use in the value of the Authorization header (e.g. Bearer)
358
     * @param string $value the value to set for the the Authorization header
359
     * @return $this
360
     */
361
    public function authorization(string $scheme, string $value): self
362
    {
363
        return $this->header('Authorization', sprintf('%s %s', $scheme, $value));
364
    }
365
366
    /**
367
     * Return the headers
368
     * @return array<string, array<int, mixed>>
369
     */
370
    public function getHeaders(): array
371
    {
372
        return $this->headers;
373
    }
374
375
    /**
376
     * Return the parameters
377
     * @return array<string, mixed>
378
     */
379
    public function getParameters(): array
380
    {
381
        return $this->parameters;
382
    }
383
384
    /**
385
     * Return the cookies
386
     * @return array<string, mixed>
387
     */
388
    public function getCookies(): array
389
    {
390
        return $this->cookies;
391
    }
392
393
    /**
394
     * Return the timeout
395
     * @return int
396
     */
397
    public function getTimeout(): int
398
    {
399
        return $this->timeout;
400
    }
401
402
    /**
403
     * Whether to verify SSL certificate
404
     * @return bool
405
     */
406
    public function isVerifySslCertificate(): bool
407
    {
408
        return $this->verifySslCertificate;
409
    }
410
411
    /**
412
     * Return the username for basic authentication
413
     * @return string
414
     */
415
    public function getUsername(): string
416
    {
417
        return $this->username;
418
    }
419
420
    /**
421
     * Return the password for basic authentication
422
     * @return string
423
     */
424
    public function getPassword(): string
425
    {
426
        return $this->password;
427
    }
428
429
    /**
430
     * Execute the request as a GET request to the specified path
431
     * @param string $path
432
     * @return HttpResponse
433
     */
434
    public function get(string $path = ''): HttpResponse
435
    {
436
        return $this->request($path, HttpMethod::GET);
437
    }
438
439
    /**
440
     * Execute the request as a POST request to the specified path
441
     * @param string $path
442
     * @param mixed|null $body the request body
443
     * @return HttpResponse
444
     */
445
    public function post(string $path = '', $body = null): HttpResponse
446
    {
447
        return $this->request($path, HttpMethod::POST, $body);
448
    }
449
450
    /**
451
     * Execute the request as a PUT request to the specified path
452
     * @param string $path
453
     * @param mixed|null $body the request body
454
     * @return HttpResponse
455
     */
456
    public function put(string $path = '', $body = null): HttpResponse
457
    {
458
        return $this->request($path, HttpMethod::PUT, $body);
459
    }
460
461
    /**
462
     * Execute the request as a DELETE request to the specified path
463
     * @param string $path
464
     * @param mixed|null $body the request body
465
     * @return HttpResponse
466
     */
467
    public function delete(string $path = '', $body = null): HttpResponse
468
    {
469
        return $this->request($path, HttpMethod::DELETE, $body);
470
    }
471
472
    /**
473
     * Execute the request as a HEAD request to the specified path
474
     * @param string $path
475
     * @param mixed|null $body the request body
476
     * @return HttpResponse
477
     */
478
    public function head(string $path = '', $body = null): HttpResponse
479
    {
480
        return $this->request($path, HttpMethod::HEAD, $body);
481
    }
482
483
    /**
484
     * Execute the request as a TRACE request to the specified path
485
     * @param string $path
486
     * @param mixed|null $body the request body
487
     * @return HttpResponse
488
     */
489
    public function trace(string $path = '', $body = null): HttpResponse
490
    {
491
        return $this->request($path, HttpMethod::TRACE, $body);
492
    }
493
494
    /**
495
     * Execute the request as a OPTIONS request to the specified path
496
     * @param string $path
497
     * @param mixed|null $body the request body
498
     * @return HttpResponse
499
     */
500
    public function options(string $path = '', $body = null): HttpResponse
501
    {
502
        return $this->request($path, HttpMethod::OPTIONS, $body);
503
    }
504
505
    /**
506
     * Execute the request as a CONNECT request to the specified path
507
     * @param string $path
508
     * @param mixed|null $body the request body
509
     * @return HttpResponse
510
     */
511
    public function connect(string $path = '', $body = null): HttpResponse
512
    {
513
        return $this->request($path, HttpMethod::CONNECT, $body);
514
    }
515
516
    /**
517
     * Construct the HTTP request and sends it using the provided method and request body
518
     * @param string $path
519
     * @param string $method
520
     * @param mixed|null $body
521
     * @return HttpResponse
522
     */
523
    public function request(string $path, string $method = HttpMethod::GET, $body = null): HttpResponse
524
    {
525
        $ch = curl_init();
526
527
        $this->processUrl($path, $ch);
528
529
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
530
531
        // Do body first as this might add additional headers
532
        $this->processBody($ch, $body);
533
        $this->processHeaders($ch);
534
        $this->processCookies($ch);
535
536
        return $this->send($ch);
537
    }
538
539
    /**
540
     * Send the request
541
     * @param mixed $ch the cURL handle
542
     * @return HttpResponse
543
     */
544
    protected function send($ch): HttpResponse
545
    {
546
        $responseHeaders = [];
547
        curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) {
548
            if (strpos($header, ':') !== false) {
549
                list($name, $value) = explode(':', $header);
550
                if (array_key_exists($name, $responseHeaders) === false) {
551
                    $responseHeaders[$name] = [];
552
                }
553
                $responseHeaders[$name][] = trim($value);
554
            }
555
556
            return strlen($header);
557
        });
558
559
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
560
        // Ensure we are coping with 300 (redirect) responses
561
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
562
        curl_setopt($ch, CURLOPT_HEADER, true);
563
564
        // Set request timeout
565
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->timeout);
566
        // Set verification of SSL certificates
567
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->verifySslCertificate);
568
569
        if (!empty($this->username)) {
570
            curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
571
            curl_setopt($ch, CURLOPT_USERPWD, sprintf('%s:%s', $this->username, $this->password));
572
        }
573
574
        if ($this->debug) {
575
            curl_setopt($ch, CURLOPT_VERBOSE, true);
576
        }
577
578
        if ($this->debugStream !== null) {
579
            curl_setopt($ch, CURLOPT_STDERR, $this->debugStream);
580
        }
581
582
        $response = curl_exec($ch);
583
        $requestInfo = curl_getinfo($ch);
584
        $error = curl_error($ch);
585
        $errorCode = curl_errno($ch);
586
        if (!empty($error)) {
587
            throw new HttpClientException($error, $errorCode);
588
        }
589
590
        return new HttpResponse($requestInfo, $responseHeaders, $response, $error);
591
    }
592
593
    /**
594
     * Process URL
595
     * @param string $path
596
     * @param mixed $ch the cURL handle
597
     * @return void
598
     */
599
    protected function processUrl(string $path, $ch): void
600
    {
601
        $url = $this->buildUrl($path);
602
        curl_setopt($ch, CURLOPT_URL, $url);
603
    }
604
605
    /**
606
     * Build the request full URL
607
     * @param string $path
608
     * @return string
609
     */
610
    protected function buildUrl(string $path): string
611
    {
612
        if (empty($this->baseUrl)) {
613
            throw new InvalidArgumentException('Base URL can not be empty or null');
614
        }
615
616
        $url = $this->baseUrl;
617
        if (!empty($path)) {
618
            $url .= $path;
619
        }
620
621
        if (count($this->parameters) > 0) {
622
            $url .= '?' . http_build_query($this->parameters);
623
        }
624
625
        // Clean url
626
        // remove double slashes, except after scheme
627
        $cleanUrl = (string) preg_replace('/([^:])(\/{2,})/', '$1/', $url);
628
        // convert arrays with indexes to arrays without
629
        // (i.e. parameter[0]=1 -> parameter[]=1)
630
        $finalUrl = (string) preg_replace('/%5B[0-9]+%5D/simU', '%5B%5D', $cleanUrl);
631
632
        return $finalUrl;
633
    }
634
635
    /**
636
     * Process the request headers
637
     * @param mixed $ch the cURL handle
638
     * @return void
639
     */
640
    protected function processHeaders($ch): void
641
    {
642
        $headers = [];
643
        foreach ($this->headers as $name => $values) {
644
            foreach ($values as $value) {
645
                $headers[] = sprintf('%s: %s', $name, $value);
646
            }
647
        }
648
649
        if (count($headers) > 0) {
650
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
651
        }
652
    }
653
654
    /**
655
     * Process the request cookies
656
     * @param mixed $ch the cURL handle
657
     * @return void
658
     */
659
    protected function processCookies($ch): void
660
    {
661
        $cookies = [];
662
        foreach ($this->cookies as $name => $value) {
663
            $cookies[] = sprintf('%s=%s', $name, $value);
664
        }
665
        if (count($cookies) > 0) {
666
            curl_setopt($ch, CURLOPT_COOKIE, implode(';', $cookies));
667
        }
668
    }
669
670
    /**
671
     * Process the request body
672
     * @param mixed $ch the cURL handle
673
     * @param array<mixed>|object|null $body the request body
674
     * @return void
675
     */
676
    protected function processBody($ch, $body = null): void
677
    {
678
        if ($body === null) {
679
            return;
680
        }
681
682
        if (isset($this->headers['Content-Type'][0])) {
683
            $contentType = $this->headers['Content-Type'][0];
684
            if (stripos($contentType, 'application/json') !== false) {
685
                $body = Json::encode($body);
686
            } elseif (stripos($contentType, 'application/x-www-form-urlencoded') !== false) {
687
                $body = http_build_query($body);
688
            } elseif (stripos($contentType, 'multipart/form-data') !== false) {
689
                $boundary = $this->parseBoundaryFromContentType($contentType);
690
                $body = $this->buildMultipartBody(/** @var array<mixed> $body */ $body, $boundary);
691
            }
692
        }
693
694
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
695
    }
696
697
    /**
698
     * Parse boundary from request content type
699
     * @param string $contentType
700
     * @return string
701
     */
702
    protected function parseBoundaryFromContentType(string $contentType): string
703
    {
704
        $match = [];
705
        if (preg_match('/boundary="([^\"]+)"/is', $contentType, $match) > 0) {
706
            return $match[1];
707
        }
708
709
        throw new InvalidArgumentException(
710
            'The provided Content-Type header contained a "multipart/*" content type but did not '
711
                . 'define a boundary.'
712
        );
713
    }
714
715
    /**
716
     * Build the multipart body
717
     * @param array<string, mixed> $fields
718
     * @param string $boundary
719
     * @return string
720
     */
721
    protected function buildMultipartBody(array $fields, string $boundary): string
722
    {
723
        $body = '';
724
        foreach ($fields as $name => $value) {
725
            if (is_array($value)) {
726
                $data = $value['data'];
727
                $filename = $value['filename'] ?? false;
728
729
                $body .= sprintf("--%s\nContent-Disposition: form-data; name=\"%s\"", $boundary, $name);
730
                if ($filename !== false) {
731
                    $body .= sprintf(";filename=\"%s\"", $filename);
732
                }
733
734
                $body .= sprintf("\n\n%s\n", $data);
735
            } elseif (!empty($value)) {
736
                $body .= sprintf(
737
                    "--%s\nContent-Disposition: form-data; name=\"%s\"\n\n%s\n",
738
                    $boundary,
739
                    $name,
740
                    $value
741
                );
742
            }
743
        }
744
745
        $body .= sprintf('--%s--', $boundary);
746
747
        return $body;
748
    }
749
}
750