1 <?php
2
3 /*
4 * This file is part of the ICanBoogie package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace ICanBoogie\HTTP;
13
14 use ICanBoogie\Accessor\AccessorTrait;
15
16 /**
17 * A response to a HTTP request.
18 *
19 * @property Status $status
20 * @property mixed $body The body of the response.
21 *
22 * @property int $ttl Adjusts the `s-maxage` part of the `Cache-Control` header field definition according to the `Age` header field definition.
23 * @property int $age Shortcut to the `Age` header field definition.
24 * @property Headers\CacheControl $cache_control Shortcut to the `Cache-Control` header field definition.
25 * @property int $content_length Shortcut to the `Content-Length` header field definition.
26 * @property Headers\ContentType $content_type Shortcut to the `Content-Type` header field definition.
27 * @property Headers\Date $date Shortcut to the `Date` header field definition.
28 * @property string $etag Shortcut to the `Etag` header field definition.
29 * @property Headers\Date $expires Shortcut to the `Expires` header field definition.
30 * @property Headers\Date $last_modified Shortcut to the `Last-Modified` header field definition.
31 * @property string $location Shortcut to the `Location` header field definition.
32 *
33 * @property-read boolean $is_cacheable {@link get_is_cacheable()}
34 * @property-read boolean $is_fresh {@link get_is_fresh()}
35 * @property-read boolean $is_private {@link get_is_private()}
36 * @property-read boolean $is_validateable {@link get_is_validateable()}
37 *
38 * @see http://tools.ietf.org/html/rfc2616
39 */
40 class Response
41 {
42 use AccessorTrait;
43
44 /**
45 * Response headers.
46 *
47 * @var Headers
48 */
49 public $headers;
50
51 /**
52 * The HTTP protocol version (1.0 or 1.1), defaults to '1.0'
53 *
54 * @var string
55 */
56 public $version = '1.0';
57
58 /**
59 * Initializes the {@link $body}, {@link $header}, {@link $date} and {@link $status}
60 * properties.
61 *
62 * @param mixed $body The body of the response.
63 * @param int|Status $status The status code of the response.
64 * @param Headers|array $headers The initial header fields of the response.
65 */
66 public function __construct($body = null, $status = 200, $headers = [])
67 {
68 if (is_array($headers))
69 {
70 $headers = new Headers($headers);
71 }
72 else if (!($headers instanceof Headers))
73 {
74 throw new \InvalidArgumentException("\$headers must be an array or a ICanBoogie\\HTTP\\Headers instance. Given: " . gettype($headers));
75 }
76
77 $this->headers = $headers;
78
79 if ($this->date->is_empty)
80 {
81 $this->date = 'now';
82 }
83
84 $this->set_status($status);
85
86 if ($body !== null)
87 {
88 $this->set_body($body);
89 }
90 }
91
92 /**
93 * Clones the {@link $headers} and {@link $status} properties.
94 */
95 public function __clone()
96 {
97 $this->headers = clone $this->headers;
98 $this->status = clone $this->status;
99 }
100
101 /**
102 * Renders the response as an HTTP string.
103 *
104 * @return string
105 */
106 public function __toString()
107 {
108 try
109 {
110 $header = clone $this->headers;
111 $body = $this->body;
112
113 $this->finalize($header, $body);
114
115 ob_start();
116
117 $this->send_body($body);
118
119 $body = ob_get_clean();
120
121 return "HTTP/{$this->version} {$this->status}\r\n"
122 . $header
123 . "\r\n"
124 . $body;
125 }
126 catch (\Exception $e)
127 {
128 return $e->getMessage();
129 }
130 }
131
132 /**
133 * Issues the HTTP response.
134 *
135 * {@link finalize()} is invoked to finalize the headers (a cloned actually)
136 * and the body. {@link send_headers} is invoked to send the headers and {@link send_body()}
137 *is invoked to send the body, if the body is not `null`.
138 *
139 * The body is not send in the following instances:
140 *
141 * - The finalized body is `null`.
142 * - The status is not ok.
143 */
144 public function __invoke()
145 {
146 $headers = clone $this->headers;
147 $body = $this->body;
148
149 $this->finalize($headers, $body);
150 $this->send_headers($headers);
151
152 if ($body === null)
153 {
154 return;
155 }
156
157 $this->send_body($body);
158 }
159
160 /**
161 * Finalize the body.
162 *
163 * Subclasses might want to override this method if they wish to alter the header or the body
164 * before the response is sent or transformed into a string.
165 *
166 * @param Headers $headers Reference to the final header.
167 * @param mixed $body Reference to the final body.
168 */
169 protected function finalize(Headers &$headers, &$body)
170 {
171 if ($body instanceof \Closure || !method_exists($body, '__toString'))
172 {
173 return;
174 }
175
176 $body = (string) $body;
177 }
178
179 /**
180 * Sends response headers.
181 *
182 * @param Headers $headers
183 *
184 * @return bool `true` is the headers were sent, `false` otherwise.
185 */
186 // @codeCoverageIgnoreStart
187 protected function send_headers(Headers $headers)
188 {
189 if (headers_sent($file, $line))
190 {
191 trigger_error(\ICanBoogie\format
192 (
193 "Cannot modify header information because it was already sent. Output started at !at.", [
194
195 'at' => $file . ':' . $line
196
197 ]
198 ));
199
200 return false;
201 }
202
203 header_remove('Pragma');
204 header_remove('X-Powered-By');
205
206 header("HTTP/{$this->version} {$this->status}");
207
208 $headers();
209
210 return true;
211 }
212 // @codeCoverageIgnoreEnd
213
214 /**
215 * Sends response body.
216 *
217 * @param mixed $body
218 */
219 protected function send_body($body)
220 {
221 if ($body instanceof \Closure)
222 {
223 $body($this);
224
225 return;
226 }
227
228 echo $body;
229 }
230
231 /**
232 * Status of the HTTP response.
233 *
234 * @var Status
235 */
236 private $status;
237
238 /**
239 * Sets response status code and optionally status message.
240 *
241 * @param int|Status $status HTTP status code or HTTP status code and HTTP status message.
242 */
243 protected function set_status($status)
244 {
245 $this->status = Status::from($status);
246 }
247
248 /**
249 * Returns the response status.
250 *
251 * @return Status
252 */
253 protected function get_status()
254 {
255 return $this->status;
256 }
257
258 /**
259 * The response body.
260 *
261 * @var mixed
262 *
263 * @see set_body(), get_body()
264 */
265 private $body;
266
267 /**
268 * Sets the response body.
269 *
270 * The body can be any data type that can be converted into a string. This includes numeric
271 * and objects implementing the {@link __toString()} method.
272 *
273 * @param string|\Closure|null $body
274 *
275 * @throws \UnexpectedValueException when the body cannot be converted to a string.
276 */
277 protected function set_body($body)
278 {
279 $this->assert_body_is_valid($body);
280
281 $this->body = $body;
282 }
283
284 /**
285 * Asserts that the a body is valid.
286 *
287 * @param $body
288 *
289 * @throws \UnexpectedValueException if the specified body doesn't meet the requirements.
290 */
291 protected function assert_body_is_valid($body)
292 {
293 if ($body === null
294 || $body instanceof \Closure
295 || is_numeric($body)
296 || is_string($body)
297 || (is_object($body) && method_exists($body, '__toString')))
298 {
299 return;
300 }
301
302 throw new \UnexpectedValueException(\ICanBoogie\format
303 (
304 'The Response body must be a string, an object implementing the __toString() method or be callable, %type given. !value', array
305 (
306 'type' => gettype($body),
307 'value' => $body
308 )
309 ));
310 }
311
312 /**
313 * Returns the response body.
314 *
315 * @return string
316 */
317 protected function get_body()
318 {
319 return $this->body;
320 }
321
322 /**
323 * Sets the value of the `Location` header field.
324 *
325 * @param string|null $url
326 */
327 protected function set_location($url)
328 {
329 if ($url !== null && !$url)
330 {
331 throw new \InvalidArgumentException('Cannot redirect to an empty URL.');
332 }
333
334 $this->headers['Location'] = $url;
335 }
336
337 /**
338 * Returns the value of the `Location` header field.
339 *
340 * @return string
341 */
342 protected function get_location()
343 {
344 return $this->headers['Location'];
345 }
346
347 /**
348 * Sets the value of the `Content-Type` header field.
349 *
350 * @param string $content_type
351 */
352 protected function set_content_type($content_type)
353 {
354 $this->headers['Content-Type'] = $content_type;
355 }
356
357 /**
358 * Returns the value of the `Content-Type` header field.
359 *
360 * @return Headers\ContentType
361 */
362 protected function get_content_type()
363 {
364 return $this->headers['Content-Type'];
365 }
366
367 /**
368 * Sets the value of the `Content-Length` header field.
369 *
370 * @param int $length
371 */
372 protected function set_content_length($length)
373 {
374 $this->headers['Content-Length'] = $length;
375 }
376
377 /**
378 * Returns the value of the `Content-Length` header field.
379 *
380 * @return int
381 */
382 protected function get_content_length()
383 {
384 return $this->headers['Content-Length'];
385 }
386
387 /**
388 * Sets the value of the `Date` header field.
389 *
390 * @param mixed $time
391 */
392 protected function set_date($time)
393 {
394 $this->headers['Date'] = $time;
395 }
396
397 /**
398 * Returns the value of the `Date` header field.
399 *
400 * @return Headers\Date
401 */
402 protected function get_date()
403 {
404 return $this->headers['Date'];
405 }
406
407 /**
408 * Sets the value of the `Age` header field.
409 *
410 * @param int $age
411 */
412 protected function set_age($age)
413 {
414 $this->headers['Age'] = $age;
415 }
416
417 /**
418 * Returns the age of the response.
419 *
420 * @return int
421 */
422 protected function get_age()
423 {
424 $age = $this->headers['Age'];
425
426 if ($age)
427 {
428 return $age;
429 }
430
431 if (!$this->date->is_empty)
432 {
433 return max(0, time() - $this->date->utc->timestamp);
434 }
435
436 return null;
437 }
438
439 /**
440 * Sets the value of the `Last-Modified` header field.
441 *
442 * @param mixed $time
443 */
444 protected function set_last_modified($time)
445 {
446 $this->headers['Last-Modified'] = $time;
447 }
448
449 /**
450 * Returns the value of the `Last-Modified` header field.
451 *
452 * @return Headers\Date
453 */
454 protected function get_last_modified()
455 {
456 return $this->headers['Last-Modified'];
457 }
458
459 /**
460 * Sets the value of the `Expires` header field.
461 *
462 * The method also calls the {@link session_cache_expire()} function.
463 *
464 * @param mixed $time
465 */
466 protected function set_expires($time)
467 {
468 $this->headers['Expires'] = $time;
469
470 session_cache_expire($time); // TODO-20120831: Is this required now that we have an awesome response system ?
471 }
472
473 /**
474 * Returns the value of the `Expires` header field.
475 *
476 * @return Headers\Date
477 */
478 protected function get_expires()
479 {
480 return $this->headers['Expires'];
481 }
482
483 /**
484 * Sets the value of the `Etag` header field.
485 *
486 * @param string $value
487 */
488 protected function set_etag($value)
489 {
490 $this->headers['Etag'] = $value;
491 }
492
493 /**
494 * Returns the value of the `Etag` header field.
495 *
496 * @return string
497 */
498 protected function get_etag()
499 {
500 return $this->headers['Etag'];
501 }
502
503 /**
504 * Sets the directives of the `Cache-Control` header field.
505 *
506 * @param string $cache_directives
507 */
508 protected function set_cache_control($cache_directives)
509 {
510 $this->headers['Cache-Control'] = $cache_directives;
511 }
512
513 /**
514 * Returns the `Cache-Control` header field.
515 *
516 * @return Headers\CacheControl
517 */
518 protected function get_cache_control()
519 {
520 return $this->headers['Cache-Control'];
521 }
522
523 /**
524 * Sets the response's time-to-live for shared caches.
525 *
526 * This method adjusts the Cache-Control/s-maxage directive.
527 *
528 * @param int $seconds The number of seconds.
529 */
530 protected function set_ttl($seconds)
531 {
532 $this->cache_control->s_maxage = $this->age + $seconds;
533 }
534
535 /**
536 * Returns the response's time-to-live in seconds.
537 *
538 * When the responses TTL is <= 0, the response may not be served from cache without first
539 * re-validating with the origin.
540 *
541 * @return int|null The number of seconds to live, or `null` is no freshness information
542 * is present.
543 */
544 protected function get_ttl()
545 {
546 $max_age = $this->cache_control->max_age;
547
548 if ($max_age)
549 {
550 return $max_age - $this->age;
551 }
552
553 return null;
554 }
555
556 /**
557 * Checks that the response includes header fields that can be used to validate the response
558 * with the origin server using a conditional GET request.
559 *
560 * @return boolean
561 */
562 protected function get_is_validateable()
563 {
564 return !$this->headers['Last-Modified']->is_empty || $this->headers['ETag'];
565 }
566
567 /**
568 * Checks that the response is worth caching under any circumstance.
569 *
570 * Responses marked _private_ with an explicit `Cache-Control` directive are considered
571 * not cacheable.
572 *
573 * Responses with neither a freshness lifetime (Expires, max-age) nor cache validator
574 * (`Last-Modified`, `ETag`) are considered not cacheable.
575 *
576 * @return boolean
577 */
578 protected function get_is_cacheable()
579 {
580 if (!$this->status->is_cacheable || $this->cache_control->no_store || $this->cache_control->cacheable == 'private')
581 {
582 return false;
583 }
584
585 return $this->is_validateable || $this->is_fresh;
586 }
587
588 /**
589 * Checks if the response is fresh.
590 *
591 * @return boolean
592 */
593 protected function get_is_fresh()
594 {
595 return $this->ttl > 0;
596 }
597 }
598