Uri::withPath()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 1
dl 0
loc 13
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Class Uri
4
 *
5
 * @created      10.08.2018
6
 * @author       smiley <[email protected]>
7
 * @copyright    2018 smiley
8
 * @license      MIT
9
 */
10
11
namespace chillerlan\HTTP\Psr7;
12
13
use chillerlan\HTTP\Utils\{QueryUtil, UriUtil};
14
use InvalidArgumentException;
15
use Psr\Http\Message\UriInterface;
16
17
use function call_user_func_array, explode, filter_var, is_array, is_string, ltrim, mb_strtolower,
18
	preg_replace_callback, rawurlencode, strtolower, str_contains, str_starts_with, ucfirst, var_export;
19
20
use const FILTER_FLAG_IPV6, FILTER_VALIDATE_IP;
21
22
class Uri implements UriInterface{
23
24
	protected string  $scheme   = '';
25
	protected string  $user     = '';
26
	protected ?string $pass     = null;
27
	protected string  $host     = '';
28
	protected ?int    $port     = null;
29
	protected string  $path     = '';
30
	protected string  $query    = '';
31
	protected string  $fragment = '';
32
33
	/**
34
	 * Uri constructor.
35
	 *
36
	 * @throws \InvalidArgumentException
37
	 */
38
	public function __construct(string|array $uri = null){
39
40
		if($uri !== null){
41
42
			if(is_string($uri)){
0 ignored issues
show
introduced by
The condition is_string($uri) is always false.
Loading history...
43
				$uri = QueryUtil::parseUrl($uri);
44
			}
45
46
			if(!is_array($uri)){
0 ignored issues
show
introduced by
The condition is_array($uri) is always true.
Loading history...
47
				throw new InvalidArgumentException('invalid URI: '.var_export($uri, true));
48
			}
49
50
			$this->parseUriParts($uri);
51
			$this->validateState();
52
		}
53
54
	}
55
56
	/**
57
	 * @inheritDoc
58
	 */
59
	public function __toString():string{
60
		$this->validateState();
61
62
		$uri       = '';
63
		$authority = $this->getAuthority();
64
65
		if($this->scheme !== ''){
66
			$uri .= $this->scheme.':';
67
		}
68
69
		if($authority !== '' || $this->scheme === 'file'){
70
			$uri .= '//'.$authority;
71
		}
72
73
		$uri .= $this->path;
74
75
		if($this->query !== ''){
76
			$uri .= '?'.$this->query;
77
		}
78
79
		if($this->fragment !== ''){
80
			$uri .= '#'.$this->fragment;
81
		}
82
83
		return $uri;
84
	}
85
86
	/*
87
	 * Scheme
88
	 */
89
90
	/**
91
	 * @throws \InvalidArgumentException
92
	 */
93
	protected function filterScheme(mixed $scheme):string{
94
95
		if(!is_string($scheme)){
96
			throw new InvalidArgumentException('scheme must be a string');
97
		}
98
99
		return strtolower($scheme);
100
	}
101
102
	/**
103
	 * @inheritDoc
104
	 */
105
	public function getScheme():string{
106
		return $this->scheme;
107
	}
108
109
	/**
110
	 * @inheritDoc
111
	 */
112
	public function withScheme($scheme):UriInterface{
113
		$scheme = $this->filterScheme($scheme);
114
115
		if($this->scheme === $scheme){
116
			return $this;
117
		}
118
119
		$clone         = clone $this;
120
		$clone->scheme = $scheme;
121
122
		$clone->removeDefaultPort();
123
		$clone->validateState();
124
125
		return $clone;
126
	}
127
128
	/*
129
	 * Authority
130
	 */
131
132
	/**
133
	 * @throws \InvalidArgumentException
134
	 */
135
	protected function filterUser(mixed $user):string{
136
137
		if(!is_string($user)){
138
			throw new InvalidArgumentException('user must be a string');
139
		}
140
141
		return $user;
142
	}
143
144
	/**
145
	 * @throws \InvalidArgumentException
146
	 */
147
	protected function filterPass(mixed $pass):string{
148
149
		if(!is_string($pass)){
150
			throw new InvalidArgumentException('pass must be a string');
151
		}
152
153
		return $pass;
154
	}
155
156
	/**
157
	 * @inheritDoc
158
	 */
159
	public function getAuthority():string{
160
		$authority = $this->host;
161
		$userInfo  = $this->getUserInfo();
162
163
		if($userInfo !== ''){
164
			$authority = $userInfo.'@'.$authority;
165
		}
166
167
		if($this->port !== null){
168
			$authority .= ':'.$this->port;
169
		}
170
171
		return $authority;
172
	}
173
174
	/**
175
	 * @inheritDoc
176
	 */
177
	public function getUserInfo():string{
178
		return $this->user.($this->pass != '' ? ':'.$this->pass : '');
179
	}
180
181
	/**
182
	 * @inheritDoc
183
	 */
184
	public function withUserInfo($user, $password = null):UriInterface{
185
		$info = $user;
186
187
		if($password !== null && $password !== ''){
188
			$info .= ':'.$password;
189
		}
190
191
		if($this->getUserInfo() === $info){
192
			return $this;
193
		}
194
195
		$clone       = clone $this;
196
		$clone->user = $user;
197
		$clone->pass = $password;
198
199
		$clone->validateState();
200
201
		return $clone;
202
	}
203
204
	/*
205
	 * Host
206
	 */
207
208
	/**
209
	 * @throws \InvalidArgumentException
210
	 */
211
	protected function filterHost(mixed $host):string{
212
213
		if(!is_string($host)){
214
			throw new InvalidArgumentException('host must be a string');
215
		}
216
217
		if(filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)){
218
			$host = '['.$host.']';
219
		}
220
221
		return mb_strtolower($host);
222
	}
