Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
94 / 94
96.43% covered (success)
96.43%
81 / 84
66.67% covered (warning)
66.67%
50 / 75
94.12% covered (success)
94.12%
32 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
Request
100.00% covered (success)
100.00%
94 / 94
96.43% covered (success)
96.43%
81 / 84
66.67% covered (warning)
66.67%
50 / 75
100.00% covered (success)
100.00%
34 / 34
162.00
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unwrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 params
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 param
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 form
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 field
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 cookies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 cookie
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serverParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 server
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 header
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 headerArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 headers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 withHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withAddedHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withoutHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 with
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 uri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 origin
100.00% covered (success)
100.00%
6 / 6
71.43% covered (warning)
71.43%
5 / 7
25.00% covered (danger)
25.00%
1 / 4
100.00% covered (success)
100.00%
1 / 1
6.80
 target
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 method
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 body
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 json
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 files
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
11 / 11
40.00% covered (danger)
40.00%
4 / 10
100.00% covered (success)
100.00%
1 / 1
10.40
 file
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
13 / 13
57.14% covered (warning)
57.14%
4 / 7
100.00% covered (success)
100.00%
1 / 1
8.83
 parsedBody
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 returnOrFail
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
7 / 7
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
4.59
 formatKeys
100.00% covered (success)
100.00%
4 / 4
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
 validateKeys
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
10 / 10
37.50% covered (danger)
37.50%
6 / 16
100.00% covered (success)
100.00%
1 / 1
11.10
 wrapping
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Core;
6
7use Celema\Core\Exception\OutOfBoundsException;
8use Celema\Core\Exception\RuntimeException;
9use Celema\Router\RequestWrapper;
10use Override;
11use Psr\Http\Message\ServerRequestInterface as PsrServerRequest;
12use Psr\Http\Message\StreamInterface as PsrStream;
13use Psr\Http\Message\UploadedFileInterface as PsrUploadedFile;
14use Psr\Http\Message\UriInterface as PsrUri;
15
16/**
17 * A reading API over a PSR-7 server request. Like the request it wraps, it
18 * is immutable: the `with*()` methods return a new wrapper, so a change is
19 * never visible to other holders of the same wrapper.
20 *
21 * @api
22 */
23class Request implements RequestWrapper
24{
25    public function __construct(
26        protected readonly PsrServerRequest $psrRequest,
27    ) {}
28
29    #[Override]
30    public function unwrap(): PsrServerRequest
31    {
32        return $this->psrRequest;
33    }
34
35    /** @return array<array-key, mixed> */
36    public function params(): array
37    {
38        return $this->psrRequest->getQueryParams();
39    }
40
41    public function param(string $key, mixed $default = null): mixed
42    {
43        $params = $this->psrRequest->getQueryParams();
44        $error = 'Query string variable not found';
45
46        return $this->returnOrFail($params, $key, $default, $error, func_num_args());
47    }
48
49    /** @return array<array-key, mixed>|null */
50    public function form(): ?array
51    {
52        return $this->parsedBody();
53    }
54
55    public function field(string $key, mixed $default = null): mixed
56    {
57        $body = $this->parsedBody();
58        $error = 'Form field not found';
59
60        return $this->returnOrFail($body, $key, $default, $error, func_num_args());
61    }
62
63    /** @return array<array-key, mixed> */
64    public function cookies(): array
65    {
66        return $this->psrRequest->getCookieParams();
67    }
68
69    public function cookie(string $key, mixed $default = null): mixed
70    {
71        $params = $this->psrRequest->getCookieParams();
72        $error = 'Cookie not found';
73
74        return $this->returnOrFail($params, $key, $default, $error, func_num_args());
75    }
76
77    /** @return array<array-key, mixed> */
78    public function serverParams(): array
79    {
80        return $this->psrRequest->getServerParams();
81    }
82
83    public function server(string $key, mixed $default = null): mixed
84    {
85        $params = $this->psrRequest->getServerParams();
86        $error = 'Server parameter not found';
87
88        return $this->returnOrFail($params, $key, $default, $error, func_num_args());
89    }
90
91    public function header(string $name): string
92    {
93        return $this->psrRequest->getHeaderLine($name);
94    }
95
96    /** @return string[] */
97    public function headerArray(string $header): array
98    {
99        return $this->psrRequest->getHeader($header);
100    }
101
102    /** @return ($firstOnly is true ? array<array-key, string> : array<array-key, string[]>) */
103    public function headers(bool $firstOnly = false): array
104    {
105        $headers = $this->psrRequest->getHeaders();
106
107        if ($firstOnly) {
108            return array_map(static fn(array $val): string => $val[0] ?? '', $headers);
109        }
110
111        return $headers;
112    }
113
114    public function withHeader(string $header, string $value): static
115    {
116        return $this->wrapping($this->psrRequest->withHeader($header, $value));
117    }
118
119    public function withAddedHeader(string $header, string $value): static
120    {
121        return $this->wrapping($this->psrRequest->withAddedHeader($header, $value));
122    }
123
124    public function withoutHeader(string $header): static
125    {
126        return $this->wrapping($this->psrRequest->withoutHeader($header));
127    }
128
129    public function hasHeader(string $header): bool
130    {
131        return $this->psrRequest->hasHeader($header);
132    }
133
134    /** @return array<array-key, mixed> */
135    public function attributes(): array
136    {
137        return $this->psrRequest->getAttributes();
138    }
139
140    /** A new wrapper whose request carries the attribute. */
141    public function with(string $attribute, mixed $value): static
142    {
143        return $this->wrapping($this->psrRequest->withAttribute($attribute, $value));
144    }
145
146    public function get(string $key, mixed $default = null): mixed
147    {
148        $params = $this->psrRequest->getAttributes();
149        $error = 'Request attribute not found';
150
151        return $this->returnOrFail($params, $key, $default, $error, func_num_args());
152    }
153
154    public function uri(): PsrUri
155    {
156        return $this->psrRequest->getUri();
157    }
158
159    public function origin(): string
160    {
161        $uri = $this->psrRequest->getUri();
162        $scheme = $uri->getScheme();
163        $origin = $scheme !== '' ? $scheme . ':' : '';
164        $authority = $uri->getAuthority();
165        $origin .= $authority !== '' ? '//' . $authority : '';
166
167        return $origin;
168    }
169
170    public function target(): string
171    {
172        return $this->psrRequest->getRequestTarget();
173    }
174
175    public function method(): string
176    {
177        return strtoupper($this->psrRequest->getMethod());
178    }
179
180    public function isMethod(string $method): bool
181    {
182        return strtoupper($method) === $this->method();
183    }
184
185    public function body(): PsrStream
186    {
187        return $this->psrRequest->getBody();
188    }
189
190    public function json(
191        int $flags = JSON_OBJECT_AS_ARRAY,
192    ): mixed {
193        $body = (string) $this->psrRequest->getBody();
194
195        return json_decode($body, true, flags: $flags);
196    }
197
198    /**
199     * Returns the uploaded files at the given keys. A single file is
200     * wrapped in a list; nested keys keep their structure.
201     *
202     * @no-named-arguments
203     *
204     * @param list<string>|string ...$keys
205     *
206     * @return array<array-key, mixed>
207     *
208     * @throws OutOfBoundsException RuntimeException
209     */
210    public function files(array|string ...$keys): array
211    {
212        $files = $this->psrRequest->getUploadedFiles();
213        $keys = $this->validateKeys($keys);
214
215        // Walk into the uploaded files structure
216        foreach ($keys as $key) {
217            if (is_array($files) && array_key_exists($key, $files)) {
218                /**
219                 * @psalm-suppress MixedAssignment
220                 *
221                 * Psalm does not support recursive types like:
222                 *     T = array<string, string|T>
223                 */
224                $files = $files[$key];
225            } else {
226                throw new OutOfBoundsException('Invalid files key ' . $this->formatKeys($keys));
227            }
228        }
229
230        // Check if it is a single file upload.
231        // A multifile upload would already produce an array
232        if ($files instanceof PsrUploadedFile) {
233            return [$files];
234        }
235
236        assert(is_array($files), 'Uploaded files selection must resolve to an array');
237
238        return $files;
239    }
240
241    /**
242     * @no-named-arguments
243     *
244     * @param list<non-empty-string>|string ...$keys
245     *
246     * @throws OutOfBoundsException RuntimeException
247     */
248    public function file(array|string ...$keys): PsrUploadedFile
249    {
250        $keys = $this->validateKeys($keys);
251
252        if (count($keys) === 0) {
253            throw new RuntimeException('No file key given');
254        }
255
256        $files = $this->psrRequest->getUploadedFiles();
257        $i = 0;
258
259        foreach ($keys as $key) {
260            if (isset($files[$key])) {
261                /** @var array<array-key, mixed>|PsrUploadedFile $files */
262                $files = $files[$key];
263                $i++;
264
265                if ($files instanceof PsrUploadedFile) {
266                    if ($i < count($keys)) {
267                        throw new OutOfBoundsException(
268                            'Invalid file key (too deep) ' . $this->formatKeys($keys),
269                        );
270                    }
271
272                    return $files;
273                }
274            } else {
275                throw new OutOfBoundsException('Invalid file key ' . $this->formatKeys($keys));
276            }
277        }
278
279        throw new RuntimeException('Multiple files available at key ' . $this->formatKeys($keys));
280    }
281
282    /** @return array<array-key, mixed>|null */
283    private function parsedBody(): ?array
284    {
285        $body = $this->psrRequest->getParsedBody();
286
287        if (is_object($body)) {
288            throw new RuntimeException('The parsed body is an object, not form data');
289        }
290
291        return $body;
292    }
293
294    /** @param array<array-key, mixed>|null $array */
295    private function returnOrFail(
296        ?array $array,
297        string $key,
298        mixed $default,
299        string $error,
300        int $numArgs,
301    ): mixed {
302        if ($array !== null && array_key_exists($key, $array)) {
303            return $array[$key];
304        }
305
306        if ($numArgs > 1) {
307            return $default;
308        }
309
310        throw new OutOfBoundsException("{$error}: '{$key}'");
311    }
312
313    /** @param non-empty-list<string> $keys */
314    private function formatKeys(array $keys): string
315    {
316        return implode('', array_map(
317            static fn(string $key): string => "['" . $key . "']",
318            $keys,
319        ));
320    }
321
322    /**
323     * @param list<list<string>|string> $keys
324     *
325     * @return list<string>
326     */
327    private function validateKeys(array $keys): array
328    {
329        if (count($keys) === 1 && is_array($keys[0])) {
330            $keys = $keys[0];
331        }
332
333        $strings = [];
334
335        foreach ($keys as $key) {
336            if (is_array($key)) {
337                throw new RuntimeException('Either provide a single array or plain string arguments');
338            }
339
340            $strings[] = $key;
341        }
342
343        return $strings;
344    }
345
346    /** A copy for the changed request; subclasses keep their own state. */
347    private function wrapping(PsrServerRequest $request): static
348    {
349        return clone($this, ['psrRequest' => $request]);
350    }
351}