      1: <?php
      2: /**
      3:  * Copyright 2012-2014 Rackspace US, Inc.
      4:  *
      5:  * Licensed under the Apache License, Version 2.0 (the "License");
      6:  * you may not use this file except in compliance with the License.
      7:  * You may obtain a copy of the License at
      8:  *
      9:  * http://www.apache.org/licenses/LICENSE-2.0
     10:  *
     11:  * Unless required by applicable law or agreed to in writing, software
     12:  * distributed under the License is distributed on an "AS IS" BASIS,
     13:  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14:  * See the License for the specific language governing permissions and
     15:  * limitations under the License.
     16:  */
     17:
     18: namespace OpenCloud\Common\Collection;
     19:
     20: use Guzzle\Http\Exception\ClientErrorResponseException;
     21: use Guzzle\Http\Url;
     22: use Iterator;
     23: use OpenCloud\Common\Http\Message\Formatter;
     24:
     25: /**
     26:  * Class ResourceIterator is tasked with iterating over resource collections - many of which are paginated. Based on
     27:  * a base URL, the iterator will append elements based on further requests to the API. Each time this happens,
     28:  * query parameters (marker) are updated based on the current value.
     29:  *
     30:  * @package OpenCloud\Common\Collection
     31:  * @since   1.8.0
     32:  */
     33: class PaginatedIterator extends ResourceIterator implements Iterator
     34: {
     35:     const MARKER = 'marker';
     36:     const LIMIT = 'limit';
     37:
     38:     /**
     39:      * @var string Used for requests which append elements.
     40:      */
     41:     protected $currentMarker;
     42:
     43:     /**
     44:      * @var \Guzzle\Http\Url The next URL for pagination
     45:      */
     46:     protected $nextUrl;
     47:
     48:     protected $defaults = array(
     49:         // Collection limits
     50:         'limit.total'           => 10000,
     51:         'limit.page'            => 100,
     52:
     53:         // The "links" element key in response
     54:         'key.links'             => 'links',
     55:
     56:         // JSON structure
     57:         'key.collection'        => null,
     58:         'key.collectionElement' => null,
     59:
     60:         // The property used as the marker
     61:         'key.marker'            => 'name',
     62:
     63:         // Options for "next page" request
     64:         'request.method'        => 'GET',
     65:         'request.headers'       => array(),
     66:         'request.body'          => null,
     67:         'request.curlOptions'   => array()
     68:     );
     69:
     70:     protected $required = array('resourceClass', 'baseUrl');
     71:
     72:     /**
     73:      * Basic factory method to easily instantiate a new ResourceIterator.
     74:      *
     75:      * @param       $parent  The parent object
     76:      * @param array $options Iterator options
     77:      * @param array $data    Optional data to set initially
     78:      * @return static
     79:      */
     80:     public static function factory($parent, array $options = array(), array $data = null)
     81:     {
     82:         $list = new static();
     83:
     84:         $list->setOptions($list->parseOptions($options))
     85:             ->setResourceParent($parent)
     86:             ->rewind();
     87:
     88:         if ($data) {
     89:             $list->setElements($data);
     90:         } else {
     91:             $list->appendNewCollection();
     92:         }
     93:
     94:         return $list;
     95:     }
     96:
     97:
     98:     /**
     99:      * @param Url $url
    100:      * @return $this
    101:      */
    102:     public function setBaseUrl(Url $url)
    103:     {
    104:         $this->baseUrl = $url;
    105:
    106:         return $this;
    107:     }
    108:
    109:     public function current()
    110:     {
    111:         return parent::current();
    112:     }
    113:
    114:     public function key()
    115:     {
    116:         return parent::key();
    117:     }
    118:
    119:     /**
    120:      * {@inheritDoc}
    121:      * Also update the current marker.
    122:      */
    123:     public function next()
    124:     {
    125:         if (!$this->valid()) {
    126:             return false;
    127:         }
    128:
    129:         $current = $this->current();
    130:
    131:         $this->position++;
    132:         $this->updateMarkerToCurrent();
    133:
    134:         return $current;
    135:     }
    136:
    137:     /**
    138:      * Update the current marker based on the current element. The marker will be based on a particular property of this
    139:      * current element, so you must retrieve it first.
    140:      */
    141:     public function updateMarkerToCurrent()
    142:     {
    143:         if (!isset($this->elements[$this->position])) {
    144:             return;
    145:         }
    146:
    147:         $element = $this->elements[$this->position];
    148:
    149:         $key = $this->getOption('key.marker');
    150:
    151:         if (isset($element->$key)) {
    152:             $this->currentMarker = $element->$key;
    153:         }
    154:     }
    155:
    156:     /**
    157:      * {@inheritDoc}
    158:      * Also reset current marker.
    159:      */
    160:     public function rewind()
    161:     {
    162:         parent::rewind();
    163:         $this->currentMarker = null;
    164:     }
    165:
    166:     public function valid()
    167:     {
    168:         if ($this->getOption('limit.total') !== false && $this->position >= $this->getOption('limit.total')) {
    169:             return false;
    170:         } elseif (isset($this->elements[$this->position])) {
    171:             return true;
    172:         } elseif ($this->shouldAppend() === true) {
    173:             $before = $this->count();
    174:             $this->appendNewCollection();
    175:
    176:             return ($this->count() > $before) ? true : false;
    177:         }
    178:
    179:         return false;
    180:     }
    181:
    182:     protected function shouldAppend()
    183:     {
    184:         return $this->currentMarker && $this->position % $this->getOption('limit.page') == 0;
    185:     }
    186:
    187:     /**
    188:      * Append an array of standard objects to the current collection.
    189:      *
    190:      * @param array $elements
    191:      * @return $this
    192:      */
    193:     public function appendElements(array $elements)
    194:     {
    195:         $this->elements = array_merge($this->elements, $elements);
    196:
    197:         return $this;
    198:     }
    199:
    200:     /**
    201:      * Retrieve a new page of elements from the API (based on a new request), parse its response, and append them to the
    202:      * collection.
    203:      *
    204:      * @return $this|bool
    205:      */
    206:     public function appendNewCollection()
    207:     {
    208:         $request = $this->resourceParent
    209:             ->getClient()
    210:             ->createRequest(
    211:                 $this->getOption('request.method'),
    212:                 $this->constructNextUrl(),
    213:                 $this->getOption('request.headers'),
    214:                 $this->getOption('request.body'),
    215:                 $this->getOption('request.curlOptions')
    216:             );
    217:
    218:         try {
    219:             $response = $request->send();
    220:         } catch (ClientErrorResponseException $e) {
    221:             return false;
    222:         }
    223:
    224:         if (!($body = Formatter::decode($response)) || $response->getStatusCode() == 204) {
    225:             return false;
    226:         }
    227:
    228:         $this->nextUrl = $this->extractNextLink($body);
    229:
    230:         return $this->appendElements($this->parseResponseBody($body));
    231:     }
    232:
    233:     /**
    234:      * Based on the response body, extract the explicitly set "link" value if provided.
    235:      *
    236:      * @param $body
    237:      * @return bool
    238:      */
    239:     public function extractNextLink($body)
    240:     {
    241:         $key = $this->getOption('key.links');
    242:
    243:         $value = null;
    244:
    245:         if (isset($body->$key)) {
    246:             foreach ($body->$key as $link) {
    247:                 if (isset($link->rel) && $link->rel == 'next') {
    248:                     $value = $link->href;
    249:                     break;
    250:                 }
    251:             }
    252:         }
    253:
    254:         return $value;
    255:     }
    256:
    257:     /**
    258:      * Make the next page URL.
    259:      *
    260:      * @return Url|string
    261:      */
    262:     public function constructNextUrl()
    263:     {
    264:         if (!$url = $this->nextUrl) {
    265:
    266:             $url = clone $this->getOption('baseUrl');
    267:             $query = $url->getQuery();
    268:
    269:             if (isset($this->currentMarker)) {
    270:                 $query[static::MARKER] = $this->currentMarker;
    271:             }
    272:
    273:             if (($limit = $this->getOption('limit.page')) && !$query->hasKey(static::LIMIT)) {
    274:                 $query[static::LIMIT] = $limit;
    275:             }
    276:
    277:             $url->setQuery($query);
    278:         }
    279:
    280:         return $url;
    281:     }
    282:
    283:     /**
    284:      * Based on the response from the API, parse it for the data we need (i.e. an meaningful array of elements).
    285:      *
    286:      * @param $body
    287:      * @return array
    288:      */
    289:     public function parseResponseBody($body)
    290:     {
    291:         $collectionKey = $this->getOption('key.collection');
    292:
    293:         $data = array();
    294:
    295:         if (is_array($body)) {
    296:             $data = $body;
    297:         } elseif (isset($body->$collectionKey)) {
    298:             if (null !== ($elementKey = $this->getOption('key.collectionElement'))) {
    299:                 // The object has element levels which need to be iterated over
    300:                 foreach ($body->$collectionKey as $item) {
    301:                     $subValues = $item->$elementKey;
    302:                     unset($item->$elementKey);
    303:                     $data[] = array_merge((array) $item, (array) $subValues);
    304:                 }
    305:             } else {
    306:                 // The object has a top-level collection name only
    307:                 $data = $body->$collectionKey;
    308:             }
    309:         }
    310:
    311:         return $data;
    312:     }
    313:
    314:     /**
    315:      * Walk the entire collection, populating everything.
    316:      */
    317:     public function populateAll()
    318:     {
    319:         while ($this->valid()) {
    320:             $this->next();
    321:         }
    322:     }
    323: }
    324: