Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
62 / 62
98.36% covered (success)
98.36%
60 / 61
40.91% covered (danger)
40.91%
27 / 66
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.36% covered (success)
98.36%
60 / 61
40.91% covered (danger)
40.91%
27 / 66
100.00% covered (success)
100.00%
9 / 9
215.70
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%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 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        // ob_get_length() is false without an active buffer.
55        if (ob_get_length() > 0) {
56            throw new RuntimeException('Output already present in the output buffer');
57        }
58    }
59
60    /**
61     * Emitted before the status line so PHP cannot override the status
62     * code with one derived from a header like Location.
63     */
64    private function emitHeaders(Response $response): void
65    {
66        $statusCode = $response->getStatusCode();
67
68        foreach ($response->getHeaders() as $name => $values) {
69            $name = ucwords((string) $name, '-');
70            $replace = $name !== 'Set-Cookie';
71
72            foreach ($values as $value) {
73                header("{$name}: {$value}", $replace, $statusCode);
74                $replace = false;
75            }
76        }
77    }
78
79    private function emitStatusLine(Response $response): void
80    {
81        $statusCode = $response->getStatusCode();
82        $reasonPhrase = $response->getReasonPhrase();
83
84        header(
85            sprintf(
86                'HTTP/%s %d%s',
87                $response->getProtocolVersion(),
88                $statusCode,
89                $reasonPhrase === '' ? '' : " {$reasonPhrase}",
90            ),
91            true,
92            $statusCode,
93        );
94    }
95
96    /**
97     * A 1xx, 204, or 304 response must not include a body (RFC 9110).
98     */
99    private function isBodyless(int $statusCode): bool
100    {
101        return $statusCode < 200 || $statusCode === 204 || $statusCode === 304;
102    }
103
104    /**
105     * Returns [first, last] byte positions from a valid bytes Content-Range
106     * header, or null when the whole body should be emitted.
107     *
108     * @return array{0: int, 1: int}|null
109     */
110    private function contentRange(string $header): ?array
111    {
112        if (preg_match('/^bytes (?P<first>\d+)-(?P<last>\d+)\/(?:\d+|\*)$/', $header, $matches) !== 1) {
113            return null;
114        }
115
116        $first = (int) $matches['first'];
117        $last = (int) $matches['last'];
118
119        return $first <= $last ? [$first, $last] : null;
120    }
121
122    private function emitBody(Stream $body): void
123    {
124        if ($body->isSeekable()) {
125            $body->rewind();
126        }
127
128        if (!$body->isReadable()) {
129            echo (string) $body;
130
131            return;
132        }
133
134        while (!$body->eof()) {
135            echo $body->read($this->maxBufferLength);
136        }
137    }
138
139    private function emitBodyRange(Stream $body, int $first, int $last): void
140    {
141        $length = $last - $first + 1;
142
143        if ($body->isSeekable()) {
144            $body->seek($first);
145            $first = 0;
146        }
147
148        if (!$body->isReadable()) {
149            echo substr($body->getContents(), $first, $length);
150
151            return;
152        }
153
154        $remaining = $length;
155
156        while ($remaining >= $this->maxBufferLength && !$body->eof()) {
157            $contents = $body->read($this->maxBufferLength);
158            $remaining -= strlen($contents);
159            echo $contents;
160        }
161
162        if ($remaining > 0 && !$body->eof()) {
163            echo $body->read($remaining);
164        }
165    }
166}