Lines 93.12% 122 / 131
Methods 82.14% 23 / 28
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 json 84.61% 11 / 13 0.00% 0 / 1 5.09
 getStatusCode 100.00% 1 / 1 100.00% 1 / 1 1
 withStatus 100.00% 8 / 8 100.00% 1 / 1 4
 getReasonPhrase 100.00% 1 / 1 100.00% 1 / 1 1
 withJsonBody 80.00% 4 / 5 0.00% 0 / 1 2.03
 withJsonEnvelope 100.00% 9 / 9 100.00% 1 / 1 2
 withRedirect 100.00% 6 / 6 100.00% 1 / 1 2
 withStream 100.00% 7 / 7 100.00% 1 / 1 3
 withEventStream 100.00% 6 / 6 100.00% 1 / 1 1
 [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
21class Response extends AbstractMessage implements ResponseInterface
22{
23    /** @var int HTTP status code */
24    private int $statusCode = 200;
25
26    /** @var string Reason phrase */
27    private string $reasonPhrase = 'OK';
28
29    /** @var array<string, string> Map of status codes to default reason phrases */
30    private const PHRASES = [
31        100 => 'Continue',
32        101 => 'Switching Protocols',
33        102 => 'Processing',
34        200 => 'OK',
35        201 => 'Created',
36        202 => 'Accepted',
37        203 => 'Non-Authoritative Information',
38        204 => 'No Content',
39        205 => 'Reset Content',
40        206 => 'Partial Content',
41        300 => 'Multiple Choices',
42        301 => 'Moved Permanently',
43        302 => 'Found',
44        303 => 'See Other',
45        304 => 'Not Modified',
46        305 => 'Use Proxy',
47        307 => 'Temporary Redirect',
48        308 => 'Permanent Redirect',
49        400 => 'Bad Request',
50        401 => 'Unauthorized',
51        402 => 'Payment Required',
52        403 => 'Forbidden',
53        404 => 'Not Found',
54        405 => 'Method Not Allowed',
55        406 => 'Not Acceptable',
56        407 => 'Proxy Authentication Required',
57        408 => 'Request Timeout',
58        409 => 'Conflict',
59        410 => 'Gone',
60        411 => 'Length Required',
61        412 => 'Precondition Failed',
62        413 => 'Payload Too Large',
63        414 => 'URI Too Long',
64        415 => 'Unsupported Media Type',
65        416 => 'Range Not Satisfiable',
66        417 => 'Expectation Failed',
67        426 => 'Upgrade Required',
68        429 => 'Too Many Requests',
69        451 => 'Unavailable For Legal Reasons',
70        500 => 'Internal Server Error',
71        501 => 'Not Implemented',
72        502 => 'Bad Gateway',
73        503 => 'Service Unavailable',
74        504 => 'Gateway Timeout',
75        505 => 'HTTP Version Not Supported',
76    ];
77
78    public function __construct()
79    {
80        parent::__construct();
81    }
82
83    // â”€â”€â”€ Static Factories â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
84
85    /**
86     * Create a PSR-7 Response from JSON data.
87     *
88     * @param mixed $data Data to JSON-encode as the body
89     * @param int $status HTTP status code
90     * @param array $headers Additional headers
91     * @return self
92     * @throws \InvalidArgumentException when the $status is invalid or json encoding fails
93     */
94    public static function json(mixed $data, int $status = 200, array $headers = []): static
95    {
96        // Validate the status code the same way withStatus() does.
97        if ($status < 100 || $status > 599) {
98            throw new \InvalidArgumentException("Invalid status code: $status");
99        }
100
101        $response = new static();
102        $response->statusCode = $status;
103        $response->reasonPhrase = self::PHRASES[$status] ?? '';
104
105        $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
106        if ($payload === false) {
107            throw new \InvalidArgumentException("Unable to JSON-encode response data: " . json_last_error_msg());
108        }
109        $response->setBody(Stream::fromString($payload));
110
111        $response->withHeaderInternal('Content-Type', 'application/json; charset=utf-8');
112        foreach ($headers as $name => $value) {
113            $response->withHeaderInternal($name, $value);
114        }
115
116        return $response;
117    }
118
119    // â”€â”€â”€ ResponseInterface â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
120
121    public function getStatusCode(): int
122    {
123        return $this->statusCode;
124    }
125
126    public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface
127    {
128        if ($code < 100 || $code > 599) {
129            throw new \InvalidArgumentException("Invalid status code: $code");
130        }
131
132        $new = clone $this;
133        $new->statusCode = $code;
134        $new->reasonPhrase = $reasonPhrase !== ''
135            ? $reasonPhrase
136            : (self::PHRASES[$code] ?? '');
137        return $new;
138    }
139
140    public function getReasonPhrase(): string
141    {
142        return $this->reasonPhrase;
143    }
144
145    // â”€â”€â”€ Lucent Convenience Methods â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
146
147    /**
148     * Return a new response with a JSON-encoded body.
149     *
150     * @param mixed $data Data to JSON-encode
151     */
152    public function withJsonBody(mixed $data): static
153    {
154        $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
155        if ($payload === false) {
156            throw new \RuntimeException('Unable to JSON-encode response data: ' . json_last_error_msg());
157        }
158        return $this->withBody(Stream::fromString($payload))
159            ->withHeader('Content-Type', 'application/json; charset=utf-8');
160    }
161
162    /**
163     * Return a new response with the legacy {message, outcome, status, content} JSON envelope.
164     *
165     * @param array $content The response content
166     * @param string $message The response message
167     * @param bool $outcome Whether the request succeeded
168     * @param int $status HTTP status code
169     * @param array $errors Optional errors payload (e.g. exception details)
170     */
171    public function withJsonEnvelope(
172        array $content = [],
173        string $message = 'Request successfully executed.',
174        bool $outcome = true,
175        int $status = 200,
176        array $errors = []
177    ): static {
178        $envelope = [
179            'message' => $message,
180            'outcome' => $outcome,
181            'status' => $status,
182            'content' => $content,
183        ];
184
185        if ($errors !== []) {
186            $envelope['errors'] = $errors;
187        }
188
189        return $this->withJsonBody($envelope)->withStatus($status);
190    }
191
192    /**
193     * Return a new redirect response.
194     *
195     * @param string $url The URL to redirect to
196     * @param int $status HTTP status code (default 302)
197     */
198    public function withRedirect(string $url, int $status = 302): static
199    {
200        $new = $this->withStatus($status)
201            ->withHeader('Location', $url)
202            ->withBody(Stream::fromString("Redirecting to {$url}"));
203
204        if (! $new->hasHeader('Content-Type')) {
205            $new = $new->withHeader('Content-Type', 'text/plain; charset=utf-8');
206        }
207
208        return $new;
209    }
210
211    /**
212     * Return a new response with a streaming body.
213     *
214     * A callable produces a lazy one-shot body ({@see LazyStream}): invoked
215     * once on first read, fully buffered â€” deferred execution, not
216     * incremental. A Traversable produces true incremental streaming
217     * ({@see IteratorStream}): one chunk per read.
218     *
219     * @param callable|Traversable $source A callable (uses LazyStream) or Traversable/Generator (uses IteratorStream)
220     * @param array $headers Additional headers to set
221     */
222    public function withStream(callable|Traversable $source, array $headers = []): static
223    {
224        $body = $source instanceof Traversable
225            ? new IteratorStream($source)
226            : new LazyStream($source);
227
228        $new = $this->withBody($body);
229        foreach ($headers as $name => $value) {
230            $new = $new->withHeader($name, $value);
231        }
232
233        return $new;
234    }
235
236    /**
237     * Return a new SSE (Server-Sent Events) response.
238     *
239     * Sets appropriate SSE headers and wraps the source in an IteratorStream.
240     *
241     * Accepts any Traversable â€” Generator, Iterator, or IteratorAggregate
242     * (e.g. LazyCollection). Each iteration yields one SSE event, flushed
243     * per event (true streaming). A callable cannot be partially invoked, so
244     * it would have to self-flush around the stream â€” use withStream() for
245     * one-shot lazy bodies instead.
246     *
247     * @param Traversable $source A lazy iterable that yields SSE event payloads
248     */
249    public function withEventStream(Traversable $source): static
250    {
251        return $this
252            ->withBody(new IteratorStream($source))
253            ->withHeader('Content-Type', 'text/event-stream')
254            ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
255            ->withHeader('X-Accel-Buffering', 'no')
256            ->withHeader('Connection', 'keep-alive');
257    }
258}

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    }