Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
62 / 62
98.41% covered (success)
98.41%
62 / 63
39.71% covered (danger)
39.71%
27 / 68
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sapi
100.00% covered (success)
100.00%
62 / 62
98.41% covered (success)
98.41%
62 / 63
39.71% covered (danger)
39.71%
27 / 68
100.00% covered (success)
100.00%
9 / 9
241.64
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
 emit
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
8 / 8
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
4.59
 assertNoPreviousOutput
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
7 / 7
60.00% covered (warning)
60.00%
3 / 5
100.00% covered (success)
100.00%
1 / 1
5.02
 emitHeaders
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
7 / 7
40.00% covered (danger)
40.00%
2 / 5
100.00% covered (success)
100.00%
1 / 1
4.94
 emitStatusLine
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isBodyless
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 contentRange
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 emitBody
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
8 / 8
50.00% covered (danger)
50.00%
3 / 6
100.00% covered (success)
100.00%
1 / 1
6.00
 emitBodyRange
100.00% covered (success)
100.00%
14 / 14
94.12% covered (success)
94.12%
16 / 17
16.67% covered (danger)
16.67%
6 / 36
100.00% covered (success)
100.00%
1 / 1
35.36
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Core\Emitter;
6
7use Celema\Core\Exception\RuntimeException;
8use Override;
9use Psr\Http\Message\ResponseInterface as Response;
10use Psr\Http\Message\StreamInterface as Stream;
11
12/**
13 * Derived from the SAPI emitters of laminas/laminas-httphandlerrunner,
14 * copyright (c) 2020 Laminas Project a Series of LF Projects, LLC,
15 * distributed under the BSD-3-Clause license. See LICENSE.md.
16 *
17 * @api
18 */
19final class Sapi implements Emitter
20{
21    public function __construct(
22        private readonly int $maxBufferLength = 8192,
23    ) {}
24
25    #[Override]
26    public function emit(Response $response, bool $withoutBody = false): bool
27    {
28        $this->assertNoPreviousOutput();
29        $this->emitHeaders($response);
30        $this->emitStatusLine($response);
31        flush();
32
33        if ($withoutBody || $this->isBodyless($response->getStatusCode())) {
34            return true;
35        }
36
37        $range = $this->contentRange($response->getHeaderLine('Content-Range'));
38
39        if ($range === null) {
40            $this->emitBody($response->getBody());
41        } else {
42            $this->emitBodyRange($response->getBody(), $range[0], $range[1]);
43        }
44
45        return true;
46    }
47
48    private function assertNoPreviousOutput(): void
49    {
50        if (headers_sent($file, $line)) {
51            throw new RuntimeException("Headers already sent in {$file} on line {$line}");
52        }
53
54        if (ob_get_level() > 0 && ob_get_length() > 0) {
55            throw new RuntimeException('Output already present in the output buffer');
56        }
57    }
58
59    /**
60     * Emitted before the status line so PHP cannot override the status
61     * code with one derived from a header like Location.
62     */
63    private function emitHeaders(Response $response): void
64    {
65        $statusCode = $response->getStatusCode();
66
67        foreach ($response->getHeaders() as $name => $values) {
68            $name = ucwords((string) $name, '-');
69            $replace = $name !== 'Set-Cookie';
70
71            foreach ($values as $value) {
72                header("{$name}{$value}", $replace, $statusCode);
73                $replace = false;
74            }
75        }
76    }
77
78    private function emitStatusLine(Response $response): void
79    {
80        $statusCode = $response->getStatusCode();
81        $reasonPhrase = $response->getReasonPhrase();
82
83        header(
84            sprintf(
85                'HTTP/%s %d%s',
86                $response->getProtocolVersion(),
87                $statusCode,
88                $reasonPhrase === '' ? '' : " {$reasonPhrase}",
89            ),
90            true,
91            $statusCode,
92        );
93    }
94
95    /**
96     * A 1xx, 204, or 304 response must not include a body (RFC 9110).
97     */
98    private function isBodyless(int $statusCode): bool
99    {
100        return $statusCode < 200 || $statusCode === 204 || $statusCode === 304;
101    }
102
103    /**
104     * Returns [first, last] byte positions from a valid bytes Content-Range
105     * header, or null when the whole body should be emitted.
106     *
107     * @return array{0: int, 1: int}|null
108     */
109    private function contentRange(string $header): ?array
110    {
111        if (preg_match('/^bytes (?P<first>\d+)-(?P<last>\d+)\/(?:\d+|\*)$/', $header, $matches) !== 1) {
112            return null;
113        }
114
115        $first = (int) $matches['first'];
116        $last = (int) $matches['last'];
117
118        return $first <= $last ? [$first, $last] : null;
119    }
120
121    private function emitBody(Stream $body): void
122    {
123        if ($body->isSeekable()) {
124            $body->rewind();
125        }
126
127        if (!$body->isReadable()) {
128            echo (string) $body;
129
130            return;
131        }
132
133        while (!$body->eof()) {
134            echo $body->read($this->maxBufferLength);
135        }
136    }
137
138    private function emitBodyRange(Stream $body, int $first, int $last): void
139    {
140        $length = $last - $first + 1;
141
142        if ($body->isSeekable()) {
143            $body->seek($first);
144            $first = 0;
145        }
146
147        if (!$body->isReadable()) {
148            echo substr($body->getContents(), $first, $length);
149
150            return;
151        }
152
153        $remaining = $length;
154
155        while ($remaining >= $this->maxBufferLength && !$body->eof()) {
156            $contents = $body->read($this->maxBufferLength);
157            $remaining -= strlen($contents);
158            echo $contents;
159        }
160
161        if ($remaining > 0 && !$body->eof()) {
162            echo $body->read($remaining);
163        }
164    }
165}