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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Sapi->__construct
22        private readonly int $maxBufferLength = 8192,
23    ) {}
Sapi->assertNoPreviousOutput
50        if (headers_sent($file, $line)) {
51            throw new RuntimeException("Headers already sent in {$file} on line {$line}");
54        if (ob_get_level() > 0 && ob_get_length() > 0) {
54        if (ob_get_level() > 0 && ob_get_length() > 0) {
54        if (ob_get_level() > 0 && ob_get_length() > 0) {
55            throw new RuntimeException('Output already present in the output buffer');
57    }
Sapi->contentRange
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;
115        $first = (int) $matches['first'];
116        $last = (int) $matches['last'];
117
118        return $first <= $last ? [$first, $last] : null;
118        return $first <= $last ? [$first, $last] : null;
118        return $first <= $last ? [$first, $last] : null;
118        return $first <= $last ? [$first, $last] : null;
119    }
Sapi->emit
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())) {
33        if ($withoutBody || $this->isBodyless($response->getStatusCode())) {
33        if ($withoutBody || $this->isBodyless($response->getStatusCode())) {
34            return true;
37        $range = $this->contentRange($response->getHeaderLine('Content-Range'));
38
39        if ($range === null) {
39        if ($range === null) {
40            $this->emitBody($response->getBody());
42            $this->emitBodyRange($response->getBody(), $range[0], $range[1]);
43        }
44
45        return true;
45        return true;
46    }
Sapi->emitBody
121    private function emitBody(Stream $body): void
122    {
123        if ($body->isSeekable()) {
124            $body->rewind();
125        }
126
127        if (!$body->isReadable()) {
127        if (!$body->isReadable()) {
128            echo (string) $body;
129
130            return;
133        while (!$body->eof()) {
133        while (!$body->eof()) {
134            echo $body->read($this->maxBufferLength);
133        while (!$body->eof()) {
136    }
Sapi->emitBodyRange
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()) {
147        if (!$body->isReadable()) {
148            echo substr($body->getContents(), $first, $length);
148            echo substr($body->getContents(), $first, $length);
148            echo substr($body->getContents(), $first, $length);
148            echo substr($body->getContents(), $first, $length);
149
150            return;
153        $remaining = $length;
154
155        while ($remaining >= $this->maxBufferLength && !$body->eof()) {
155        while ($remaining >= $this->maxBufferLength && !$body->eof()) {
156            $contents = $body->read($this->maxBufferLength);
155        while ($remaining >= $this->maxBufferLength && !$body->eof()) {
155        while ($remaining >= $this->maxBufferLength && !$body->eof()) {
155        while ($remaining >= $this->maxBufferLength && !$body->eof()) {
161        if ($remaining > 0 && !$body->eof()) {
161        if ($remaining > 0 && !$body->eof()) {
161        if ($remaining > 0 && !$body->eof()) {
162            echo $body->read($remaining);
163        }
164    }
164    }
Sapi->emitHeaders
63    private function emitHeaders(Response $response): void
64    {
65        $statusCode = $response->getStatusCode();
66
67        foreach ($response->getHeaders() as $name => $values) {
67        foreach ($response->getHeaders() as $name => $values) {
67        foreach ($response->getHeaders() as $name => $values) {
68            $name = ucwords((string) $name, '-');
69            $replace = $name !== 'Set-Cookie';
70
71            foreach ($values as $value) {
71            foreach ($values as $value) {
71            foreach ($values as $value) {
72                header("{$name}{$value}", $replace, $statusCode);
67        foreach ($response->getHeaders() as $name => $values) {
68            $name = ucwords((string) $name, '-');
69            $replace = $name !== 'Set-Cookie';
70
71            foreach ($values as $value) {
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    }
Sapi->emitStatusLine
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}",
88                $reasonPhrase === '' ? '' : " {$reasonPhrase}",
88                $reasonPhrase === '' ? '' : " {$reasonPhrase}",
88                $reasonPhrase === '' ? '' : " {$reasonPhrase}",
89            ),
90            true,
91            $statusCode,
92        );
93    }
Sapi->isBodyless
98    private function isBodyless(int $statusCode): bool
99    {
100        return $statusCode < 200 || $statusCode === 204 || $statusCode === 304;
100        return $statusCode < 200 || $statusCode === 204 || $statusCode === 304;
100        return $statusCode < 200 || $statusCode === 204 || $statusCode === 304;
100        return $statusCode < 200 || $statusCode === 204 || $statusCode === 304;
100        return $statusCode < 200 || $statusCode === 204 || $statusCode === 304;
101    }