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\PropertyNotWritable;
15
16 /**
17 * A response to a HTTP request.
18 *
19 * @property integer $status The HTTP status code.
20 * See: {@link set_status()} {@link get_status()}
21 * @property string $status_message The HTTP status message.
22 * See: {@link set_status_message()} {@link get_status_message()}
23 * @property int $ttl Adjusts the `s-maxage` part of the `Cache-Control` header field definition according to the `Age` header field definition.
24 * See: {@link set_ttl()} {@link get_ttl()}
25 *
26 * @property int $age Shortcut to the `Age` header field definition.
27 * See: {@link set_age()} {@link get_age()}
28 * @property \ICanBoogie\HTTP\CacheControlHeader $cache_control Shortcut to the `Cache-Control` header field definition.
29 * See: {@link set_cache_control()} {@link get_cache_control()}
30 * @property int $content_length Shortcut to the `Content-Length` header field definition.
31 * See: {@link set_content_length()} {@link get_content_length()}
32 * @property \ICanBoogie\HTTP\ContentTypeHeader $content_type Shortcut to the `Content-Type` header field definition.
33 * See: {@link set_content_type()} {@link get_content_type()}
34 * @property \ICanBoogie\HTTP\DateHeader $date Shortcut to the `Date` header field definition.
35 * See: {@link set_date()} {@link get_date()}
36 * @property string $etag Shortcut to the `Etag` header field definition.
37 * See: {@link set_etag()} {@link get_etag()}
38 * @property \ICanBoogie\HTTP\DateHeader $expires Shortcut to the `Expires` header field definition.
39 * See: {@link set_expires()} {@link get_expires()}
40 * @property \ICanBoogie\HTTP\DateHeader $last_modified Shortcut to the `Last-Modified` header field definition.
41 * See: {@link set_last_modified()} {@link get_last_modified()}
42 * @property string $location Shortcut to the `Location` header field definition.
43 * See: {@link set_location()} {@link get_location()}
44 *
45 * @property string|\Closure $body The body of the response.
46 * See: {@link set_body()} {@link get_body()}
47 *
48 * @property-read boolean $is_cacheable {@link get_is_cacheable()}
49 * @property-read boolean $is_client_error {@link get_is_client_error()}
50 * @property-read boolean $is_empty {@link get_is_empty()}
51 * @property-read boolean $is_forbidden {@link get_is_forbidden()}
52 * @property-read boolean $is_fresh {@link get_is_fresh()}
53 * @property-read boolean $is_informational {@link get_is_informational()}
54 * @property-read boolean $is_not_found {@link get_is_not_found()}
55 * @property-read boolean $is_ok {@link get_is_ok()}
56 * @property-read boolean $is_private {@link get_is_private()}
57 * @property-read boolean $is_redirect {@link get_is_redirect()}
58 * @property-read boolean $is_server_error {@link get_is_server_error()}
59 * @property-read boolean $is_successful {@link get_is_successful()}
60 * @property-read boolean $is_valid {@link get_is_valid()}
61 * @property-read boolean $is_validateable {@link get_is_validateable()}
62 *
63 * @see http://tools.ietf.org/html/rfc2616
64 */
65 class Response extends \ICanBoogie\Object
66 {
67 /**
68 * HTTP status messages.
69 *
70 * @var array[int]string
71 */
72 static public $status_messages = array
73 (
74 100 => "Continue",
75 101 => "Switching Protocols",
76
77 200 => "OK",
78 201 => "Created",
79 202 => "Accepted",
80 203 => "Non-Authoritative Information",
81 204 => "No Content",
82 205 => "Reset Content",
83 206 => "Partial Content",
84
85 300 => "Multiple Choices",
86 301 => "Moved Permanently",
87 302 => "Found",
88 303 => "See Other",
89 304 => "Not Modified",
90 305 => "Use Proxy",
91 307 => "Temporary Redirect",
92
93 400 => "Bad Request",
94 401 => "Unauthorized",
95 402 => "Payment Required",
96 403 => "Forbidden",
97 404 => "Not Found",
98 405 => "Method Not Allowed",
99 406 => "Not Acceptable",
100 407 => "Proxy Authentication Required",
101 408 => "Request Timeout",
102 409 => "Conflict",
103 410 => "Gone",
104 411 => "Length Required",
105 412 => "Precondition Failed",
106 413 => "Request Entity Too Large",
107 414 => "Request-URI Too Long",
108 415 => "Unsupported Media Type",
109 416 => "Requested Range Not Satisfiable",
110 417 => "Expectation Failed",
111 418 => "I'm a teapot",
112
113 500 => "Internal Server Error",
114 501 => "Not Implemented",
115 502 => "Bad Gateway",
116 503 => "Service Unavailable",
117 504 => "Gateway Timeout",
118 505 => "HTTP Version Not Supported"
119 );
120
121 /**
122 * Response headers.
123 *
124 * @var Headers
125 */
126 public $headers;
127
128 /**
129 * The HTTP protocol version (1.0 or 1.1), defaults to '1.0'
130 *
131 * @var string
132 */
133 public $version = '1.0';
134
135 /**
136 * Initializes the {@link $body}, {@link $header}, {@link $date} and {@link $status}
137 * properties.
138 *
139 * @param mixed $body The body of the response.
140 * @param int $status The status code of the response.
141 * @param Headers|array $headers The initial header fields of the response.
142 */
143 public function __construct($body=null, $status=200, $headers=array())
144 {
145 if (is_array($headers))
146 {
147 $headers = new Headers($headers);
148 }
149 else if (!($headers instanceof Headers))
150 {
151 throw new \InvalidArgumentException("$headers must be an array or a ICanBoogie\HTTP\Headers instance. Given: " . gettype($headers));
152 }
153
154 $this->headers = $headers;
155
156 if (!$this->headers['Date'])
157 {
158 $this->date = 'now';
159 }
160
161 $this->set_status($status);
162 $this->set_body($body);
163 }
164
165 /**
166 * Handles read-only properties.
167 *
168 * @throws PropertyNotWritable in attempt to write one of the following properties:
169 * {@link $is_valid}, {@link $is_informational}, {@link $is_successful}, {@link $is_redirect),
170 * {@link $is_client_error}, {@link $is_server_error}, {@link $is_ok}, {@link $is_forbidden},
171 * {@link $is_not_found}, {@link $is_empty}, {@link $is_validateable}, {@link $is_cacheable},
172 * {@link $is_fresh}
173 */
174 public function __set($property, $value)
175 {
176 static $readonly = array
177 (
178 'is_valid',
179 'is_informational',
180 'is_successful',
181 'is_redirect',
182 'is_client_error',
183 'is_server_error',
184 'is_ok',
185 'is_forbidden',
186 'is_not_found',
187 'is_empty',
188 'is_validateable',
189 'is_cacheable',
190 'is_fresh'
191 );
192
193 if (in_array($property, $readonly))
194 {
195 throw new PropertyNotWritable(array($property, $this));
196 }
197
198 parent::__set($property, $value);
199 }
200
201 /**
202 * Clones the {@link $headers] property.
203 */
204 public function __clone()
205 {
206 $this->headers = clone $this->headers;
207 }
208
209 /**
210 * Renders the response as an HTTP string.
211 *
212 * @return string
213 */
214 public function __toString()
215 {
216 ob_start();
217
218 $this->echo_body($this->body);
219
220 $body = ob_get_clean();
221
222 return "HTTP/{$this->version} {$this->status} {$this->status_message}\r\n"
223 . $this->headers
224 . "\r\n"
225 . $body;
226 }
227
228 /**
229 * Issues the HTTP response.
230 *
231 * The header is modified according to the {@link version}, {@link status} and
232 * {@link status_message} properties.
233 *
234 * The usual behaviour of the response is to echo its body and then terminate the script. But
235 * if its body is `null` the following happens :
236 *
237 * - If the {@link $location} property is defined the script is terminated.
238 *
239 * - If the {@link $is_ok} property is falsy **the method returns**.
240 *
241 * Note: If the body is a string, or an object implementing the `__toString()` method, the
242 * `Content-Length` header is automatically defined to the lenght of the body string.
243 *
244 * Note: If the body is an instance of {@link Closure} it MUST echo the response's body.
245 */
246 public function __invoke()
247 {
248 #
249 # If the body is a string we add the `Content-Length`
250 #
251
252 $body = $this->body;
253
254 if (is_object($body) && method_exists($body, '__toString'))
255 {
256 $body = (string) $body;
257 }
258
259 if (is_numeric($body) || is_string($body))
260 {
261 $this->headers['Content-Length'] = strlen($body);
262 }
263
264 #
265 # send headers
266 #
267
268 if (headers_sent($file, $line))
269 {
270 trigger_error(\ICanBoogie\format
271 (
272 "Cannot modify header information because it was already sent. Output started at !at.", array
273 (
274 'at' => $file . ':' . $line
275 )
276 ));
277 }
278 else
279 {
280 header_remove('Pragma');
281 header_remove('X-Powered-By');
282
283 header("HTTP/{$this->version} {$this->status} {$this->status_message}");
284
285 $this->headers();
286 }
287
288 if ($body === null)
289 {
290 if ($this->location)
291 {
292 exit;
293 }
294 else if (!$this->is_ok)
295 {
296 return;
297 }
298 }
299
300 $this->echo_body($body);
301
302 exit;
303 }
304
305 /**
306 * Status of the HTTP response.
307 *
308 * @var int
309 */
310 private $status;
311
312 /**
313 * Message describing the status code.
314 *
315 * @var string
316 */
317 public $status_message;
318
319 /**
320 * Sets response status code and optionally status message.
321 *
322 * This method is the setter for the {@link $status} property.
323 *
324 * @param integer|array $status HTTP status code or HTTP status code and HTTP status message.
325 *
326 * @throws \InvalidArgumentException When the HTTP status code is not valid.
327 */
328 protected function set_status($status)
329 {
330 $status_message = null;
331
332 if (is_array($status))
333 {
334 list($status, $status_message) = $status;
335 }
336
337 $this->status = (int) $status;
338
339 if (!$this->is_valid)
340 {
341 throw new StatusCodeNotValid($status);
342 }
343
344 if ($status_message === null)
345 {
346 unset($this->status_message);
347 }
348 else
349 {
350 $this->status_message = $status_message;
351 }
352 }
353
354 /**
355 * Returns the response status code.
356 *
357 * This method is the getter for the {@link $status} property.
358 *
359 * @return integer
360 */
361 protected function get_status()
362 {
363 return $this->status;
364 }
365
366 /**
367 * The response body.
368 *
369 * @var mixed
370 *
371 * @see set_body(), get_body()
372 */
373 private $body;
374
375 /**
376 * Sets the response body.
377 *
378 * The body can be any data type that can be converted into a string this includes numeric and
379 * objects implementing the {@link __toString()} method.
380 *
381 * Note: This method is the setter for the {@link $body} property.
382 *
383 * @param string|\Closure|null $body
384 *
385 * @throws \UnexpectedValueException when the body cannot be converted to a string.
386 */
387 protected function set_body($body)
388 {
389 if ($body !== null
390 && !($body instanceof \Closure)
391 && !is_numeric($body)
392 && !is_string($body)
393 && !(is_object($body) && method_exists($body, '__toString')))
394 {
395 throw new \UnexpectedValueException(\ICanBoogie\format
396 (
397 'The Response body must be a string, an object implementing the __toString() method or be callable, %type given. !value', array
398 (
399 'type' => gettype($body),
400 'value' => $body
401 )
402 ));
403 }
404
405 if ($body === null)
406 {
407 $this->headers['Content-Length'] = null;
408 }
409
410 $this->body = $body;
411 }
412
413 /**
414 * Returns the response body.
415 *
416 * Note: This method is the getter for the {@link $body} property.
417 *
418 * @return string
419 */
420 protected function get_body()
421 {
422 return $this->body;
423 }
424
425 /**
426 * Echo the body.
427 *
428 * @param mixed $body
429 */
430 protected function echo_body($body)
431 {
432 if ($body instanceof \Closure)
433 {
434 $body($this);
435 }
436 else
437 {
438 echo $body;
439 }
440 }
441
442 /**
443 * Returns the message associated with the status code.
444 *
445 * This method is the volatile getter for the {@link $status_message} property and is only
446 * called if the property is not accessible.
447 *
448 * @return string
449 */
450 protected function get_status_message()
451 {
452 return self::$status_messages[$this->status];
453 }
454
455 /**
456 * Sets the value of the `Location` header field.
457 *
458 * @param string|null $url
459 */
460 protected function set_location($url)
461 {
462 if ($url !== null && !$url)
463 {
464 throw new \InvalidArgumentException('Cannot redirect to an empty URL.');
465 }
466
467 $this->headers['Location'] = $url;
468 }
469
470 /**
471 * Returns the value of the `Location` header field.
472 *
473 * @return string
474 */
475 protected function get_location()
476 {
477 return $this->headers['Location'];
478 }
479
480 /**
481 * Sets the value of the `Content-Type` header field.
482 *
483 * @param string $content_type
484 */
485 protected function set_content_type($content_type)
486 {
487 $this->headers['Content-Type'] = $content_type;
488 }
489
490 /**
491 * Returns the value of the `Content-Type` header field.
492 *
493 * @return Headers\ContentType
494 */
495 protected function get_content_type()
496 {
497 return $this->headers['Content-Type'];
498 }
499
500 /**
501 * Sets the value of the `Content-Length` header field.
502 *
503 * @param int $length
504 */
505 protected function set_content_length($length)
506 {
507 $this->headers['Content-Length'] = $length;
508 }
509
510 /**
511 * Returns the value of the `Content-Length` header field.
512 *
513 * @return int
514 */
515 protected function get_content_length()
516 {
517 return $this->headers['Content-Length'];
518 }
519
520 /**
521 * Sets the value of the `Date` header field.
522 *
523 * @param mixed $time
524 */
525 protected function set_date($time)
526 {
527 $this->headers['Date'] = $time;
528 }
529
530 /**
531 * Returns the value of the `Date` header field.
532 *
533 * @return Headers\DateHeader
534 */
535 protected function get_date()
536 {
537 return $this->headers['Date'];
538 }
539
540 /**
541 * Sets the value of the `Age` header field.
542 *
543 * @param int $age
544 */
545 protected function set_age($age)
546 {
547 $this->headers['Age'] = $age;
548 }
549
550 /**
551 * Returns the age of the response.
552 *
553 * @return int
554 */
555 protected function get_age()
556 {
557 $age = $this->headers['Age'];
558
559 if ($age)
560 {
561 return $age;
562 }
563
564 return max(time() - $this->date->format('U'), 0);
565 }
566
567 /**
568 * Sets the value of the `Last-Modified` header field.
569 *
570 * @param mixed $time.
571 */
572 protected function set_last_modified($time)
573 {
574 $this->headers['Last-Modified'] = $time;
575 }
576
577 /**
578 * Returns the value of the `Last-Modified` header field.
579 *
580 * @return Headers\DateHeader
581 */
582 protected function get_last_modified()
583 {
584 return $this->headers['Last-Modified'];
585 }
586
587 /**
588 * Sets the value of the `Expires` header field.
589 *
590 * The method also calls the {@link session_cache_expire()} function.
591 *
592 * @param mixed $time.
593 */
594 protected function set_expires($time)
595 {
596 $this->headers['Expires'] = $time;
597
598 session_cache_expire($time); // TODO-20120831: Is this required now that we have an awesome response system ?
599 }
600
601 /**
602 * Returns the value of the `Expires` header field.
603 *
604 * @return Headers\DateHeader
605 */
606 protected function get_expires()
607 {
608 return $this->headers['Expires'];
609 }
610
611 /**
612 * Sets the value of the `Etag` header field.
613 *
614 * @param string $value
615 */
616 protected function set_etag($value)
617 {
618 $this->headers['Etag'] = $value;
619 }
620
621 /**
622 * Returns the value of the `Etag` header field.
623 *
624 * @return string
625 */
626 protected function get_etag()
627 {
628 return $this->headers['Etag'];
629 }
630
631 /**
632 * Sets the directives of the `Cache-Control` header field.
633 *
634 * @param string $cache_directives
635 */
636 protected function set_cache_control($cache_directives)
637 {
638 $this->headers['Cache-Control'] = $cache_directives;
639 }
640
641 /**
642 * Returns the `Cache-Control` header field.
643 *
644 * @return \ICanBoogie\HTTP\CacheControlHeader
645 */
646 protected function get_cache_control()
647 {
648 return $this->headers['Cache-Control'];
649 }
650
651 /**
652 * Sets the response's time-to-live for shared caches.
653 *
654 * This method adjusts the Cache-Control/s-maxage directive.
655 *
656 * @param int $seconds The number of seconds.
657 */
658 protected function set_ttl($seconds)
659 {
660 $this->cache_control->s_max_age = $this->age->timestamp + $seconds;
661 }
662
663 /**
664 * Returns the response's time-to-live in seconds.
665 *
666 * When the responses TTL is <= 0, the response may not be served from cache without first
667 * revalidating with the origin.
668 *
669 * @return int|null The number of seconds to live, or `null` is no freshness information
670 * is present.
671 */
672 protected function get_ttl()
673 {
674 $max_age = $this->cache_control->max_age;
675
676 if ($max_age)
677 {
678 return $max_age - $this->age;
679 }
680 }
681
682 /**
683 * Set the `cacheable` property of the `Cache-Control` header field to `private`.
684 *
685 * @param boolean $value
686 */
687 protected function set_is_private($value)
688 {
689 $this->cache_control->cacheable = 'private';
690 }
691
692 /**
693 * Checks that the `cacheable` property of the `Cache-Control` header field is `private`.
694 *
695 * @return boolean
696 */
697 protected function get_is_private()
698 {
699 return $this->cache_control->cacheable == 'private';
700 }
701
702 /**
703 * Checks if the response is valid.
704 *
705 * A response is considered valid when its status is between 100 and 600, 100 included.
706 *
707 * Note: This method is the getter for the `is_valid` magic property.
708 *
709 * @return boolean
710 */
711 protected function get_is_valid()
712 {
713 return $this->status >= 100 && $this->status < 600;
714 }
715
716 /**
717 * Checks if the response is informational.
718 *
719 * A response is considered informational when its status is between 100 and 200, 100 included.
720 *
721 * Note: This method is the getter for the `is_informational` magic property.
722 *
723 * @return boolean
724 */
725 protected function get_is_informational()
726 {
727 return $this->status >= 100 && $this->status < 200;
728 }
729
730 /**
731 * Checks if the response is successful.
732 *
733 * A response is considered successful when its status is between 200 and 300, 200 included.
734 *
735 * Note: This method is the getter for the `is_successful` magic property.
736 *
737 * @return boolean
738 */
739 protected function get_is_successful()
740 {
741 return $this->status >= 200 && $this->status < 300;
742 }
743
744 /**
745 * Checks if the response is a redirection.
746 *
747 * A response is considered to be a redirection when its status is between 300 and 400, 300
748 * included.
749 *
750 * Note: This method is the getter for the `is_redirect` magic property.
751 *
752 * @return boolean
753 */
754 protected function get_is_redirect()
755 {
756 return $this->status >= 300 && $this->status < 400;
757 }
758
759 /**
760 * Checks if the response is a client error.
761 *
762 * A response is considered a client error when its status is between 400 and 500, 400
763 * included.
764 *
765 * Note: This method is the getter for the `is_client_error` magic property.
766 *
767 * @return boolean
768 */
769 protected function get_is_client_error()
770 {
771 return $this->status >= 400 && $this->status < 500;
772 }
773
774 /**
775 * Checks if the response is a server error.
776 *
777 * A response is considered a server error when its status is between 500 and 600, 500
778 * included.
779 *
780 * Note: This method is the getter for the `is_server_error` magic property.
781 *
782 * @return boolean
783 */
784 protected function get_is_server_error()
785 {
786 return $this->status >= 500 && $this->status < 600;
787 }
788
789 /**
790 * Checks if the response is ok.
791 *
792 * A response is considered ok when its status is 200.
793 *
794 * Note: This method is the getter for the `is_ok` magic property.
795 *
796 * @return boolean
797 */
798 protected function get_is_ok()
799 {
800 return $this->status == 200;
801 }
802
803 /**
804 * Checks if the response is forbidden.
805 *
806 * A response is forbidden ok when its status is 403.
807 *
808 * Note: This method is the getter for the `is_forbidden` magic property.
809 *
810 * @return boolean
811 */
812 protected function get_is_forbidden()
813 {
814 return $this->status == 403;
815 }
816
817 /**
818 * Checks if the response is not found.
819 *
820 * A response is considered not found when its status is 404.
821 *
822 * Note: This method is the getter for the `is_not_found` magic property.
823 *
824 * @return boolean
825 */
826 protected function get_is_not_found()
827 {
828 return $this->status == 404;
829 }
830
831 /**
832 * Checks if the response is empty.
833 *
834 * A response is considered empty when its status is 201, 204 or 304.
835 *
836 * Note: This method is the getter for the `is_empty` magic property.
837 *
838 * @return boolean
839 */
840 protected function get_is_empty()
841 {
842 static $range = array(201, 204, 304);
843
844 return in_array($this->status, $range);
845 }
846
847 /**
848 * Checks that the response includes header fields that can be used to validate the response
849 * with the origin server using a conditional GET request.
850 *
851 * @return boolean
852 */
853 protected function get_is_validateable()
854 {
855 return $this->headers['Last-Modified'] || $this->headers['ETag'];
856 }
857
858 /**
859 * Checks that the response is worth caching under any circumstance.
860 *
861 * Responses marked _private_ with an explicit `Cache-Control` directive are considered
862 * uncacheable.
863 *
864 * Responses with neither a freshness lifetime (Expires, max-age) nor cache validator
865 * (`Last-Modified`, `ETag`) are considered uncacheable.
866 *
867 * @return boolean
868 */
869 protected function get_is_cacheable()
870 {
871 static $range = array(200, 203, 300, 301, 302, 404, 410);
872
873 if (!in_array($this->status, $range))
874 {
875 return false;
876 }
877
878 if ($this->cache_control->no_store || $this->cache_control->cacheable == 'private')
879 {
880 return false;
881 }
882
883 return $this->is_validateable() || $this->is_fresh();
884 }
885
886 /**
887 * Checks if the response is fresh.
888 *
889 * @return boolean
890 */
891 protected function get_is_fresh()
892 {
893 return $this->ttl > 0;
894 }
895
896 /**
897 * @throws PropertyNotWritable in attempt to write an unsupported property.
898 *
899 * Check the following:
900 *
901 * - status_message is not writeable.
902 */
903 /*
904 protected function last_chance_set($property, $value, &$success)
905 {
906 throw new PropertyNotWritable(array($property, $this));
907 }
908 */
909 }
910
911 /**
912 * A HTTP response doing a redirect.
913 */
914 class RedirectResponse extends Response
915 {
916 /**
917 * Initializes the `Location` header.
918 *
919 * @param string $url URL to redirect to.
920 * @param int $status Status code (default to 302).
921 * @param array $headers Additional headers.
922 *
923 * @throws \InvalidArgumentException if the provided status code is not a redirect.
924 */
925 public function __construct($url, $status=302, array $headers=array())
926 {
927 parent::__construct
928 (
929 function($response) {
930
931 $location = $response->location;
932 $title = \ICanBoogie\escape($location);
933
934 echo <<<EOT
935 <!DOCTYPE html>
936 <html>
937 <head>
938 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
939 <meta http-equiv="refresh" content="1;url={$location}" />
940
941 <title>Redirecting to {$title}</title>
942 </head>
943 <body>
944 Redirecting to <a href="{$location}">{$title}</a>.
945 </body>
946 </html>
947 EOT;
948 },
949
950 $status, array('Location' => $url) + $headers
951 );
952
953 if (!$this->is_redirect)
954 {
955 throw new \InvalidArgumentException("The HTTP status code is not a redirect: {$status}.");
956 }
957 }
958 }