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 /**
15 * HTTP Header field definitions.
16 *
17 * Instances of this class are used to collect and manipulate HTTP header field definitions.
18 * Header field instances are used to handle the definition of complex header fields such as
19 * `Content-Type` and `Cache-Control`. For instance a {@link Headers\CacheControl} instance
20 * is used to handle the directives of the `Cache-Control` header field.
21 *
22 * @see http://tools.ietf.org/html/rfc2616#section-14
23 */
24 class Headers implements \ArrayAccess, \IteratorAggregate
25 {
26 static private $mapping = array
27 (
28 'Cache-Control' => 'ICanBoogie\HTTP\CacheControlHeader',
29 'Content-Disposition' => 'ICanBoogie\HTTP\ContentDispositionHeader',
30 'Content-Type' => 'ICanBoogie\HTTP\ContentTypeHeader',
31 'Date' => 'ICanBoogie\HTTP\DateHeader',
32 'Expires' => 'ICanBoogie\HTTP\DateHeader',
33 'If-Modified-Since' => 'ICanBoogie\HTTP\DateHeader',
34 'If-Unmodified-Since' => 'ICanBoogie\HTTP\DateHeader',
35 'Last-Modified' => 'ICanBoogie\HTTP\DateHeader'
36 );
37
38 /**
39 * Header fields.
40 *
41 * @var array[string]mixed
42 */
43 protected $fields = array();
44
45 /**
46 * If the `REQUEST_URI` key is found in the header fields they are considered coming from the
47 * super global `$_SERVER` array in which case they are filtered to keep only keys
48 * starting with the `HTTP_` prefix. Also, header field names are normalized. For instance,
49 * `HTTP_CONTENT_TYPE` becomes `Content-Type`.
50 *
51 * @param array $headers The initial headers.
52 */
53 public function __construct(array $fields=array())
54 {
55 if (isset($fields['REQUEST_URI']))
56 {
57 foreach ($fields as $field => $value)
58 {
59 if (strpos($field, 'HTTP_') !== 0)
60 {
61 continue;
62 }
63
64 $field = strtr(substr($field, 5), '_', '-');
65 $field = mb_convert_case($field, MB_CASE_TITLE);
66 $this[$field] = $value;
67 }
68 }
69 else
70 {
71 foreach ($fields as $field => $value)
72 {
73 if (strpos($field, 'HTTP_') === 0)
74 {
75 $field = strtr(substr($field, 5), '_', '-');
76 $field = mb_convert_case($field, MB_CASE_TITLE);
77 }
78
79 $this[$field] = $value;
80 }
81 }
82 }
83
84 /**
85 * Returns the header as a string.
86 *
87 * Header fields with empty string values are discarted.
88 *
89 * @return string
90 */
91 public function __toString()
92 {
93 $header = '';
94
95 foreach ($this->fields as $field => $value)
96 {
97 $value = (string) $value;
98
99 if ($value === '')
100 {
101 continue;
102 }
103
104 $header .= "$field: $value\r\n";
105 }
106
107 return $header;
108 }
109
110 /**
111 * Sends header fields using the {@link header()} function.
112 *
113 * Header fields with empty string values are discarted.
114 */
115 public function __invoke()
116 {
117 foreach ($this->fields as $field => $value)
118 {
119 $value = (string) $value;
120
121 if ($value === '')
122 {
123 continue;
124 }
125
126 header("$field: $value");
127 }
128 }
129
130 /**
131 * Checks if a header field exists.
132 *
133 * @param mixed $field
134 *
135 * @return boolean
136 */
137 public function offsetExists($field)
138 {
139 return isset($this->fields[(string) $field]);
140 }
141
142 /**
143 * Returns a header.
144 *
145 * @param mixed $field
146 *
147 * @return string|null The header field value or null if it is not defined.
148 */
149 public function offsetGet($field)
150 {
151 if (isset(self::$mapping[$field]))
152 {
153 if (empty($this->fields[$field]))
154 {
155 $class = self::$mapping[$field];
156 $this->fields[$field] = call_user_func($class . '::from', null);
157 }
158
159 return $this->fields[$field];
160 }
161
162 return $this->offsetExists($field) ? $this->fields[$field] : null;
163 }
164
165 /**
166 * Sets a header field.
167 *
168 * Note: Setting a header field to `null` removes it, just like unset() would.
169 *
170 * ## Date, Expires, Last-Modified
171 *
172 * The `Date`, `Expires` and `Last-Modified` header fields can be provided as a Unix
173 * timestamp, a string or a {@link \DateTime} object.
174 *
175 * ## Cache-Control, Content-Disposition and Content-Type
176 *
177 * Instances of the {@link Headers\CacheControl}, {@link Headers\ContentDisposition} and
178 * {@link Headers\ContentType} are used to handle the values of the `Cache-Control`,
179 * `Content-Disposition` and `Content-Type` header fields.
180 *
181 * @param string $field The header field to set.
182 * @param mixed $value The value of the header field.
183 */
184 public function offsetSet($field, $value)
185 {
186 if ($value === null)
187 {
188 unset($this[$field]);
189
190 return;
191 }
192
193 switch ($field)
194 {
195 # http://tools.ietf.org/html/rfc2616#section-14.25
196 case 'If-Modified-Since':
197 {
198 #
199 # Removes the ";length=xxx" string added by Internet Explorer.
200 # http://stackoverflow.com/questions/12626699/if-modified-since-http-header-passed-by-ie9-includes-length
201 #
202
203 if (is_string($value))
204 {
205 $pos = strpos($value, ';');
206
207 if ($pos)
208 {
209 $value = substr($value, 0, $pos);
210 }
211 }
212 }
213 break;
214
215 # http://tools.ietf.org/html/rfc2616#section-14.37
216 case 'Retry-After':
217 {
218 $value = is_numeric($value) ? $value : DateHeader::from($value);
219 }
220 break;
221 }
222
223 if (isset(self::$mapping[$field]))
224 {
225 $value = call_user_func(self::$mapping[$field] . '::from', $value);
226 }
227
228 $this->fields[$field] = $value;
229 }
230
231 /**
232 * Removes a header field.
233 */
234 public function offsetUnset($field)
235 {
236 unset($this->fields[$field]);
237 }
238
239 /**
240 * Returns an iterator for the header fields.
241 */
242 public function getIterator()
243 {
244 return new \ArrayIterator($this->fields);
245 }
246 }