1 <?php
2 /* --------------------------------------------------------------
3 ApiController.php 2016-02-25
4 Gambio GmbH
5 http://www.gambio.de
6 Copyright (c) 2016 Gambio GmbH
7 Released under the GNU General Public License (Version 2)
8 [http://www.gnu.org/licenses/gpl-2.0.html]
9 --------------------------------------------------------------
10 */
11
12 MainFactory::load_class('AbstractApiV2Controller');
13
14 /**
15 * Class HttpApiV2Controller
16 *
17 * Contains common functionality for all the GX2 APIv2 controllers. You can use the $api instance in the
18 * child-controllers in order to gain access to request and response information. The $uri variable is an
19 * array that contains the requested resource path.
20 *
21 * You can use a protected "__initialize" method in your child controllers for performing common operations
22 * without overriding the parent constructor method.
23 *
24 * This class contains some private methods that define the core operations of each controller and should
25 * not be called from a child-controller (like validation, authorization, rate limiting). The only way to
26 * disable the execution of these methods is to override the controller.
27 *
28 * @see AbstractApiV2Controller
29 *
30 * @todo Add _cacheResponse() helper function which will cache request data and it will provide the required
31 * headers.
32 *
33 * @category System
34 * @package ApiV2Controllers
35 */
36 class HttpApiV2Controller extends AbstractApiV2Controller
37 {
38 /**
39 * Sort response array with the "sort" GET parameter.
40 *
41 * This method supports nested sort values, so by providing a "+address.street" value
42 * to the "sort" GET parameter the records will be sort by street value in ascending
43 * order. Method supports sorting up to 5 fields.
44 *
45 * Important #1:
46 * This method has some advantages and disadvantages over the classic database sort mechanism. First it
47 * does not need mapping between the API fields and the database fields. Second it does not depend on
48 * external system code to sort the response items, so if for example a domain-service does not support
49 * sorting the result can still be sorted before sent to the client. The disadvantages are that it will
50 * only support a predefined number of fields and this is a trade-off because the method should not use
51 * the "eval" function, which will introduce security risks. Furthermore it might be a bit slower than
52 * the database sorting.
53 *
54 * Important #2:
55 * This method is using PHP's array_multisort which by default will sort strings in a case sensitive
56 * manner. That means that strings starting with a capital letter will come before strings starting
57 * with a lowercase letter.
58 * http://php.net/manual/en/function.array-multisort.php
59 *
60 * Example:
61 * // will sort ascending by customer ID and descending by customer company
62 * api.php/v2/customers?sort=+id,-address.company
63 *
64 * @param array $response Passed by reference, contains an array of the multiple items
65 * that will returned as a response to the client.
66 */
67 protected function _sortResponse(array &$response)
68 {
69 if($this->api->request->get('sort') === null)
70 {
71 return; // no sort parameter was provided
72 }
73
74 $params = explode(',', $this->api->request->get('sort'));
75
76 for($i = 0; $i < 5; $i++)
77 {
78 $sort[$i] = array(
79 'array' => array_fill(0, count($response), ''),
80 'direction' => SORT_ASC // default
81 );
82 }
83
84 foreach($params as $paramIndex => &$param)
85 {
86 $fields = explode('.', substr($param, 1));
87
88 foreach($response as $itemIndex => $item)
89 {
90 $value = $item;
91 foreach($fields as $field)
92 {
93 $value = $value[$field];
94 }
95
96 $sort[$paramIndex]['direction'] = (substr($param, 0, 1) === '-') ? SORT_DESC : SORT_ASC;
97 $sort[$paramIndex]['array'][$itemIndex] = $value;
98 }
99 }
100
101 // Multisort array (currently supports up to 5 sort fields).
102 array_multisort($sort[0]['array'], $sort[0]['direction'], $sort[1]['array'], $sort[1]['direction'],
103 $sort[2]['array'], $sort[2]['direction'], $sort[3]['array'], $sort[3]['direction'],
104 $sort[4]['array'], $sort[4]['direction'], $response);
105 }
106
107
108 /**
109 * Minimize response using the $fields parameter.
110 *
111 * APIv2 supports the GET "fields" parameter which enables the client to select the
112 * exact fields to be included in the response. It does not support nested fields,
113 * only first-level.
114 *
115 * You can provide both associative (single response item) or sequential (multiple response
116 * items) arrays and this method will adjust the links accordingly.
117 *
118 * @param array $response Passed by reference, it will be minified to the required fields.
119 */
120 protected function _minimizeResponse(array &$response)
121 {
122 if($this->api->request->get('fields') === null)
123 {
124 return; // no minification parameter was provided
125 }
126
127 $fields = explode(',', $this->api->request->get('fields'));
128 $map = array();
129 foreach($fields as $field)
130 {
131 $field = array_shift(explode('.', $field)); // take only the first field
132 $map[$field] = array();
133 }
134
135 // If $response array is associative then converted to sequential array.
136 $revertBackToAssociative = false;
137 if(key($response) !== 0 && !is_array($response[0]))
138 {
139 $response = array($response);
140 $revertBackToAssociative = true;
141 }
142
143 // Minimize all the items.
144 foreach($response as &$item)
145 {
146 $item = array_intersect_key($item, $map);
147 }
148
149 // Revert back to associative (if necessary).
150 if($revertBackToAssociative)
151 {
152 $response = $response[0];
153 }
154 }
155
156
157 /**
158 * Paginate response using the $page and $per_page GET parameters.
159 *
160 * One of the common functionalities of the APIv2 is the pagination and this can be
161 * easily achieved by this function which will update the response with the records
162 * that need to be returned. This method will automatically set the pagination headers
163 * in the response so that client apps can easily navigate through results.
164 *
165 * @param array $response Passed by reference, it will be paginated according to the provided parameters.
166 */
167 protected function _paginateResponse(array &$response)
168 {
169 if($this->api->request->get('page') === null)
170 {
171 return; // no pagination parameter was provided
172 }
173
174 $limit = ($this->api->request->get('per_page')
175 !== null) ? $this->api->request->get('per_page') : self::DEFAULT_PAGE_ITEMS;
176 $offset = $limit * ((int)$this->api->request->get('page') - 1);
177 $totalItemCount = count($response);
178 $this->_setPaginationHeader($this->api->request->get('page'), $limit, $totalItemCount);
179 $response = array_slice($response, $offset, $limit);
180 }
181
182
183 /**
184 * Include links to response resources.
185 *
186 * The APIv2 operates with simple resources that might be linked with other resources. This
187 * architecture promotes flexibility so that API consumers can have a simpler structure. This
188 * method will search for existing external resources and will add a link to the end of each
189 * resource.
190 *
191 * IMPORTANT: If for some reason you need to include custom links to your resources
192 * do not use this method. Include them inside your controller method manually.
193 *
194 * NOTICE #1: This method will only search at the first level of the resource. That means that
195 * nested ID values will not be taken into concern.
196 *
197 * NOTICE #2: You can provide both associative (single response item) or sequential (multiple response
198 * items) arrays and this method will adjust the links accordingly.
199 *
200 * @param array $response Passed by reference, new links will be appended into the end
201 * of each resource.
202 */
203 protected function _linkResponse(array &$response)
204 {
205 if($this->api->request->get('disable_links') !== null || count($response) === 0)
206 {
207 return; // client does not require links
208 }
209
210 // Define the link mappings to the resources.
211 $map = array(
212 'customerId' => 'customers',
213 'addressId' => 'addresses',
214 'countryId' => 'countries',
215 'zoneId' => 'zones',
216 'ordersId' => 'orders'
217 );
218
219 // If $response array is associative then converted to sequential array.
220 $revertBackToAssociative = false;
221 if(key($response) !== 0 && !is_array($response[0]))
222 {
223 $response = array($response);
224 $revertBackToAssociative = true;
225 }
226
227 // Parse the resource results and add the links.
228 foreach($response as &$item)
229 {
230 $links = array(); // will be appended to each resource
231
232 foreach($map as $key => $resource)
233 {
234 if(array_key_exists($key, $item) && $item[$key] !== null)
235 {
236 $links[str_replace('Id', '', $key)] = GM_HTTP_SERVER . $this->api->request->getRootUri() . '/v2/'
237 . $resource . '/' . $item[$key];
238 }
239 }
240
241 $item['_links'] = $links;
242 }
243
244 if($revertBackToAssociative)
245 {
246 $response = $response[0];
247 }
248 }
249
250
251 /**
252 * Write JSON encoded response data.
253 *
254 * Use this method to write a JSON encoded, pretty printed and unescaped response to
255 * the client consumer. It is very important that the API provides pretty printed responses
256 * because it is easier for users to debug and develop.
257 *
258 * IMPORTANT: PHP v5.3 does not support the JSON_PRETTY_PRINT and JSON_UNESCAPED_SLASHES so
259 * this method will check for their existance and then use them if possible.
260 *
261 * @param array $response Contains the response data to be written.
262 * @param int $p_statusCode (optional) Provide a custom status code for the response, default 200 - Success.
263 */
264 protected function _writeResponse(array $response, $p_statusCode = 200)
265 {
266 if($p_statusCode !== 200 && is_numeric($p_statusCode))
267 {
268 $this->api->response->setStatus((int)$p_statusCode);
269 }
270
271 if(defined('JSON_PRETTY_PRINT') && defined('JSON_UNESCAPED_SLASHES'))
272 {
273 $responseJsonString = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
274 }
275 else
276 {
277 $responseJsonString = json_encode($response); // PHP v5.3
278 }
279
280 $this->api->response->write($responseJsonString);
281 }
282
283
284 /**
285 * Map the sub-resource to another controller.
286 *
287 * Some API resources contain many subresources which makes the creation of a single
288 * controller class complicated and hard to maintain. This method will forward the
289 * request to a another controller by checking the provided criteria.
290 *
291 * Example:
292 *
293 * $criteria = array(
294 * 'items' => 'OrdersItemsAttributesApiV2Controller',
295 * 'totals' => 'OrdersTotalsApiV2Controller'
296 * );
297 *
298 * Notice: Each controller should map a direct subresource and not deeper ones. This way
299 * every API controller is responsible to map its direct subresources.
300 *
301 *
302 * @param array $criteria An array containing the mapping criteria.
303 *
304 * @return bool Returns whether the request was eventually mapped.
305 *
306 * @throws HttpApiV2Exception If the subresource is not supported by the API.
307 */
308 protected function _mapResponse(array $criteria)
309 {
310 $result = false;
311
312 foreach($criteria as $subresource => $class)
313 {
314 for($i = count($this->uri) - 1; $i > 0; $i--)
315 {
316 if($subresource === $this->uri[$i])
317 {
318 $controller = MainFactory::create($class, $this->api, $this->uri);
319 $method = strtolower($this->api->request->getMethod());
320 $resource = array($controller, $method);
321
322 if(!is_callable($resource))
323 {
324 throw new HttpApiV2Exception('The requested subresource is not supported by the API v2.', 400);
325 }
326
327 call_user_func($resource);
328
329 $result = true;
330 break 2; // Exit both loops.
331 }
332 }
333 }
334
335 return $result;
336 }
337
338
339 /**
340 * Perform a search on the response array.
341 *
342 * Normally the best way to filter the results is through the corresponding service but some times
343 * there is not specific method for searching the requested resource or subresource. When this is
344 * the case use this method to filter the results of the response before returning them back to the
345 * client.
346 *
347 * @param array $response Contains the response data to be written.
348 * @param string $p_keyword The keyword to be used for the search.
349 *
350 * @throws InvalidArgumentException If search keyword parameter is not a string.
351 */
352 protected function _searchResponse(array &$response, $p_keyword)
353 {
354 if(!is_string($p_keyword))
355 {
356 throw new InvalidArgumentException('Invalid argument provided (expected string got ' . gettype($p_keyword)
357 . '): ' . $p_keyword);
358 }
359
360 if($p_keyword === '')
361 {
362 return; // do not perform the search
363 }
364
365 $searchResults = array();
366
367 foreach($response as $item)
368 {
369 if(!is_array($item))
370 {
371 continue;
372 }
373
374 foreach($item as $key => $value)
375 {
376 if((is_string($value) || is_numeric($value)) && preg_match('/' . $p_keyword . '/i', $value) === 1)
377 {
378 $searchResults[] = $item;
379 break;
380 }
381 }
382 }
383
384 $response = $searchResults;
385 }
386
387
388 /**
389 * Add location header to a specific response.
390 *
391 * Use this method whenever you want the "Location" header to point to an existing resource so that
392 * clients can use it to fetch that resource without having to generate the URL themselves.
393 *
394 * @param string $p_name
395 * @param int $p_id
396 *
397 * @throws InvalidArgumentException If the arguments contain an invalid value.
398 */
399 protected function _locateResource($p_name, $p_id)
400 {
401 if(!is_string($p_name))
402 {
403 throw new InvalidArgumentException('Invalid argument provided (expected string got ' . gettype($p_name)
404 . '): ' . $p_name);
405 }
406
407 if(!is_numeric($p_id))
408 {
409 throw new InvalidArgumentException('Invalid argument provided (expected int got ' . gettype($p_id) . '): '
410 . $p_id);
411 }
412
413 $this->api->response->header('Location',
414 $this->api->request->getUrl() . $this->api->request->getRootUri() . '/v2/'
415 . $p_name . '/' . $p_id);
416 }
417
418
419 /**
420 * [PRIVATE] Set header pagination links.
421 *
422 * Useful for GET responses that return multiple items to the client. The client
423 * can use the links to navigate through the records without having to construct
424 * them on its own.
425 *
426 * @link http://www.w3.org/wiki/LinkHeader
427 *
428 * Not available to child-controllers (private method).
429 *
430 * @param int $p_currentPage Current request page number.
431 * @param int $p_itemsPerPage The number of items to be returned in each page.
432 * @param int $p_totalItemCount Total number of the resource items.
433 *
434 * @throws HttpApiV2Exception If one of the parameters are invalid.
435 */
436 private function _setPaginationHeader($p_currentPage, $p_itemsPerPage, $p_totalItemCount)
437 {
438 if($p_itemsPerPage <= 0)
439 {
440 throw new HttpApiV2Exception('Items per page number must not be below 1.', 400);
441 }
442
443 $totalPages = ceil($p_totalItemCount / $p_itemsPerPage);
444 $linksArray = array();
445 $baseLinkUri = HTTP_SERVER . $this->api->request->getRootUri() . $this->api->request->getResourceUri();
446 $getParams = $this->api->request->get();
447
448 if($p_currentPage > 1)
449 {
450 $getParams['page'] = 1;
451 $linksArray['first'] = '<' . $baseLinkUri . '?' . http_build_query($getParams) . '>; rel="first"';
452
453 $getParams['page'] = $p_currentPage - 1;
454 $linksArray['previous'] = '<' . $baseLinkUri . '?' . http_build_query($getParams) . '>; rel="previous"';
455 }
456
457 if($p_currentPage < $totalPages)
458 {
459 $getParams['page'] = $p_currentPage + 1;
460 $linksArray['next'] = '<' . $baseLinkUri . '?' . http_build_query($getParams) . '>; rel="next"';
461
462 $getParams['page'] = $totalPages;
463 $linksArray['last'] = '<' . $baseLinkUri . '?' . http_build_query($getParams) . '>; rel="last"';
464 }
465
466 $this->api->response->headers->set('Link', implode(',' . PHP_EOL, $linksArray));
467 }
468
469
470 /**
471 * @param string $jsonString The json formatted string which should be updated.
472 * @param string $property The name or key of the property which should be updated.
473 * @param string $value The new value which should be set.
474 *
475 * @return string The updated json formatted string.
476 */
477 protected function _setJsonValue($jsonString, $property, $value)
478 {
479 $json = json_decode($jsonString);
480
481 $json->$property = $value;
482
483 return json_encode($json);
484 }
485 }