223
224
	/**
225
	 * @inheritDoc
226
	 */
227
	public function getHost():string{
228
		return $this->host;
229
	}
230
231
	/**
232
	 * @inheritDoc
233
	 */
234
	public function withHost($host):UriInterface{
235
		$host = $this->filterHost($host);
236
237
		if($this->host === $host){
238
			return $this;
239
		}
240
241
		$clone       = clone $this;
242
		$clone->host = $host;
243
244
		$clone->validateState();
245
246
		return $clone;
247
	}
248
249
	/*
250
	 * Port
251
	 */
252
253
	/**
254
	 * @throws \InvalidArgumentException
255
	 */
256
	protected function filterPort(mixed $port):?int{
257
258
		if($port === null){
259
			return null;
260
		}
261
262
		$port = (int)$port;
263
264
		if($port >= 1 && $port <= 0xffff){
265
			return $port;
266
		}
267
268
		throw new InvalidArgumentException('invalid port: '.$port);
269
	}
270
271
	/**
272
	 * @inheritDoc
273
	 */
274
	public function getPort():?int{
275
		return $this->port;
276
	}
277
278
	/**
279
	 * @inheritDoc
280
	 */
281
	public function withPort($port):UriInterface{
282
		$port = $this->filterPort($port);
283
284
		if($this->port === $port){
285
			return $this;
286
		}
287
288
		$clone       = clone $this;
289
		$clone->port = $port;
290
291
		$clone->removeDefaultPort();
292
		$clone->validateState();
293
294
		return $clone;
295
	}
296
297
	/*
298
	 * Path
299
	 */
300
301
	/**
302
	 * @throws \InvalidArgumentException
303
	 */
304
	protected function filterPath(mixed $path):string{
305
306
		if(!is_string($path)){
307
			throw new InvalidArgumentException('path must be a string');
308
		}
309
310
		return $this->replaceChars($path);
311
	}
312
313
	/**
314
	 * @inheritDoc
315
	 */
316
	public function getPath():string{
317
		return $this->path;
318
	}
319
320
	/**
321
	 * @inheritDoc
322
	 */
