Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
62 / 62 |
|
98.41% |
62 / 63 |
|
39.71% |
27 / 68 |
|
88.89% |
8 / 9 |
CRAP | |
0.00% |
0 / 1 |
| Sapi | |
100.00% |
62 / 62 |
|
98.41% |
62 / 63 |
|
39.71% |
27 / 68 |
|
100.00% |
9 / 9 |
241.64 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| emit | |
100.00% |
11 / 11 |
|
100.00% |
8 / 8 |
|
66.67% |
4 / 6 |
|
100.00% |
1 / 1 |
4.59 | |||
| assertNoPreviousOutput | |
100.00% |
4 / 4 |
|
100.00% |
7 / 7 |
|
60.00% |
3 / 5 |
|
100.00% |
1 / 1 |
5.02 | |||
| emitHeaders | |
100.00% |
7 / 7 |
|
100.00% |
7 / 7 |
|
40.00% |
2 / 5 |
|
100.00% |
1 / 1 |
4.94 | |||
| emitStatusLine | |
100.00% |
12 / 12 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| isBodyless | |
100.00% |
1 / 1 |
|
100.00% |
5 / 5 |
|
75.00% |
3 / 4 |
|
100.00% |
1 / 1 |
3.14 | |||
| contentRange | |
100.00% |
5 / 5 |
|
100.00% |
6 / 6 |
|
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| emitBody | |
100.00% |
7 / 7 |
|
100.00% |
8 / 8 |
|
50.00% |
3 / 6 |
|
100.00% |
1 / 1 |
6.00 | |||
| emitBodyRange | |
100.00% |
14 / 14 |
|
94.12% |
16 / 17 |
|
16.67% |
6 / 36 |
|
100.00% |
1 / 1 |
35.36 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celema\Core\Emitter; |
| 6 | |
| 7 | use Celema\Core\Exception\RuntimeException; |
| 8 | use Override; |
| 9 | use Psr\Http\Message\ResponseInterface as Response; |
| 10 | use 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 | */ |
| 19 | final 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 | } |
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.
| 22 | private readonly int $maxBufferLength = 8192, |
| 23 | ) {} |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |