Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.52% covered (success)
96.52%
111 / 115
93.75% covered (success)
93.75%
30 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractRequestResponse
96.52% covered (success)
96.52%
111 / 115
93.75% covered (success)
93.75%
30 / 32
76
0.00% covered (danger)
0.00%
0 / 1
 setHeaders
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addHeader
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 addHeaders
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getHeaderObject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeaderAsString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getHeaderValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getHeaderValueAsString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getHeaderObjects
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveHeaderName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getHeader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getHeaders
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getHeaderLine
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeadersAsArray
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getHeadersAsString
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 hasHeaders
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
1
 removeHeader
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 removeHeaders
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 withHeader
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 withAddedHeader
70.00% covered (success)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 withoutHeader
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setBody
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBodyContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getBodyContentLength
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 hasBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasBodyContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 decodeBodyContent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 removeBody
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 withBody
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __get
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 __clone
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http;
16
17use Pop\Mime\Part\Header;
18use Pop\Http\Body;
19use Psr\Http\Message\MessageInterface;
20
21/**
22 * Abstract HTTP request/response class
23 *
24 * Declares `implements MessageInterface` here (in addition to the concrete
25 * `implements RequestInterface`/`ResponseInterface` added to AbstractRequest/
26 * AbstractResponse) because PHP's covariant-return check for `static` requires
27 * the class where a method is physically defined to already be provably
28 * compatible with the interface's return type; without this, PHP fatals on
29 * `withHeader()`/`withAddedHeader()`/`withoutHeader()`/`withBody()` as soon as
30 * a subclass implements MessageInterface, even though those methods'
31 * behavior already satisfies it. `getProtocolVersion()`/`withProtocolVersion()`
32 * remain unimplemented here (legal since this class stays abstract) — added by
33 * AbstractRequest/AbstractResponse per PSR-7 message methods task.
34 *
35 * @category   Pop
36 * @package    Pop\Http
37 * @author     Nick Sagona, III <nick@popphp.org>
38 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
39 * @license    https://www.popphp.org/license     New BSD License
40 * @version    6.0.0
41 */
42abstract class AbstractRequestResponse implements RequestResponseInterface, MessageInterface
43{
44
45    /**
46     * Headers
47     * @var array
48     */
49    protected array $headers = [];
50
51    /**
52     * Case-folded header name index (lowercase name => actual stored key), kept in sync by
53     * addHeader()/removeHeader()/setHeaders()/removeHeaders() so resolveHeaderName() - used by
54     * every PSR-7 case-insensitive lookup (getHeader()/withHeader()/withAddedHeader()/
55     * withoutHeader()) - is an O(1) lookup instead of a linear strcasecmp() scan of every header.
56     * @var array
57     */
58    protected array $headerIndex = [];
59
60    /**
61     * Body
62     * @var ?Body
63     */
64    protected ?Body $body = null;
65
66    /**
67     * Set all headers (clear out any existing headers)
68     *
69     * @param  array $headers
70     * @return AbstractRequestResponse
71     */
72    public function setHeaders(array $headers): AbstractRequestResponse
73    {
74        $this->headers     = [];
75        $this->headerIndex = [];
76        $this->addHeaders($headers);
77
78        return $this;
79    }
80
81    /**
82     * Add a header
83     *
84     * @param  Header|string|int $header
85     * @param  ?string           $value
86     * @return AbstractRequestResponse
87     */
88    public function addHeader(Header|string|int $header, ?string $value = null): AbstractRequestResponse
89    {
90        if ($header instanceof Header) {
91            $this->headers[$header->getName()] = $header;
92            $name = $header->getName();
93        } else {
94            if (is_numeric($header) && str_contains($value, ':')) {
95                $header = Header::parse($value);
96                $this->headers[$header->getName()] = $header;
97                $name = $header->getName();
98            } else {
99                $this->headers[$header] = new Header($header, $value);
100                $name = $header;
101            }
102        }
103
104        $this->headerIndex[strtolower((string)$name)] = (string)$name;
105
106        return $this;
107    }
108
109    /**
110     * Add all headers
111     *
112     * @param  array $headers
113     * @return AbstractRequestResponse
114     */
115    public function addHeaders(array $headers): AbstractRequestResponse
116    {
117        foreach ($headers as $header => $value) {
118            if ($value instanceof Header) {
119                $this->addHeader($value);
120            } else {
121                $this->addHeader($header, $value);
122            }
123        }
124        return $this;
125    }
126
127    /**
128     * Get a header
129     *
130     * @param  string $name
131     * @return mixed
132     */
133    public function getHeaderObject(string $name): mixed
134    {
135        return $this->headers[$name] ?? null;
136    }
137
138    /**
139     * Get a header
140     *
141     * @param  string $name
142     * @return mixed
143     */
144    public function getHeaderAsString(string $name): mixed
145    {
146        return (isset($this->headers[$name])) ? (string)$this->headers[$name] : null;
147    }
148
149    /**
150     * Get header value
151     *
152     * @param  string $name
153     * @param  int    $i
154     * @return mixed
155     */
156    public function getHeaderValue(string $name, int $i = 0): mixed
157    {
158        return (isset($this->headers[$name])) ? $this->headers[$name]->getValue($i) : null;
159    }
160
161    /**
162     * Get header value as string
163     *
164     * @param  string $name
165     * @param  int    $i
166     * @return string|null
167     */
168    public function getHeaderValueAsString(string $name, int $i = 0): string|null
169    {
170        return (isset($this->headers[$name])) ? $this->headers[$name]->getValueAsString($i) : null;
171    }
172
173    /**
174     * Get all headers
175     *
176     * @return array
177     */
178    public function getHeaderObjects(): array
179    {
180        return $this->headers;
181    }
182
183    /**
184     * Resolve a header name to whatever key is actually stored, matching
185     * case-insensitively per PSR-7. Returns the input unchanged if no
186     * existing header matches. Only PSR-7-facing methods route through
187     * this - the non-PSR addHeader()/removeHeader()/hasHeader()/
188     * getHeaderObject()/getHeaderObjects() methods keep their existing
189     * exact-match storage keying.
190     *
191     * @param  string $name
192     * @return string
193     */
194    private function resolveHeaderName(string $name): string
195    {
196        if (isset($this->headers[$name])) {
197            return $name;
198        }
199
200        return $this->headerIndex[strtolower($name)] ?? $name;
201    }
202
203    /**
204     * Get all string values for a header, per PSR-7 (empty array if absent, case-insensitive)
205     *
206     * @param  string $name
207     * @return array
208     */
209    public function getHeader(string $name): array
210    {
211        $name = $this->resolveHeaderName($name);
212
213        if (!isset($this->headers[$name])) {
214            return [];
215        }
216
217        $values = $this->headers[$name]->getValuesAsStrings();
218
219        return is_array($values) ? $values : [$values];
220    }
221
222    /**
223     * Get all headers, per PSR-7 (each header's values array-wrapped)
224     *
225     * @return array
226     */
227    public function getHeaders(): array
228    {
229        $headers = [];
230
231        foreach ($this->headers as $name => $header) {
232            $headers[$name] = $this->getHeader($name);
233        }
234
235        return $headers;
236    }
237
238    /**
239     * Get a header's values comma-joined into one string, per PSR-7
240     *
241     * @param  string $name
242     * @return string
243     */
244    public function getHeaderLine(string $name): string
245    {
246        return implode(', ', $this->getHeader($name));
247    }
248
249    /**
250     * Get all header values as associative array
251     *
252     * @param  bool $asStrings
253     * @return array
254     */
255    public function getHeadersAsArray(bool $asStrings = true): array
256    {
257        $headers = [];
258
259        foreach ($this->headers as $name => $header) {
260            if (count($header->getValues()) == 1) {
261                $headers[$name] = ($asStrings) ? $header->getValueAsString(0) : $header->getValue(0);
262            } else {
263                $headers[$name] = ($asStrings) ? $header->getValuesAsStrings() : $header->getValues();
264            }
265        }
266        return $headers;
267    }
268
269    /**
270     * Get all header values formatted string
271     *
272     * @param  mixed  $status
273     * @param  string $eol
274     * @return string
275     */
276    public function getHeadersAsString(mixed $status = null, string $eol = "\r\n"): string
277    {
278        $headers = '';
279
280        if (is_string($status)) {
281            $headers = $status . $eol;
282        }
283
284        foreach ($this->headers as $header) {
285            $headers .= $header . $eol;
286        }
287
288        return $headers;
289    }
290
291    /**
292     * Determine if there are headers
293     *
294     * @return bool
295     */
296    public function hasHeaders(): bool
297    {
298        return (count($this->headers) > 0);
299    }
300
301    /**
302     * Has a header
303     *
304     * @param  string $name
305     * @return bool
306     */
307    public function hasHeader(string $name): bool
308    {
309        return (isset($this->headers[$name]));
310    }
311
312    /**
313     * Remove a header
314     *
315     * @param  string $name
316     * @return AbstractRequestResponse
317     */
318    public function removeHeader(string $name): AbstractRequestResponse
319    {
320        if (isset($this->headers[$name])) {
321            unset($this->headers[$name]);
322        }
323
324        $lower = strtolower($name);
325        if (isset($this->headerIndex[$lower]) && ($this->headerIndex[$lower] === $name)) {
326            unset($this->headerIndex[$lower]);
327        }
328
329        return $this;
330    }
331
332    /**
333     * Remove all headers
334     *
335     * @return AbstractRequestResponse
336     */
337    public function removeHeaders(): AbstractRequestResponse
338    {
339        $this->headers     = [];
340        $this->headerIndex = [];
341        return $this;
342    }
343
344    /**
345     * Return an instance with the specified header, replacing any existing values, per PSR-7
346     *
347     * Parameter is typed `mixed` (rather than `string|array`) only to satisfy PSR-7's
348     * untyped `MessageInterface::withHeader()` signature per PHP's LSP variance rules;
349     * the supported values remain a string or an array of strings.
350     *
351     * @param  string $name
352     * @param  mixed  $value
353     * @return static
354     */
355    public function withHeader(string $name, mixed $value): static
356    {
357        $name  = $this->resolveHeaderName($name);
358        $clone = clone $this;
359        $clone->removeHeader($name);
360
361        $values = is_array($value) ? $value : [$value];
362        $clone->addHeader($name, array_shift($values));
363        foreach ($values as $v) {
364            $clone->headers[$name]->addValue($v);
365        }
366
367        return $clone;
368    }
369
370    /**
371     * Return an instance with the specified header value(s) appended, per PSR-7
372     *
373     * Parameter is typed `mixed` (rather than `string|array`) only to satisfy PSR-7's
374     * untyped `MessageInterface::withAddedHeader()` signature per PHP's LSP variance
375     * rules; the supported values remain a string or an array of strings.
376     *
377     * @param  string $name
378     * @param  mixed  $value
379     * @return static
380     */
381    public function withAddedHeader(string $name, mixed $value): static
382    {
383        $name   = $this->resolveHeaderName($name);
384        $clone  = clone $this;
385        $values = is_array($value) ? $value : [$value];
386
387        if ($clone->hasHeader($name)) {
388            foreach ($values as $v) {
389                $clone->headers[$name]->addValue($v);
390            }
391        } else {
392            $clone->addHeader($name, array_shift($values));
393            foreach ($values as $v) {
394                $clone->headers[$name]->addValue($v);
395            }
396        }
397
398        return $clone;
399    }
400
401    /**
402     * Return an instance without the specified header, per PSR-7
403     *
404     * @param  string $name
405     * @return static
406     */
407    public function withoutHeader(string $name): static
408    {
409        $name  = $this->resolveHeaderName($name);
410        $clone = clone $this;
411        $clone->removeHeader($name);
412        return $clone;
413    }
414
415    /**
416     * Set the body
417     *
418     * @param  string|Body $body
419     * @return AbstractRequestResponse
420     */
421    public function setBody(string|Body $body): AbstractRequestResponse
422    {
423        $this->body = ($body instanceof Body) ? $body : new Body($body);
424        return $this;
425    }
426
427    /**
428     * Get the body, per PSR-7 (a transient, never-stored empty Body when none is set,
429     * so hasBody() remains unaffected by calling this)
430     *
431     * @return Body
432     */
433    public function getBody(): Body
434    {
435        return $this->body ?? new Body();
436    }
437
438    /**
439     * Get body content
440     *
441     * @return mixed
442     */
443    public function getBodyContent(): mixed
444    {
445        return ($this->body !== null) ? $this->body->getContent() : null;
446    }
447
448    /**
449     * Get body content length
450     *
451     * @param  bool $mb
452     * @return int
453     */
454    public function getBodyContentLength(bool $mb = false): int
455    {
456        if ($this->body === null) {
457            return 0;
458        }
459        return ($mb) ? mb_strlen($this->body->getContent()) : ($this->body->getSize() ?? 0);
460    }
461
462    /**
463     * Has a body
464     *
465     * @return bool
466     */
467    public function hasBody(): bool
468    {
469        return ($this->body !== null);
470    }
471
472    /**
473     * Has body content
474     *
475     * @return bool
476     */
477    public function hasBodyContent(): bool
478    {
479        return (($this->body !== null) && $this->body->hasContent());
480    }
481
482    /**
483     * Decode the body
484     *
485     * @param  ?string $body
486     * @return Body
487     */
488    public function decodeBodyContent(?string $body = null): Body
489    {
490        if ($body !== null) {
491            $this->setBody($body);
492        }
493        if (($this->hasHeader('Transfer-Encoding')) && (count($this->getHeaderObject('Transfer-Encoding')->getValues()) == 1) &&
494            (strtolower((string)$this->getHeaderObject('Transfer-Encoding')->getValueAsString(0)) == 'chunked')) {
495            $this->body->setContent(Parser::decodeChunkedData($this->body->getContent()));
496        }
497        $contentEncoding = ($this->hasHeader('Content-Encoding') && (count($this->getHeaderObject('Content-Encoding')->getValues()) == 1)) ?
498            $this->getHeaderObject('Content-Encoding')->getValueAsString(0) : null;
499        $this->body->setContent(Parser::decodeData($this->body->getContent(), $contentEncoding));
500
501        return $this->body;
502    }
503
504    /**
505     * Remove the body
506     *
507     * @return AbstractRequestResponse
508     */
509    public function removeBody(): AbstractRequestResponse
510    {
511        $this->body = null;
512        return $this;
513    }
514
515    /**
516     * Return an instance with the specified body, per PSR-7
517     *
518     * @param  Body|\Psr\Http\Message\StreamInterface $body
519     * @return static
520     */
521    public function withBody(Body|\Psr\Http\Message\StreamInterface $body): static
522    {
523        $clone       = clone $this;
524        $clone->body = ($body instanceof Body) ? $body : new Body((string)$body);
525        return $clone;
526    }
527
528    /**
529     * Magic method to get either the headers or body
530     *
531     * @param  string $name
532     * @return mixed
533     */
534    public function __get(string $name): mixed
535    {
536        return match ($name) {
537            'headers' => $this->headers,
538            'body'    => $this->body,
539            default   => null,
540        };
541    }
542
543    /**
544     * Deep-clone headers and body so a with*() clone never shares mutable state
545     * with the instance it was cloned from
546     *
547     * @return void
548     */
549    public function __clone(): void
550    {
551        foreach ($this->headers as $name => $header) {
552            $this->headers[$name] = clone $header;
553        }
554
555        if ($this->body !== null) {
556            $this->body = clone $this->body;
557        }
558    }
559
560}