Skip to content

Commit 2183b6a

Browse files
committed
✨ response emitter
1 parent acffc7a commit 2183b6a

File tree

4 files changed

+598
-0
lines changed

4 files changed

+598
-0
lines changed
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
<?php
2+
/**
3+
* Class ResponseEmitterAbstract
4+
*
5+
* @created 22.10.2022
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2022 smiley
8+
* @license MIT
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace chillerlan\HTTP\Utils\Emitter;
14+
15+
use Psr\Http\Message\{ResponseInterface, StreamInterface};
16+
use InvalidArgumentException, RuntimeException;
17+
use function connection_status, flush, in_array, intval, preg_match, sprintf, strlen, strtolower, trim;
18+
use const CONNECTION_NORMAL;
19+
20+
/**
21+
* @see https://datatracker.ietf.org/doc/html/rfc2616
22+
* @see https://datatracker.ietf.org/doc/html/rfc9110
23+
*/
24+
abstract class ResponseEmitterAbstract implements ResponseEmitterInterface{
25+
26+
protected StreamInterface $body;
27+
protected bool $hasCustomLength;
28+
protected bool $hasContentRange;
29+
protected int $rangeStart = 0;
30+
protected int $rangeLength = 0;
31+
32+
/**
33+
* ResponseEmitter constructor
34+
*/
35+
public function __construct(
36+
protected ResponseInterface $response,
37+
protected int $bufferSize = 65536
38+
){
39+
40+
if($this->bufferSize < 1){
41+
throw new InvalidArgumentException('Buffer length must be greater than zero.'); // @codeCoverageIgnore
42+
}
43+
44+
$this->body = $this->response->getBody();
45+
$this->hasContentRange = $this->response->getStatusCode() === 206 && $this->response->hasHeader('Content-Range');
46+
47+
$this->setContentLengthHeader();
48+
49+
if($this->body->isSeekable()){
50+
$this->body->rewind();
51+
}
52+
53+
}
54+
55+
/**
56+
* Checks whether the response has (or is supposed to have) a body
57+
*
58+
* @see https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx
59+
* @see https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content
60+
* @see https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content
61+
* @see https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified
62+
*/
63+
protected function hasBody():bool{
64+
$status = $this->response->getStatusCode();
65+
// these response codes never return a body
66+
if($status < 200 || in_array($status, [204, 205, 304])){
67+
return false;
68+
}
69+
70+
return $this->body->isReadable() && $this->body->getSize() > 0;
71+
}
72+
73+
/**
74+
* Returns a full status line for the given response, e.g. "HTTP/1.1 200 OK"
75+
*/
76+
protected function getStatusLine():string{
77+
78+
$status = sprintf(
79+
'HTTP/%s %d %s',
80+
$this->response->getProtocolVersion(),
81+
$this->response->getStatusCode(),
82+
$this->response->getReasonPhrase(),
83+
);
84+
85+
// the reason phrase may be empty, so we make sure there's no extra trailing spaces in the status line
86+
return trim($status);
87+
}
88+
89+
/**
90+
* Sets/adjusts the Content-Length header
91+
*
92+
* (technically we could do this in a PSR-15 middleware but this class is supposed to work as standalone as well)
93+
*
94+
* @see https://datatracker.ietf.org/doc/html/rfc9110#name-content-length
95+
*/
96+
protected function setContentLengthHeader():void{
97+
$this->hasCustomLength = false;
98+
99+
// remove the content-length header if body is not present
100+
if(!$this->hasBody()){
101+
$this->response = $this->response->withoutHeader('Content-Length');
102+
103+
return;
104+
}
105+
106+
// response has a content-range header set
107+
if($this->hasContentRange){
108+
$parsed = $this->parseContentRange();
109+
110+
if($parsed === null){
111+
$this->hasContentRange = false;
112+
113+
// content range is invalid, we'll remove the header send the full response with a code 200 instead
114+
// @see https://datatracker.ietf.org/doc/html/rfc9110#status.416 (note)
115+
$this->response = $this->response
116+
->withStatus(200, 'OK')
117+
->withoutHeader('Content-Range')
118+
->withHeader('Content-Length', (string)$this->body->getSize())
119+
;
120+
121+
return;
122+
}
123+
124+
[$this->rangeStart, $end, $total, $this->rangeLength] = $parsed;
125+
126+
$this->response = $this->response
127+
// adjust the content-range header to include the full response size
128+
->withHeader('Content-Range', sprintf('bytes %s-%s/%s', $this->rangeStart, $end, $total))
129+
// add the content-length header with the partially fulfilled size
130+
->withHeader('Content-Length', (string)$this->rangeLength)
131+
;
132+
133+
return;
134+
}
135+
136+
// add the header if it's missing
137+
if(!$this->response->hasHeader('Content-Length')){
138+
$this->response = $this->response->withHeader('Content-Length', (string)$this->body->getSize());
139+
140+
return;
141+
}
142+
143+
// a header was present
144+
$contentLength = (int)$this->response->getHeaderLine('Content-Length');
145+
// we don't touch the custom value that has been set for whatever reason
146+
if($contentLength < $this->body->getSize()){
147+
$this->hasCustomLength = true;
148+
$this->rangeLength = $contentLength;
149+
}
150+
151+
}
152+
153+
/**
154+
* @see https://datatracker.ietf.org/doc/html/rfc9110#name-content-range
155+
*/
156+
protected function parseContentRange():array|null{
157+
$contentRange = $this->response->getHeaderLine('Content-Range');
158+
if(preg_match('/(?P<unit>[a-z]+)\s+(?P<start>\d+)-(?P<end>\d+)\/(?P<total>\d+|\*)/i', $contentRange, $matches)){
159+
// we only accept the "bytes" unit here
160+
if(strtolower($matches['unit']) !== 'bytes'){
161+
return null;
162+
}
163+
164+
$start = intval($matches['start']);
165+
$end = intval($matches['end']);
166+
$total = ($matches['total'] === '*') ? $this->body->getSize() : intval($matches['total']);
167+
$length = ($end - $start + 1);
168+
169+
if($end < $start){
170+
return null;
171+
}
172+
173+
// we're being generous and adjust if the end is greater than the total size
174+
if($end > $total){
175+
$length = ($total - $start);
176+
}
177+
178+
return [$start, $end, $total, $length];
179+
}
180+
181+
return null;
182+
}
183+
184+
/**
185+
* emits the given buffer
186+
*
187+
* @codeCoverageIgnore (overridden in test)
188+
*/
189+
protected function emitBuffer(string $buffer):void{
190+
echo $buffer;
191+
}
192+
193+
/**
194+
* emits the body of the given response with respect to the parameters given in content-range and content-length headers
195+
*/
196+
protected function emitBody():void{
197+
198+
if(!$this->hasBody()){
199+
return;
200+
}
201+
202+
// a length smaller than the total body size was specified
203+
if($this->hasCustomLength === true){
204+
$this->emitBodyRange(0, $this->rangeLength);
205+
206+
return;
207+
}
208+
209+
// a content-range header was set
210+
if($this->hasContentRange === true){
211+
$this->emitBodyRange($this->rangeStart, $this->rangeLength);
212+
213+
return;
214+
}
215+
216+
// dump the whole body
217+
while(!$this->body->eof()){
218+
$this->emitBuffer($this->body->read($this->bufferSize));
219+
220+
if(connection_status() !== CONNECTION_NORMAL){
221+
break; // @codeCoverageIgnore
222+
}
223+
}
224+
225+
}
226+
227+
/**
228+
* emits a part of the body
229+
*/
230+
protected function emitBodyRange(int $start, int $length):void{
231+
flush();
232+
233+
if(!$this->body->isSeekable()){
234+
throw new RuntimeException('body must be seekable'); // @codeCoverageIgnore
235+
}
236+
237+
$this->body->seek($start);
238+
239+
while($length >= $this->bufferSize && !$this->body->eof()){
240+
$contents = $this->body->read($this->bufferSize);
241+
$length -= strlen($contents);
242+
243+
$this->emitBuffer($contents);
244+
245+
if(connection_status() !== CONNECTION_NORMAL){
246+
break; // @codeCoverageIgnore
247+
}
248+
}
249+
250+
if($length > 0 && !$this->body->eof()){
251+
$this->emitBuffer($this->body->read($length));
252+
}
253+
254+
}
255+
256+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
/**
3+
* Interface ResponseEmitterInterface
4+
*
5+
* @created 22.10.2022
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2022 smiley
8+
* @license MIT
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace chillerlan\HTTP\Utils\Emitter;
14+
15+
/**
16+
*
17+
*/
18+
interface ResponseEmitterInterface{
19+
20+
/**
21+
* Emits a PSR-7 response.
22+
*/
23+
public function emit():void;
24+
25+
}

src/Emitter/SapiEmitter.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
/**
3+
* Class SapiEmitter
4+
*
5+
* @created 22.10.2022
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2022 smiley
8+
* @license MIT
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace chillerlan\HTTP\Utils\Emitter;
14+
15+
use chillerlan\HTTP\Utils\HeaderUtil;
16+
use RuntimeException;
17+
use function header, headers_sent, is_array, ob_get_length, ob_get_level, sprintf;
18+
19+
/**
20+
*
21+
*/
22+
class SapiEmitter extends ResponseEmitterAbstract{
23+
24+
/**
25+
* @inheritDoc
26+
*/
27+
public function emit():void{
28+
29+
if(ob_get_level() > 0 && ob_get_length() > 0){
30+
throw new RuntimeException('Output has been emitted previously; cannot emit response.');
31+
}
32+
33+
if(headers_sent($file, $line)){
34+
throw new RuntimeException(sprintf('Headers already sent in file %s on line %s.', $file, $line));
35+
}
36+
37+
$this->emitHeaders();
38+
$this->emitBody();
39+
}
40+
41+
/**
42+
* Emits the headers
43+
*
44+
* There are two special-case header calls. The first is a header
45+
* that starts with the string "HTTP/" (case is not significant),
46+
* which will be used to figure out the HTTP status code to send.
47+
* For example, if you have configured Apache to use a PHP script
48+
* to handle requests for missing files (using the ErrorDocument
49+
* directive), you may want to make sure that your script generates
50+
* the proper status code.
51+
*
52+
* The second special case is the "Location:" header.
53+
* Not only does it send this header back to the browser,
54+
* but it also returns a REDIRECT (302) status code to the browser
55+
* unless the 201 or a 3xx status code has already been set.
56+
*
57+
* @see https://www.php.net/manual/en/function.header.php
58+
*/
59+
protected function emitHeaders():void{
60+
$headers = HeaderUtil::normalize($this->response->getHeaders());
61+
62+
foreach($headers as $name => $value){
63+
64+
if($name === 'Set-Cookie'){
65+
continue;
66+
}
67+
68+
$this->sendHeader(sprintf('%s: %s', $name, $value), true);
69+
}
70+
71+
if(isset($headers['Set-Cookie']) && is_array($headers['Set-Cookie'])){
72+
73+
foreach($headers['Set-Cookie'] as $cookie){
74+
$this->sendHeader(sprintf('Set-Cookie: %s', $cookie), false);
75+
}
76+
77+
}
78+
79+
// Set the status _after_ the headers, because of PHP's "helpful" behavior with location headers.
80+
// See https://github.com/slimphp/Slim/issues/1730
81+
$this->sendHeader($this->getStatusLine(), true, $this->response->getStatusCode());
82+
}
83+
84+
/**
85+
* Allow to intercept header calls in tests
86+
*
87+
* @codeCoverageIgnore (overridden in test)
88+
*/
89+
protected function sendHeader(string $header, bool $replace, int $response_code = 0):void{
90+
header($header, $replace, $response_code);
91+
}
92+
93+
}

0 commit comments

Comments
 (0)