1: <?php
2: /**
3: * PHP OpenCloud library
4: *
5: * @copyright 2013 Rackspace Hosting, Inc. See LICENSE for information.
6: * @license https://www.apache.org/licenses/LICENSE-2.0
7: * @author Glen Campbell <glen.campbell@rackspace.com>
8: * @author Jamie Hannaford <jamie.hannaford@rackspace.com>
9: */
10:
11: namespace OpenCloud\Common;
12:
13: use Guzzle\Http\Exception\BadResponseException;
14: use Guzzle\Http\Message\Response;
15: use Guzzle\Http\Url;
16: use OpenCloud\Common\Constants\State as StateConst;
17: use OpenCloud\Common\Service\AbstractService;
18: use OpenCloud\Common\Exceptions\RuntimeException;
19: use OpenCloud\Common\Http\Message\Formatter;
20:
21: /**
22: * Represents an object that can be retrieved, created, updated and deleted.
23: *
24: * This class abstracts much of the common functionality between:
25: *
26: * * Nova servers;
27: * * Swift containers and objects;
28: * * DBAAS instances;
29: * * Cinder volumes;
30: * * and various other objects that:
31: * * have a URL;
32: * * can be created, updated, deleted, or retrieved;
33: * * use a standard JSON format with a top-level element followed by
34: * a child object with attributes.
35: *
36: * In general, you can create a persistent object class by subclassing this
37: * class and defining some protected, static variables:
38: *
39: * * $url_resource - the sub-resource value in the URL of the parent. For
40: * example, if the parent URL is `http://something/parent`, then setting this
41: * value to "another" would result in a URL for the persistent object of
42: * `http://something/parent/another`.
43: *
44: * * $json_name - the top-level JSON object name. For example, if the
45: * persistent object is represented by `{"foo": {"attr":value, ...}}`, then
46: * set $json_name to "foo".
47: *
48: * * $json_collection_name - optional; this value is the name of a collection
49: * of the persistent objects. If not provided, it defaults to `json_name`
50: * with an appended "s" (e.g., if `json_name` is "foo", then
51: * `json_collection_name` would be "foos"). Set this value if the collection
52: * name doesn't follow this pattern.
53: *
54: * * $json_collection_element - the common pattern for a collection is:
55: * `{"collection": [{"attr":"value",...}, {"attr":"value",...}, ...]}`
56: * That is, each element of the array is a \stdClass object containing the
57: * object's attributes. In rare instances, the objects in the array
58: * are named, and `json_collection_element` contains the name of the
59: * collection objects. For example, in this JSON response:
60: * `{"allowedDomain":[{"allowedDomain":{"name":"foo"}}]}`,
61: * `json_collection_element` would be set to "allowedDomain".
62: *
63: * The PersistentObject class supports the standard CRUD methods; if these are
64: * not needed (i.e. not supported by the service), the subclass should redefine
65: * these to call the `noCreate`, `noUpdate`, or `noDelete` methods, which will
66: * trigger an appropriate exception. For example, if an object cannot be created:
67: *
68: * function create($params = array())
69: * {
70: * $this->noCreate();
71: * }
72: */
73: abstract class PersistentObject extends Base
74: {
75:
76: private $service;
77: private $parent;
78: protected $metadata;
79:
80: /**
81: * Retrieves the instance from persistent storage
82: *
83: * @param mixed $service The service object for this resource
84: * @param mixed $info The ID or array/object of data
85: */
86: public function __construct($service = null, $info = null)
87: {
88: if ($service instanceof AbstractService) {
89: $this->setService($service);
90: }
91:
92: $this->metadata = new Metadata;
93:
94: $this->populate($info);
95: }
96:
97: /**
98: * Sets the service associated with this resource object.
99: *
100: * @param OpenCloud\Common\Service\AbstractService $service
101: */
102: public function setService(AbstractService $service)
103: {
104: $this->service = $service;
105: return $this;
106: }
107:
108: /**
109: * Returns the service object for this resource; required for making
110: * requests, etc. because it has direct access to the Connection.
111: *
112: * @return OpenCloud\Common\Service\AbstractService
113: */
114: public function getService()
115: {
116: if (null === $this->service) {
117: throw new Exceptions\ServiceException(
118: 'No service defined'
119: );
120: }
121: return $this->service;
122: }
123:
124: /**
125: * Set the parent object for this resource.
126: *
127: * @param \OpenCloud\Common\PersistentObject $parent
128: */
129: public function setParent(PersistentObject $parent)
130: {
131: $this->parent = $parent;
132: return $this;
133: }
134:
135: /**
136: * Returns the parent.
137: *
138: * @return \OpenCloud\Common\PersistentObject
139: */
140: public function getParent()
141: {
142: if (null === $this->parent) {
143: $this->parent = $this->getService();
144: }
145: return $this->parent;
146: }
147:
148: public function getClient()
149: {
150: return $this->getService()->getClient();
151: }
152:
153: public function setMetadata($metadata)
154: {
155: $this->metadata = $metadata;
156:
157: return $this;
158: }
159:
160: public function getMetadata()
161: {
162: return $this->metadata;
163: }
164:
165: /**
166: * Creates a new object
167: *
168: * @param array $params array of values to set when creating the object
169: * @return HttpResponse
170: * @throws VolumeCreateError if HTTP status is not Success
171: */
172: public function create($params = array())
173: {
174: // set parameters
175: if (!empty($params)) {
176: $this->populate($params, false);
177: }
178:
179: // construct the JSON
180: $json = json_encode($this->createJson());
181: $this->checkJsonError();
182:
183: $createUrl = $this->createUrl();
184:
185: $response = $this->getClient()->post($createUrl, array(), $json)->send();
186:
187: // We have to try to parse the response body first because it should have precedence over a Location refresh.
188: // I'd like to reverse the order, but Nova instances return ephemeral properties on creation which are not
189: // available when you follow the Location link...
190: if (null !== ($decoded = $this->parseResponse($response))) {
191: $this->populate($decoded);
192: } elseif ($location = $response->getHeader('Location')) {
193: $this->refreshFromLocationUrl($location);
194: }
195:
196: return $response;
197: }
198:
199: public function refreshFromLocationUrl($url)
200: {
201: $fullUrl = Url::factory($url);
202:
203: $response = $this->getClient()->get($fullUrl)->send();
204:
205: if (null !== ($decoded = $this->parseResponse($response))) {
206: $this->populate($decoded);
207: }
208: }
209:
210: /**
211: * Updates an existing object
212: *
213: * @api
214: * @param array $params array of values to set when updating the object
215: * @return HttpResponse
216: * @throws VolumeCreateError if HTTP status is not Success
217: */
218: public function update($params = array())
219: {
220: // set parameters
221: if (!empty($params)) {
222: $this->populate($params);
223: }
224:
225: // debug
226: $this->getLogger()->info('{class}::Update({name})', array(
227: 'class' => get_class($this),
228: 'name' => $this->getProperty($this->primaryKeyField())
229: ));
230:
231: // construct the JSON
232: $json = json_encode($this->updateJson($params));
233: $this->checkJsonError();
234:
235: // send the request
236: return $this->getClient()->put($this->getUrl(), array(), $json)->send();
237: }
238:
239: /**
240: * Refreshes the object from the origin (useful when the server is
241: * changing states)
242: *
243: * @return void
244: * @throws IdRequiredError
245: */
246: public function refresh($id = null, $url = null)
247: {
248: $primaryKey = $this->primaryKeyField();
249: $primaryKeyVal = $this->getProperty($primaryKey);
250:
251: if (!$url) {
252:
253: if (!$id = $id ?: $primaryKeyVal) {
254: throw new Exceptions\IdRequiredError(sprintf(
255: Lang::translate("%s has no %s; cannot be refreshed"),
256: get_class($this),
257: $primaryKey
258: ));
259: }
260:
261: if ($primaryKeyVal != $id) {
262: $this->setProperty($primaryKey, $id);
263: }
264:
265: $url = $this->getUrl();
266: }
267:
268: // reset status, if available
269: if ($this->getProperty('status')) {
270: $this->setProperty('status', null);
271: }
272:
273: $response = $this->getClient()->get($url)->send();
274:
275: if (null !== ($decoded = $this->parseResponse($response))) {
276: $this->populate($decoded);
277: }
278:
279: return $response;
280: }
281:
282: /**
283: * Deletes an object
284: *
285: * @api
286: * @return HttpResponse
287: * @throws DeleteError if HTTP status is not Success
288: */
289: public function delete()
290: {
291: $this->getLogger()->info('{class}::Delete()', array('class' => get_class($this)));
292:
293: // send the request
294: return $this->getClient()->delete($this->getUrl())->send();
295: }
296:
297: /**
298: * Returns the default URL of the object
299: *
300: * This may have to be overridden in subclasses.
301: *
302: * @param string $subresource optional sub-resource string
303: * @param array $qstr optional k/v pairs for query strings
304: * @return string
305: */
306: public function url($path = null, array $query = array())
307: {
308: return $this->getUrl($path, $query);
309: }
310:
311: public function getUrl($path = null, array $query = array())
312: {
313: if (!$url = $this->findLink('self')) {
314:
315: // ...otherwise construct a URL from parent and this resource's
316: // "URL name". If no name is set, resourceName() throws an error.
317: $url = $this->getParent()->getUrl($this->resourceName());
318:
319: // Does it have a primary key?
320: if (null !== ($primaryKey = $this->getProperty($this->primaryKeyField()))) {
321: $url->addPath($primaryKey);
322: }
323: }
324:
325: if (!$url instanceof Url) {
326: $url = Url::factory($url);
327: }
328:
329: return $url->addPath($path)->setQuery($query);
330: }
331:
332: /**
333: * Waits for the server/instance status to change
334: *
335: * This function repeatedly polls the system for a change in server
336: * status. Once the status reaches the `$terminal` value (or 'ERROR'),
337: * then the function returns.
338: *
339: * @api
340: * @param string $terminal the terminal state to wait for
341: * @param integer $timeout the max time (in seconds) to wait
342: * @param callable $callback a callback function that is invoked with
343: * each repetition of the polling sequence. This can be used, for
344: * example, to update a status display or to permit other operations
345: * to continue
346: * @return void
347: * @codeCoverageIgnore
348: */
349: public function waitFor($state = null, $timeout = null, $callback = null, $interval = null)
350: {
351: $state = $state ?: StateConst::ACTIVE;
352: $timeout = $timeout ?: StateConst::DEFAULT_TIMEOUT;
353: $interval = $interval ?: StateConst::DEFAULT_INTERVAL;
354:
355: // save stats
356: $startTime = time();
357:
358: $states = array('ERROR', $state);
359:
360: while (true) {
361:
362: $this->refresh($this->getProperty($this->primaryKeyField()));
363:
364: if ($callback) {
365: call_user_func($callback, $this);
366: }
367:
368: if (in_array($this->status(), $states) || (time() - $startTime) > $timeout) {
369: return;
370: }
371:
372: sleep($interval);
373: }
374: }
375:
376: /**
377: * Sends the json string to the /action resource
378: *
379: * This is used for many purposes, such as rebooting the server,
380: * setting the root password, creating images, etc.
381: * Since it can only be used on a live server, it checks for a valid ID.
382: *
383: * @param $object - this will be encoded as json, and we handle all the JSON
384: * error-checking in one place
385: * @throws ServerIdError if server ID is not defined
386: * @throws ServerActionError on other errors
387: * @returns boolean; TRUE if successful, FALSE otherwise
388: */
389: protected function action($object)
390: {
391: if (!$this->getProperty($this->primaryKeyField())) {
392: throw new Exceptions\IdRequiredError(sprintf(
393: Lang::translate('%s is not defined'),
394: get_class($this),
395: $this->primaryKeyField()
396: ));
397: }
398:
399: if (!is_object($object)) {
400: throw new Exceptions\ServerActionError(sprintf(
401: Lang::translate('%s::Action() requires an object as its parameter'),
402: get_class($this)
403: ));
404: }
405:
406: // convert the object to json
407: $json = json_encode($object);
408: $this->checkJsonError();
409:
410: // debug - save the request
411: $this->getLogger()->info(Lang::translate('{class}::action [{json}]'), array(
412: 'class' => get_class($this),
413: 'json' => $json
414: ));
415:
416: // get the URL for the POST message
417: $url = $this->url('action');
418:
419: // POST the message
420: return $this->getClient()->post($url, array(), $json)->send();
421: }
422:
423: /**
424: * Returns an object for the Create() method JSON
425: * Must be overridden in a child class.
426: *
427: * @throws CreateError if not overridden
428: */
429: protected function createJson()
430: {
431: if (!isset($this->createKeys)) {
432: throw new RuntimeException(sprintf(
433: 'This resource object [%s] must have a visible createKeys array',
434: get_class($this)
435: ));
436: }
437:
438: $element = (object) array();
439:
440: foreach ($this->createKeys as $key) {
441: if (null !== ($property = $this->getProperty($key))) {
442: $element->$key = $property;
443: }
444: }
445:
446: if (!empty($this->metadata)) {
447: $element->metadata = (object) $this->metadata->toArray();
448: }
449:
450: return (object) array($this->jsonName() => (object) $element);
451: }
452:
453: /**
454: * Returns an object for the Update() method JSON
455: * Must be overridden in a child class.
456: *
457: * @throws UpdateError if not overridden
458: */
459: protected function updateJson($params = array())
460: {
461: throw new Exceptions\UpdateError(sprintf(
462: Lang::translate('[%s] UpdateJson() must be overridden'),
463: get_class($this)
464: ));
465: }
466:
467: /**
468: * throws a CreateError for subclasses that don't support Create
469: *
470: * @throws CreateError
471: */
472: protected function noCreate()
473: {
474: throw new Exceptions\CreateError(sprintf(
475: Lang::translate('[%s] does not support Create()'),
476: get_class($this)
477: ));
478: }
479:
480: /**
481: * throws a DeleteError for subclasses that don't support Delete
482: *
483: * @throws DeleteError
484: */
485: protected function noDelete()
486: {
487: throw new Exceptions\DeleteError(sprintf(
488: Lang::translate('[%s] does not support Delete()'),
489: get_class($this)
490: ));
491: }
492:
493: /**
494: * throws a UpdateError for subclasses that don't support Update
495: *
496: * @throws UpdateError
497: */
498: protected function noUpdate()
499: {
500: throw new Exceptions\UpdateError(sprintf(
501: Lang::translate('[%s] does not support Update()'),
502: get_class($this)
503: ));
504: }
505:
506: /**
507: * Returns the displayable name of the object
508: *
509: * Can be overridden by child objects; *must* be overridden by child
510: * objects if the object does not have a `name` attribute defined.
511: *
512: * @api
513: * @return string
514: * @throws NameError if attribute 'name' is not defined
515: */
516: public function name()
517: {
518: if (null !== ($name = $this->getProperty('name'))) {
519: return $name;
520: } else {
521: throw new Exceptions\NameError(sprintf(
522: Lang::translate('Name attribute does not exist for [%s]'),
523: get_class($this)
524: ));
525: }
526: }
527:
528: /**
529: * returns the object's status or `N/A` if not available
530: *
531: * @api
532: * @return string
533: */
534: public function status()
535: {
536: return (isset($this->status)) ? $this->status : 'N/A';
537: }
538:
539: /**
540: * returns the object's identifier
541: *
542: * Can be overridden by a child class if the identifier is not in the
543: * `$id` property. Use of this function permits the `$id` attribute to
544: * be protected or private to prevent unauthorized overwriting for
545: * security.
546: *
547: * @api
548: * @return string
549: */
550: public function id()
551: {
552: return $this->id;
553: }
554:
555: /**
556: * checks for `$alias` in extensions and throws an error if not present
557: *
558: * @throws UnsupportedExtensionError
559: */
560: public function checkExtension($alias)
561: {
562: if (!in_array($alias, $this->getService()->namespaces())) {
563: throw new Exceptions\UnsupportedExtensionError(sprintf(
564: Lang::translate('Extension [%s] is not installed'),
565: $alias
566: ));
567: }
568:
569: return true;
570: }
571:
572: /**
573: * returns the region associated with the object
574: *
575: * navigates to the parent service to determine the region.
576: *
577: * @api
578: */
579: public function region()
580: {
581: return $this->getService()->Region();
582: }
583:
584: /**
585: * Since each server can have multiple links, this returns the desired one
586: *
587: * @param string $type - 'self' is most common; use 'bookmark' for
588: * the version-independent one
589: * @return string the URL from the links block
590: */
591: public function findLink($type = 'self')
592: {
593: if (empty($this->links)) {
594: return false;
595: }
596:
597: foreach ($this->links as $link) {
598: if ($link->rel == $type) {
599: return $link->href;
600: }
601: }
602:
603: return false;
604: }
605:
606: /**
607: * returns the URL used for Create
608: *
609: * @return string
610: */
611: public function createUrl()
612: {
613: return $this->getParent()->getUrl($this->resourceName());
614: }
615:
616: /**
617: * Returns the primary key field for the object
618: *
619: * The primary key is usually 'id', but this function is provided so that
620: * (in rare cases where it is not 'id'), it can be overridden.
621: *
622: * @return string
623: */
624: protected function primaryKeyField()
625: {
626: return 'id';
627: }
628:
629: /**
630: * Returns the top-level document identifier for the returned response
631: * JSON document; must be overridden in child classes
632: *
633: * For example, a server document is (JSON) `{"server": ...}` and an
634: * Instance document is `{"instance": ...}` - this function must return
635: * the top level document name (either "server" or "instance", in
636: * these examples).
637: *
638: * @throws DocumentError if not overridden
639: */
640: public static function jsonName()
641: {
642: if (isset(static::$json_name)) {
643: return static::$json_name;
644: }
645:
646: throw new Exceptions\DocumentError(sprintf(
647: Lang::translate('No JSON object defined for class [%s] in JsonName()'),
648: get_class()
649: ));
650: }
651:
652: /**
653: * returns the collection JSON element name
654: *
655: * When an object is returned in a collection, it usually has a top-level
656: * object that is an array holding child objects of the object types.
657: * This static function returns the name of the top-level element. Usually,
658: * that top-level element is simply the JSON name of the resource.'s';
659: * however, it can be overridden by specifying the $json_collection_name
660: * attribute.
661: *
662: * @return string
663: */
664: public static function jsonCollectionName()
665: {
666: if (isset(static::$json_collection_name)) {
667: return static::$json_collection_name;
668: } else {
669: return static::$json_name . 's';
670: }
671: }
672:
673: /**
674: * returns the JSON name for each element in a collection
675: *
676: * Usually, elements in a collection are anonymous; this function, however,
677: * provides for an element level name:
678: *
679: * `{ "collection" : [ { "element" : ... } ] }`
680: *
681: * @return string
682: */
683: public static function jsonCollectionElement()
684: {
685: if (isset(static::$json_collection_element)) {
686: return static::$json_collection_element;
687: }
688: }
689:
690: /**
691: * Returns the resource name for the URL of the object; must be overridden
692: * in child classes
693: *
694: * For example, a server is `/servers/`, a database instance is
695: * `/instances/`. Must be overridden in child classes.
696: *
697: * @throws UrlError
698: */
699: public static function resourceName()
700: {
701: if (isset(static::$url_resource)) {
702: return static::$url_resource;
703: }
704:
705: throw new Exceptions\UrlError(sprintf(
706: Lang::translate('No URL resource defined for class [%s] in ResourceName()'),
707: get_class()
708: ));
709: }
710:
711: public function parseResponse(Response $response)
712: {
713: $body = Formatter::decode($response);
714:
715: $top = $this->jsonName();
716:
717: if ($top && isset($body->$top)) {
718: $content = $body->$top;
719: } else {
720: $content = $body;
721: }
722:
723: return $content;
724: }
725:
726: }
727: