Lines 77.82% 193 / 248
Methods 64.28% 36 / 56
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 8 / 8 100.00% 1 / 1 3
 capture 71.42% 15 / 21 0.00% 0 / 1 9.49
 create 100.00% 25 / 25 100.00% 1 / 1 10
 getRequestTarget 85.71% 6 / 7 0.00% 0 / 1 4.05
 withRequestTarget 100.00% 3 / 3 100.00% 1 / 1 1
 getMethod 100.00% 1 / 1 100.00% 1 / 1 1
 withMethod 80.00% 4 / 5 0.00% 0 / 1 3.07
 getUri 100.00% 1 / 1 100.00% 1 / 1 1
 withUri 100.00% 9 / 9 100.00% 1 / 1 5
 getServerParams 100.00% 1 / 1 100.00% 1 / 1 1
 getCookieParams 100.00% 1 / 1 100.00% 1 / 1 1
 withCookieParams 100.00% 3 / 3 100.00% 1 / 1 1
 getQueryParams 100.00% 1 / 1 100.00% 1 / 1 1
 withQueryParams 100.00% 3 / 3 100.00% 1 / 1 1
 getUploadedFiles 100.00% 1 / 1 100.00% 1 / 1 1
 withUploadedFiles 100.00% 4 / 4 100.00% 1 / 1 1
 assertUploadedFilesTree 100.00% 6 / 6 100.00% 1 / 1 4
 getParsedBody 100.00% 1 / 1 100.00% 1 / 1 1
 withParsedBody 100.00% 5 / 5 100.00% 1 / 1 4
 getAttributes 0.00% 0 / 1 0.00% 0 / 1 2
 getAttribute 100.00% 1 / 1 100.00% 1 / 1 2
 withAttribute 100.00% 3 / 3 100.00% 1 / 1 1
 withoutAttribute 100.00% 3 / 3 100.00% 1 / 1 1
 getRouteInfo 100.00% 1 / 1 100.00% 1 / 1 1
 getUrlVars 0.00% 0 / 1 0.00% 0 / 1 2
 getUrlVar 0.00% 0 / 1 0.00% 0 / 1 2
 getContext 0.00% 0 / 2 0.00% 0 / 1 6
 withContext 0.00% 0 / 2 0.00% 0 / 1 2
 getQueryParam 0.00% 0 / 1 0.00% 0 / 1 6
 getCookie 0.00% 0 / 1 0.00% 0 / 1 6
 getServerParam 0.00% 0 / 1 0.00% 0 / 1 6
 getParsedBodyValue 0.00% 0 / 5 0.00% 0 / 1 30
 getUploadedFile 0.00% 0 / 3 0.00% 0 / 1 12
 validate 100.00% 5 / 5 100.00% 1 / 1 1
 withProtocolVersionInternal 0.00% 0 / 1 0.00% 0 / 1 2
 extractHeaders 91.66% 11 / 12 0.00% 0 / 1 7.03
 normalizeUploadedFiles 42.85% 3 / 7 0.00% 0 / 1 9.66
 createUploadedFile 0.00% 0 / 17 0.00% 0 / 1 72
 [Lucent\Http\Message\AbstractMessage] getProtocolVersion 100.00% 1 / 1 100.00% 1 / 1 1
 [Lucent\Http\Message\AbstractMessage] withProtocolVersion 100.00% 3 / 3 100.00% 1 / 1 1
 [Lucent\Http\Message\AbstractMessage] getHeaders 100.00% 1 / 1 100.00% 1 / 1 1
 [Lucent\Http\Message\AbstractMessage] hasHeader 100.00% 1 / 1 100.00% 1 / 1 1
 [Lucent\Http\Message\AbstractMessage] getHeader 100.00% 5 / 5 100.00% 1 / 1 2
 [Lucent\Http\Message\AbstractMessage] getHeaderLine 100.00% 4 / 4 100.00% 1 / 1 2
 [Lucent\Http\Message\AbstractMessage] withHeader 100.00% 10 / 10 100.00% 1 / 1 3
 [Lucent\Http\Message\AbstractMessage] withAddedHeader 100.00% 12 / 12 100.00% 1 / 1 3
 [Lucent\Http\Message\AbstractMessage] withoutHeader 87.50% 7 / 8 0.00% 0 / 1 2.01
 [Lucent\Http\Message\AbstractMessage] getBody 100.00% 3 / 3 100.00% 1 / 1 2
 [Lucent\Http\Message\AbstractMessage] withBody 100.00% 3 / 3 100.00% 1 / 1 1
 [Lucent\Http\Message\AbstractMessage] setBody 100.00% 1 / 1 100.00% 1 / 1 1
 [Lucent\Http\Message\AbstractMessage] setHeaders 100.00% 2 / 2 100.00% 1 / 1 2
 [Lucent\Http\Message\AbstractMessage] withHeaderInternal 100.00% 6 / 6 100.00% 1 / 1 3
 [Lucent\Http\Message\AbstractMessage] normalizeHeaderName 100.00% 1 / 1 100.00% 1 / 1 1
 [Lucent\Http\Message\AbstractMessage] assertHeaderName 50.00% 3 / 6 0.00% 0 / 1 8.12
 [Lucent\Http\Message\AbstractMessage] assertHeaderValue 66.66% 4 / 6 0.00% 0 / 1 5.93
 [Lucent\Http\Message\AbstractMessage] sanitizeHeaderValue 100.00% 1 / 1 100.00% 1 / 1 1
29class ServerRequest extends AbstractMessage implements ServerRequestInterface
30{
31    /** @var string HTTP method */
32    private string $method = 'GET';
33
34    /** @var UriInterface */
35    private UriInterface $uri;
36
37    /** @var array Server parameters ($_SERVER) */
38    private array $serverParams = [];
39
40    /** @var array Cookie parameters ($_COOKIE) */
41    protected array $cookieParams = [];
42
43    /** @var array Query string parameters ($_GET) */
44    protected array $queryParams = [];
45
46    /** @var array Uploaded files ($_FILES) */
47    private array $uploadedFiles = [];
48
49    /** @var array|object|null Parsed body ($_POST or parsed JSON) */
50    protected array|object|null $parsedBody = null;
51
52    /** @var array Attributes (PSR-7 extension mechanism â€” stores routeInfo, urlVars, context) */
53    private array $attributes = [];
54
55    /** @var string|null Request target */
56    private ?string $requestTarget = null;
57
58    private function __construct(
59        string $method = 'GET',
60        ?UriInterface $uri = null,
61        array $serverParams = [],
62    ) {
63        parent::__construct();
64        $this->method = strtoupper($method);
65        $this->uri = $uri ?? Uri::fromString('/');
66        $this->serverParams = $serverParams;
67
68        // A request built with a URI should carry that URI's host as its
69        // Host header unless one was already provided.
70        $host = $this->uri->getHost();
71        if ($host !== '') {
72            $port = $this->uri->getPort();
73            $this->withHeaderInternal('Host', $port !== null ? $host . ':' . $port : $host);
74        }
75    }
76
77    // â”€â”€â”€ Static Factory â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
78
79    /**
80     * Capture the incoming HTTP request from PHP superglobals.
81     *
82     * This is the production entry point â€” reads from $_SERVER, $_GET,
83     * $_POST, $_COOKIE, $_FILES, and php://input. Takes no arguments.
84     *
85     * For tests, use {@see create()} to build a request from explicit
86     * values instead of relying on global state.
87     *
88     * @return self
89     */
90    public static function capture(): self
91    {
92        $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
93        $uri = Uri::fromServer($_SERVER);
94
95        $request = new self($method, $uri, $_SERVER);
96
97        $request->queryParams = $_GET;
98        $request->cookieParams = $_COOKIE;
99        $request->uploadedFiles = self::normalizeUploadedFiles($_FILES);
100        $request->setHeaders(self::extractHeaders($_SERVER));
101
102        // One mutable context bag per request, shared by every copy of the
103        // request (see getContext()/withContext()).
104        $request->attributes['context'] = new RequestContext();
105
106        // Body â€” read php://input once (only meaningful in a real request)
107        $rawBody = file_get_contents('php://input');
108        $request->setBody(Stream::fromString($rawBody !== false ? $rawBody : ''));
109
110        $contentType = $_SERVER['CONTENT_TYPE'] ?? '';
111        if (str_contains($contentType, 'application/json') && $rawBody !== false && $rawBody !== '') {
112            $decoded = json_decode($rawBody, true);
113            if (is_array($decoded)) {
114                $request->parsedBody = $decoded;
115            }
116        } else {
117            $request->parsedBody = $_POST;
118        }
119
120        if (isset($_SERVER['SERVER_PROTOCOL'])) {
121            $version = $_SERVER['SERVER_PROTOCOL'];
122            if (preg_match('#^HTTP/(\d+\.\d+)$#', $version, $matches)) {
123                $request->withProtocolVersionInternal($matches[1]);
124            }
125        }
126
127        return $request;
128    }
129
130    /**
131     * Create a ServerRequest from explicit values.
132     *
133     * Builds a request from a method, URI, and
134     * optional parameters without touching global
135     * state.
136     *
137     * The URI should be the path only (e.g. '/users/42'). If a query string
138     * is included in the URI (e.g. '/search?q=test'), it is parsed and
139     * merged with the $query parameter.
140     *
141     * @param string $method       HTTP method (GET, POST, etc.)
142     * @param string|UriInterface $uri  URI path string or object (defaults to '/')
143     * @param array $query         Query string parameters
144     * @param array $body          Parsed body parameters
145     * @param array $cookies       Cookie parameters
146     * @param array $files         Uploaded files as $_FILES-style array
147     * @param array $headers       Headers as [name => value, ...] or [name => [value, ...]]
148     * @param array $server        Server parameters ($_SERVER-style)
149     * @return self
150     */
151    public static function create(
152        string $method = 'GET',
153        string|UriInterface $uri = '/',
154        array $query = [],
155        array $body = [],
156        array $cookies = [],
157        array $files = [],
158        array $headers = [],
159        array $server = [],
160    ): self {
161        $method = strtoupper($method);
162        $uriObject = $uri instanceof UriInterface ? $uri : Uri::fromString($uri);
163
164        // If the URI has a query string, parse it and merge with $query
165        // (explicit $query params take precedence)
166        $uriQuery = $uriObject->getQuery();
167        if ($uriQuery !== '') {
168            parse_str($uriQuery, $parsedQuery);
169            $query = array_merge($parsedQuery, $query);
170            // Strip the query from the URI so getRequestTarget() uses
171            // queryParams consistently
172            $uriObject = $uriObject->withQuery('');
173        }
174
175        // Build minimal $_SERVER-style array if not provided
176        $server = array_merge([
177            'REQUEST_METHOD' => $method,
178            'REQUEST_URI' => $uriObject->getPath() ?: '/',
179            'SERVER_PROTOCOL' => 'HTTP/1.1',
180            'HTTP_HOST' => $uriObject->getHost() ?: 'localhost',
181        ], $server);
182
183        $request = new self($method, $uriObject, $server);
184
185        $request->queryParams = $query;
186        $request->cookieParams = $cookies;
187        $request->uploadedFiles = self::normalizeUploadedFiles($files);
188
189        // One mutable context bag per request, shared by every copy of the
190        // request (see getContext()/withContext()).
191        $request->attributes['context'] = new RequestContext();
192
193        // Apply explicit headers (overrides anything extracted from $server)
194        $request->setHeaders(self::extractHeaders($server));
195        foreach ($headers as $name => $value) {
196            $request->withHeaderInternal($name, is_array($value) ? $value : [$value]);
197        }
198
199        // Body â€” no php://input in tests, use $body directly
200        // null when no body provided (matches PSR-7 convention for "no body")
201        $request->parsedBody = $body !== [] ? $body : null;
202
203        // Set Content-Type if body is present and no Content-Type was given
204        // (header names are case-insensitive, so use hasHeader())
205        if ($body !== [] && !$request->hasHeader('Content-Type')) {
206            $request->withHeaderInternal('Content-Type', ['application/x-www-form-urlencoded']);
207        }
208
209        return $request;
210    }
211
212    // â”€â”€â”€ RequestInterface â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
213
214    public function getRequestTarget(): string
215    {
216        if ($this->requestTarget !== null) {
217            return $this->requestTarget;
218        }
219
220        $target = $this->uri?->getPath() ?? '/';
221        $query = $this->uri?->getQuery() ?? '';
222        if ($query !== '') {
223            $target .= '?' . $query;
224        }
225
226        return $target ?: '/';
227    }
228
229    /**
230     * @return static
231     */
232    public function withRequestTarget(string $requestTarget): RequestInterface
233    {
234        $new = clone $this;
235        $new->requestTarget = $requestTarget;
236        return $new;
237    }
238
239    public function getMethod(): string
240    {
241        return $this->method;
242    }
243
244    /**
245     * HTTP method names are case-sensitive; The given string is
246     * stored as-provided.
247     *
248     * @throws \InvalidArgumentException for invalid HTTP methods
249     * @return static
250     */
251    public function withMethod(string $method): RequestInterface
252    {
253        if ($method === '' || !preg_match('/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+$/', $method)) {
254            throw new \InvalidArgumentException("Invalid HTTP method: '$method'");
255        }
256
257        $new = clone $this;
258        $new->method = $method;
259        return $new;
260    }
261
262    public function getUri(): UriInterface
263    {
264        return $this->uri;
265    }
266
267    /**
268     * @return static
269     */
270    public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface
271    {
272        $new = clone $this;
273        $new->uri = $uri;
274
275        // With $preserveHost=true the Host header is only kept when it is
276        // present AND non-empty; a missing or empty Host header is updated
277        // from the new URI.
278        if (! $preserveHost || $this->getHeaderLine('Host') === '') {
279            $host = $uri->getHost();
280            if ($host !== '') {
281                $port = $uri->getPort();
282                $hostValue = $port !== null ? $host . ':' . $port : $host;
283                $new = $new->withHeader('Host', $hostValue);
284            }
285        }
286
287        return $new;
288    }
289
290    // â”€â”€â”€ ServerRequestInterface â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
291
292    public function getServerParams(): array
293    {
294        return $this->serverParams;
295    }
296
297    public function getCookieParams(): array
298    {
299        return $this->cookieParams;
300    }
301
302    /**
303     * @return static
304     */
305    public function withCookieParams(array $cookies): ServerRequestInterface
306    {
307        $new = clone $this;
308        $new->cookieParams = $cookies;
309        return $new;
310    }
311
312    public function getQueryParams(): array
313    {
314        return $this->queryParams;
315    }
316
317    /**
318     * @return static
319     */
320    public function withQueryParams(array $query): ServerRequestInterface
321    {
322        $new = clone $this;
323        $new->queryParams = $query;
324        return $new;
325    }
326
327    public function getUploadedFiles(): array
328    {
329        return $this->uploadedFiles;
330    }
331
332    /**
333     * @param array $uploadedFiles An array tree of UploadedFileInterface instances
334     * @throws \InvalidArgumentException if an invalid structure is provided
335     * @return static
336     */
337    public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface
338    {
339        self::assertUploadedFilesTree($uploadedFiles);
340
341        $new = clone $this;
342        $new->uploadedFiles = $uploadedFiles;
343        return $new;
344    }
345
346    /**
347     * Recursively validate an uploaded-files tree (nested arrays of
348     * UploadedFileInterface instances are allowed).
349     *
350     * @throws \InvalidArgumentException
351     */
352    private static function assertUploadedFilesTree(array $tree): void
353    {
354        foreach ($tree as $file) {
355            if (is_array($file)) {
356                self::assertUploadedFilesTree($file);
357                continue;
358            }
359            if (!$file instanceof UploadedFileInterface) {
360                throw new \InvalidArgumentException('Uploaded files must be an array tree of UploadedFileInterface instances');
361            }
362        }
363    }
364
365    public function getParsedBody(): array|object|null
366    {
367        return $this->parsedBody;
368    }
369
370    /**
371     * @return static
372     */
373    public function withParsedBody($data): ServerRequestInterface
374    {
375        if ($data !== null && !is_array($data) && !is_object($data)) {
376            throw new \InvalidArgumentException('Parsed body must be null, an array, or an object');
377        }
378        $new = clone $this;
379        $new->parsedBody = $data;
380        return $new;
381    }
382
383    public function getAttributes(): array
384    {
385        return $this->attributes;
386    }
387
388    /**
389     * Get a single PSR-7 attribute by name.
390     *
391     * @param string $name The attribute name
392     * @param mixed $default Default value if the attribute is not set
393     * @return mixed The attribute value, or $default on a miss
394     */
395    public function getAttribute(string $name, $default = null): mixed
396    {
397        return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default;
398    }
399
400    /**
401     * @return static
402     */
403    public function withAttribute(string $name, $value): ServerRequestInterface
404    {
405        $new = clone $this;
406        $new->attributes[$name] = $value;
407        return $new;
408    }
409
410    /**
411     * @return static
412     */
413    public function withoutAttribute(string $name): ServerRequestInterface
414    {
415        $new = clone $this;
416        unset($new->attributes[$name]);
417        return $new;
418    }
419
420    // â”€â”€â”€ Lucent-Specific Getters (convenience wrappers) â”€â”€â”€
421
422    /**
423     * Get the RouteInfo stored as a PSR-7 attribute.
424     *
425     * @return RouteInfo|null
426     */
427    public function getRouteInfo(): ?RouteInfo
428    {
429        return $this->getAttribute('routeInfo');
430    }
431
432    /**
433     * Get the URL variables stored as a PSR-7 attribute.
434     *
435     * @return array<string, string>
436     */
437    public function getUrlVars(): array
438    {
439        return $this->getAttribute('urlVars', []);
440    }
441
442    /**
443     * Get a single URL variable by name.
444     *
445     * @param string $name The variable name
446     * @param mixed $default Default value if not found
447     * @return string|null
448     */
449    public function getUrlVar(string $name, mixed $default = null): ?string
450    {
451        return $this->getUrlVars()[$name] ?? $default;
452    }
453
454    /**
455     * Get a value from the request context.
456     *
457     * Context lives in a mutable {@see RequestContext} bag attached to the
458     * request, so it can be written by validation rules (via withContext())
459     * or middleware and read back anywhere that holds the same request.
460     *
461     * @param string $key The context key
462     * @param mixed $default Default value if key not found
463     * @return mixed
464     */
465    public function getContext(string $key, mixed $default = null): mixed
466    {
467        $context = RequestContext::fromRequest($this);
468        return $context !== null ? $context->get($key, $default) : $default;
469    }
470
471    /**
472     * Set a value in the request context.
473     *
474     * Mutates the shared {@see RequestContext} bag in place, so the write is
475     * visible to every copy of the request (no clone needed). Returns $this
476     * for chaining.
477     *
478     * @param string $key The context key
479     * @param mixed $value The value to store
480     * @return static
481     */
482    public function withContext(string $key, mixed $value): static
483    {
484        RequestContext::fromRequest($this)?->set($key, $value);
485        return $this;
486    }
487
488    /**
489     * Get a single query parameter by key.
490     *
491     * Convenience wrapper around {@see getQueryParams()} for the common case
492     * of reading one query value. Query values may be strings or arrays
493     * (e.g. `?tags[]=a&tags[]=b`), so the return type is mixed.
494     *
495     * @param string $key The query key to look up
496     * @param array<string, string|array>|string|null $default Default value if the key is missing
497     * @return array<string, string|array>|string|null The query value, or $default on a miss
498     */
499    public function getQueryParam(string $key, array|string|null $default = null): array|string|null
500    {
501        return array_key_exists($key, $this->queryParams) ? $this->queryParams[$key] : $default;
502    }
503
504    /**
505     * Get a single cookie by key.
506     *
507     * Convenience wrapper around {@see getCookieParams()} for the common case
508     * of reading one cookie value.
509     *
510     * @param string $key The cookie key to look up
511     * @param array<string, string|array>|string|null $default Default value if the key is missing
512     * @return array<string, string|array>|string|null The cookie value, or $default on a miss
513     */
514    public function getCookie(string $key, array|string|null $default = null): array|string|null
515    {
516        return array_key_exists($key, $this->cookieParams) ? $this->cookieParams[$key] : $default;
517    }
518
519    /**
520     * Get a single server parameter by key.
521     *
522     * Convenience wrapper around {@see getServerParams()} for the common case
523     * of reading one $_SERVER value (e.g. REQUEST_METHOD, REMOTE_ADDR).
524     *
525     * @param string $key The server key to look up
526     * @param mixed $default Default value if the key is missing
527     * @return mixed The server value, or $default on a miss
528     */
529    public function getServerParam(string $key, mixed $default = null): mixed
530    {
531        return array_key_exists($key, $this->serverParams) ? $this->serverParams[$key] : $default;
532    }
533
534    /**
535     * Get a single value from the parsed body by key.
536     *
537     * Convenience wrapper around {@see getParsedBody()} for the common case
538     * of reading one field (e.g. a form or JSON payload value) without
539     * null-checking the whole body first.
540     *
541     * @param string $key The body key to look up
542     * @param mixed $default Default value if the key is missing or the body is null
543     * @return mixed The body value, or $default on a miss
544     */
545    public function getParsedBodyValue(string $key, mixed $default = null): mixed
546    {
547        if ($this->parsedBody === null) {
548            return $default;
549        }
550
551        if (is_array($this->parsedBody)) {
552            return array_key_exists($key, $this->parsedBody) ? $this->parsedBody[$key] : $default;
553        }
554
555        return property_exists($this->parsedBody, $key) ? $this->parsedBody->{$key} : $default;
556    }
557
558    /**
559     * Get a single uploaded file by key.
560     *
561     * Convenience wrapper around {@see getUploadedFiles()} for the common
562     * case of reading one file field. Only returns a top-level file; for
563     * nested (array-of-inputs) uploads use {@see getUploadedFiles()}.
564     *
565     * @param string $key The file field key to look up
566     * @return UploadedFile|null The uploaded file, or null if the key is
567     *                           missing or no files were uploaded
568     */
569    public function getUploadedFile(string $key): ?UploadedFile
570    {
571        if ($this->uploadedFiles === null) {
572            return null;
573        }
574        return array_key_exists($key, $this->uploadedFiles) ? $this->uploadedFiles[$key] : null;
575    }
576
577    /**
578     * Validate this request's parsed body against a set of constraints.
579     *
580     * Convenience wrapper around {@see \Lucent\Validation\Validator} that
581     * passes the parsed body and uploaded files through unchanged, so object
582     * bodies (e.g. decoded JSON) and a null body are preserved. The request
583     * itself is seeded into the validation context under the `request` key,
584     * alongside any user-provided context values, so custom constraints can
585     * read them via
586     * {@see \Lucent\Validation\FieldContext::context('request', ServerRequestInterface::class)}.
587     *
588     * @param \Lucent\Validation\Constraint|array<string, \Lucent\Validation\Constraint> $constraints
589     *        A single top-level constraint, or a map of constraints keyed by field name.
590     * @param array<string, mixed> $context Optional per-validation values (e.g.
591     *        the authenticated user) exposed to constraints via
592     *        {@see \Lucent\Validation\FieldContext::get()}. The request is always
593     *        seeded under the `request` key; user values take precedence on a clash.
594     * @return Result The validation result containing errors and validated values.
595     */
596    public function validate(Constraint|array $constraints, array $context = []): Result
597    {
598        return (new Validator($constraints))->validate(
599            $this->parsedBody,
600            $this->uploadedFiles,
601            ['request' => $this, ...$context],
602        );
603    }
604
605    // â”€â”€â”€ Internal Helpers â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
606
607    /**
608     * Set protocol version internally (no clone).
609     *
610     * @param string $version The HTTP protocol version (e.g., "1.1")
611     * @return void
612     */
613    private function withProtocolVersionInternal(string $version): void
614    {
615        $this->protocolVersion = $version;
616    }
617
618    /**
619     * Extract headers from $_SERVER.
620     *
621     * @param array $server The $_SERVER array
622     * @return array<string, string[]> Headers as [name => [value, ...]]
623     */
624    private static function extractHeaders(array $server): array
625    {
626        $headers = [];
627
628        foreach ($server as $key => $value) {
629            if (str_starts_with($key, 'HTTP_')) {
630                $name = str_replace('_', '-', substr($key, 5));
631                $name = ucwords(strtolower($name), '-');
632                if (is_string($value)) {
633                    $headers[$name] = [$value];
634                }
635            }
636        }
637
638        // Content-Type is not prefixed with HTTP_
639        if (isset($server['CONTENT_TYPE'])) {
640            $headers['Content-Type'] = [is_string($server['CONTENT_TYPE']) ? $server['CONTENT_TYPE'] : ''];
641        }
642
643        // Content-Length is not prefixed with HTTP_
644        if (isset($server['CONTENT_LENGTH'])) {
645            $headers['Content-Length'] = [(string) $server['CONTENT_LENGTH']];
646        }
647
648        return $headers;
649    }
650
651    /**
652     * Normalize $_FILES into an array of UploadedFileInterface instances.
653     *
654     * Handles both simple and nested (array of inputs) $_FILES formats.
655     *
656     * @param array $files The $_FILES array to normalize
657     * @return array<string, UploadedFileInterface|UploadedFileInterface[]> Normalized file tree
658     */
659    private static function normalizeUploadedFiles(array $files): array
660    {
661        $normalized = [];
662
663        foreach ($files as $key => $value) {
664            if (is_array($value) && isset($value['tmp_name'])) {
665                // Single file upload
666                $normalized[$key] = self::createUploadedFile($value);
667            } elseif (is_array($value)) {
668                // Nested array of files (e.g., name[] inputs)
669                $normalized[$key] = self::normalizeUploadedFiles($value);
670            }
671            // Malformed entries (non-array values, or arrays without a
672            // 'tmp_name' key that are not nested trees) are skipped â€” they
673            // cannot be mapped to an UploadedFileInterface.
674        }
675
676        return $normalized;
677    }
678
679    /**
680     * Create an UploadedFile instance from a $_FILES entry.
681     *
682     * @param array $file A single $_FILES entry with keys: tmp_name, size, error, name, type
683     * @return UploadedFileInterface|UploadedFileInterface[]
684     */
685    private static function createUploadedFile(array $file): UploadedFileInterface|array
686    {
687        $tmpName = $file['tmp_name'] ?? '';
688        $size = isset($file['size']) ? (int) $file['size'] : null;
689        $error = $file['error'] ?? UPLOAD_ERR_NO_FILE;
690        $clientName = $file['name'] ?? null;
691        $clientType = $file['type'] ?? null;
692
693        if (is_array($tmpName)) {
694            // Multi-file input (name[])
695            $files = [];
696            foreach ($tmpName as $i => $tmp) {
697                $files[] = self::createUploadedFile([
698                    'tmp_name' => $tmp,
699                    'size' => is_array($size) ? ($size[$i] ?? null) : null,
700                    'error' => is_array($error) ? ($error[$i] ?? UPLOAD_ERR_NO_FILE) : UPLOAD_ERR_NO_FILE,
701                    'name' => is_array($clientName) ? ($clientName[$i] ?? null) : null,
702                    'type' => is_array($clientType) ? ($clientType[$i] ?? null) : null,
703                ]);
704            }
705            return $files;
706        }
707
708        return new UploadedFile($tmpName, $size, $error, $clientName, $clientType);
709    }
710}

Inherited from Lucent\Http\Message\AbstractMessage

35    public function getProtocolVersion(): string
36    {
37        return $this->protocolVersion;
38    }
43    public function withProtocolVersion(string $version): MessageInterface
44    {
45        $new = clone $this;
46        $new->protocolVersion = $version;
47        return $new;
48    }
52    public function getHeaders(): array
53    {
54        return $this->headers;
55    }
57    public function hasHeader(string $name): bool
58    {
59        return isset($this->headerNames[strtolower($name)]);
60    }
62    public function getHeader(string $name): array
63    {
64        $lower = strtolower($name);
65        if (!isset($this->headerNames[$lower])) {
66            return [];
67        }
68        $originalName = $this->headerNames[$lower];
69        return $this->headers[$originalName];
70    }
72    public function getHeaderLine(string $name): string
73    {
74        $values = $this->getHeader($name);
75        if (empty($values)) {
76            return '';
77        }
78        return implode(', ', $values);
79    }
84    public function withHeader(string $name, $value): MessageInterface
85    {
86        $this->assertHeaderName($name);
87        $this->assertHeaderValue($value);
88
89        $new = clone $this;
90        $normalized = $this->normalizeHeaderName($name);
91        $lower = strtolower($name);
92
93        // Remove old header if present
94        if (isset($new->headerNames[$lower])) {
95            unset($new->headers[$new->headerNames[$lower]]);
96        }
97
98        $new->headerNames[$lower] = $normalized;
99        $new->headers[$normalized] = is_array($value) ? array_map([$this, 'sanitizeHeaderValue'], $value) : [$this->sanitizeHeaderValue($value)];
100        return $new;
101    }
106    public function withAddedHeader(string $name, $value): MessageInterface
107    {
108        $this->assertHeaderName($name);
109        $this->assertHeaderValue($value);
110
111        $new = clone $this;
112        $normalized = $this->normalizeHeaderName($name);
113        $lower = strtolower($name);
114
115        $sanitized = is_array($value) ? array_map([$this, 'sanitizeHeaderValue'], $value) : [$this->sanitizeHeaderValue($value)];
116
117        if (isset($new->headerNames[$lower])) {
118            $existingName = $new->headerNames[$lower];
119            $new->headers[$existingName] = array_merge($new->headers[$existingName], $sanitized);
120        } else {
121            $new->headerNames[$lower] = $normalized;
122            $new->headers[$normalized] = $sanitized;
123        }
124
125        return $new;
126    }
131    public function withoutHeader(string $name): MessageInterface
132    {
133        $lower = strtolower($name);
134        if (!isset($this->headerNames[$lower])) {
135            return $this;
136        }
137
138        $new = clone $this;
139        $originalName = $new->headerNames[$lower];
140        unset($new->headers[$originalName]);
141        unset($new->headerNames[$lower]);
142        return $new;
143    }
147    public function getBody(): StreamInterface
148    {
149        if ($this->body === null) {
150            $this->body = Stream::fromString('');
151        }
152        return $this->body;
153    }
162    public function withBody(StreamInterface $body): MessageInterface
163    {
164        $new = clone $this;
165        $new->body = $body;
166        return $new;
167    }
174    protected function setBody(StreamInterface $body): void
175    {
176        $this->body = $body;
177    }
182    protected function setHeaders(array $headers): void
183    {
184        foreach ($headers as $name => $value) {
185            $this->withHeaderInternal($name, $value);
186        }
187    }
192    protected function withHeaderInternal(string $name, $value): void
193    {
194        $normalized = $this->normalizeHeaderName($name);
195        $lower = strtolower($name);
196
197        if (isset($this->headerNames[$lower])) {
198            unset($this->headers[$this->headerNames[$lower]]);
199        }
200
201        $this->headerNames[$lower] = $normalized;
202        $this->headers[$normalized] = is_array($value) ? $value : [$value];
203    }
214    private function normalizeHeaderName(string $name): string
215    {
216        return str_replace(' ', '-', ucwords(str_replace('-', ' ', $name)));
217    }
225    private function assertHeaderName(string $name): void
226    {
227        if ($name === '' || $name === null) {
228            throw new \InvalidArgumentException('Header name must not be empty');
229        }
230
231        if (preg_match('/[\r\n]/', $name)) {
232            throw new \InvalidArgumentException('Header name must not contain CR or LF characters');
233        }
234
235        if (!preg_match('/^[a-zA-Z0-9!#$%&\'*+\-.\^_`|~]+$/', $name)) {
236            throw new \InvalidArgumentException("Invalid header name: '$name'");
237        }
238    }
246    private function assertHeaderValue(string|array $value): void
247    {
248        $values = is_array($value) ? $value : [$value];
249        foreach ($values as $v) {
250            if (!is_string($v)) {
251                throw new \InvalidArgumentException('Header value must be a string or array of strings');
252            }
253            if (preg_match('/[\r\n]/', $v)) {
254                throw new \InvalidArgumentException('Header value must not contain CR or LF characters');
255            }
256        }
257    }
265    private function sanitizeHeaderValue(string $value): string
266    {
267        return str_replace(["\r", "\n"], '', $value);
268    }