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