Lines 92.00% 69 / 75
Methods 84.21% 16 / 19
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 getProtocolVersion 100.00% 1 / 1 100.00% 1 / 1 1
 withProtocolVersion 100.00% 3 / 3 100.00% 1 / 1 1
 getHeaders 100.00% 1 / 1 100.00% 1 / 1 1
 hasHeader 100.00% 1 / 1 100.00% 1 / 1 1
 getHeader 100.00% 5 / 5 100.00% 1 / 1 2
 getHeaderLine 100.00% 4 / 4 100.00% 1 / 1 2
 withHeader 100.00% 10 / 10 100.00% 1 / 1 3
 withAddedHeader 100.00% 12 / 12 100.00% 1 / 1 3
 withoutHeader 87.50% 7 / 8 0.00% 0 / 1 2.01
 getBody 100.00% 3 / 3 100.00% 1 / 1 2
 withBody 100.00% 3 / 3 100.00% 1 / 1 1
 setBody 100.00% 1 / 1 100.00% 1 / 1 1
 setHeaders 100.00% 2 / 2 100.00% 1 / 1 2
 withHeaderInternal 100.00% 6 / 6 100.00% 1 / 1 3
 normalizeHeaderName 100.00% 1 / 1 100.00% 1 / 1 1
 assertHeaderName 50.00% 3 / 6 0.00% 0 / 1 8.12
 assertHeaderValue 66.66% 4 / 6 0.00% 0 / 1 5.93
 sanitizeHeaderValue 100.00% 1 / 1 100.00% 1 / 1 1
15abstract class AbstractMessage implements MessageInterface
16{
17    /** @var string HTTP protocol version */
18    protected string $protocolVersion = '1.1';
19
20    /** @var array<string, string[]> Headers as [name => [value, ...]] */
21    protected array $headers = [];
22
23    /** @var array<string, string> Lowercased header name map */
24    protected array $headerNames = [];
25
26    /** @var StreamInterface|null */
27    private ?StreamInterface $body = null;
28
29    protected function __construct()
30    {
31    }
32
33    // ─── Protocol Version ───────────────────────────────────────────────
34
35    public function getProtocolVersion(): string
36    {
37        return $this->protocolVersion;
38    }
39
40    /**
41     * @return static
42     */
43    public function withProtocolVersion(string $version): MessageInterface
44    {
45        $new = clone $this;
46        $new->protocolVersion = $version;
47        return $new;
48    }
49
50    // ─── Headers ────────────────────────────────────────────────────────
51
52    public function getHeaders(): array
53    {
54        return $this->headers;
55    }
56
57    public function hasHeader(string $name): bool
58    {
59        return isset($this->headerNames[strtolower($name)]);
60    }
61
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    }
71
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    }
80
81    /**
82     * @return static
83     */
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    }
102
103    /**
104     * @return static
105     */
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    }
127
128    /**
129     * @return static
130     */
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    }
144
145    // ─── Body ───────────────────────────────────────────────────────────
146
147    public function getBody(): StreamInterface
148    {
149        if ($this->body === null) {
150            $this->body = Stream::fromString('');
151        }
152        return $this->body;
153    }
154
155    /**
156     * @param StreamInterface $body Body
157     * @throws \InvalidArgumentException When the body is not valid (enforced
158     *     by the StreamInterface type hint — non-stream bodies are rejected
159     *     by the type system before this method executes)
160     * @return static
161     */
162    public function withBody(StreamInterface $body): MessageInterface
163    {
164        $new = clone $this;
165        $new->body = $body;
166        return $new;
167    }
168
169    // ─── Internal Helpers ───────────────────────────────────────────────
170
171    /**
172     * Set the body during construction.
173     */
174    protected function setBody(StreamInterface $body): void
175    {
176        $this->body = $body;
177    }
178
179    /**
180     * Set headers during construction.
181     */
182    protected function setHeaders(array $headers): void
183    {
184        foreach ($headers as $name => $value) {
185            $this->withHeaderInternal($name, $value);
186        }
187    }
188
189    /**
190     * Add a single header during construction (mutates, no clone).
191     */
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    }
204
205    /**
206     * Normalize a header name to Title-Case-With-Dashes.
207     *
208     * Normalizing gives consistent getHeaders() keys regardless of the
209     * casing callers used; lookup stays case-insensitive via $headerNames.
210     *
211     * @param string $name The raw header name
212     * @return string The normalized header name
213     */
214    private function normalizeHeaderName(string $name): string
215    {
216        return str_replace(' ', '-', ucwords(str_replace('-', ' ', $name)));
217    }
218
219    /**
220     * Assert that a header name is valid.
221     *
222     * @param string $name The header name to validate
223     * @throws \InvalidArgumentException
224     */
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    }
239
240    /**
241     * Assert that a header value is valid.
242     *
243     * @param string|string[] $value The header value(s) to validate
244     * @throws \InvalidArgumentException
245     */
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    }
258
259    /**
260     * Strip CR/LF from a header value.
261     *
262     * @param string $value The header value to sanitize
263     * @return string The sanitized header value
264     */
265    private function sanitizeHeaderValue(string $value): string
266    {
267        return str_replace(["\r", "\n"], '', $value);
268    }
269}