323
	public function withPath($path):UriInterface{
324
		$path = $this->filterPath($path);
325
326
		if($this->path === $path){
327
			return $this;
328
		}
329
330
		$clone       = clone $this;
331
		$clone->path = $path;
332
333
		$clone->validateState();
334
335
		return $clone;
336
	}
337
338
	/*
339
	 * Query
340
	 */
341
342
	/**
343
	 * @throws \InvalidArgumentException
344
	 */
345
	protected function filterQuery(mixed $query):string{
346
347
		if(!is_string($query)){
348
			throw new InvalidArgumentException('query and fragment must be a string');
349
		}
350
351
		return $this->replaceChars($query, true);
352
	}
353
354
	/**
355
	 * @inheritDoc
356
	 */
357
	public function getQuery():string{
358
		return $this->query;
359
	}
360
361
	/**
362
	 * @inheritDoc
363
	 */
364
	public function withQuery($query):UriInterface{
365
		$query = $this->filterQuery($query);
366
367
		if($this->query === $query){
368
			return $this;
369
		}
370
371
		$clone        = clone $this;
372
		$clone->query = $query;
373
374
		$clone->validateState();
375
376
		return $clone;
377
	}
378
379
	/*
380
	 * Fragment
381
	 */
382
383
	/**
384
	 *
385
	 */
386
	protected function filterFragment(mixed $fragment):string{
387
		return $this->filterQuery($fragment);
388
	}
389
390
	/**
391
	 * @inheritDoc
392
	 */
393
	public function getFragment():string{
394
		return $this->fragment;
395
	}
396
397
	/**
398
	 * @inheritDoc
399
	 */
400
	public function withFragment($fragment):UriInterface{
401
		$fragment = $this->filterFragment($fragment);
402
403
		if($this->fragment === $fragment){
404
			return $this;
405
		}
406
407
		$clone           = clone $this;
408
		$clone->fragment = $fragment;
409
410
		$clone->validateState();
411
412
		return $clone;
413
	}
414
415
	/**
416
	 *
417
	 */
418
	protected function parseUriParts(array $parts):void{
419
420
		foreach(['scheme', 'user', 'pass', 'host', 'port', 'path', 'query', 'fragment'] as $part){
421
422
			if(!isset($parts[$part])){
423
				continue;
424
			}
425
426
			$this->{$part} = call_user_func_array([$this, 'filter'.ucfirst($part)], [$parts[$part]]);
427
		}
428
429
		$this->removeDefaultPort();
430
	}
431
432
	/**
433
	 *
434
	 */
435
	protected function replaceChars(string $str, bool $query = null):string{
436
		/** @noinspection RegExpRedundantEscape, RegExpUnnecessaryNonCapturingGroup */
437
		return preg_replace_callback(
438
			'/(?:[^a-z\d_\-\.~!\$&\'\(\)\*\+,;=%:@\/'.($query ? '\?' : '').']++|%(?![a-f\d]{2}))/i',
439
			fn(array $match):string => rawurlencode($match[0]),
440
			$str
441
		);
442
443
	}
444
445
	/**
446
	 *
447
	 */
448
	protected function removeDefaultPort():void{
449
450
		if(UriUtil::isDefaultPort($this)){
451
			$this->port = null;
452
		}
453
454
	}
455
456
	/**
457
	 *
458
	 */
459
	protected function validateState():void{
460
461
		if(empty($this->host) && ($this->scheme === 'http' || $this->scheme === 'https')){
462
			$this->host = 'localhost';
463
		}
464
465
		if($this->getAuthority() !== ''){
466
467
			if(isset($this->path[0]) && $this->path[0] !== '/'){
468
				$this->path = '/'.$this->path; // automagically fix the path, unlike Guzzle
469
			}
470
471
		}
472
		else{
473
474
			if(str_starts_with($this->path, '//')){
475
				$this->path = '/'.ltrim($this->path, '/'); // automagically fix the path, unlike Guzzle
476
			}
477
478
			if(empty($this->scheme) && str_contains(explode('/', $this->path, 2)[0], ':')){
479
				throw new InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon');
480
			}
481
482
		}
483
484
	}
485
486
}
487