1: <?php
2: /**
3: * PHP OpenCloud library.
4: *
5: * @copyright Copyright 2013 Rackspace US, Inc. See COPYING for licensing information.
6: * @license https://www.apache.org/licenses/LICENSE-2.0 Apache 2.0
7: * @version 1.6.0
8: * @author Jamie Hannaford <jamie.hannaford@rackspace.com>
9: */
10:
11: namespace OpenCloud\Common;
12:
13: use OpenCloud\Common\Base;
14: use OpenCloud\Common\Lang;
15: use OpenCloud\OpenStack;
16: use OpenCloud\Common\Exceptions;
17:
18: /**
19: * This class defines a cloud service; a relationship between a specific OpenStack
20: * and a provided service, represented by a URL in the service catalog.
21: *
22: * Because Service is an abstract class, it cannot be called directly. Provider
23: * services such as Rackspace Cloud Servers or OpenStack Swift are each
24: * subclassed from Service.
25: *
26: * @author Glen Campbell <glen.campbell@rackspace.com>
27: */
28:
29: abstract class Service extends Base
30: {
31:
32: protected $conn;
33: private $service_type;
34: private $service_name;
35: private $service_region;
36: private $service_url;
37:
38: protected $_namespaces = array();
39:
40: /**
41: * Creates a service on the specified connection
42: *
43: * Usage: `$x = new Service($conn, $type, $name, $region, $urltype);`
44: * The service's URL is defined in the OpenStack's serviceCatalog; it
45: * uses the $type, $name, $region, and $urltype to find the proper URL
46: * and set it. If it cannot find a URL in the service catalog that matches
47: * the criteria, then an exception is thrown.
48: *
49: * @param OpenStack $conn - a Connection object
50: * @param string $type - the service type (e.g., "compute")
51: * @param string $name - the service name (e.g., "cloudServersOpenStack")
52: * @param string $region - the region (e.g., "ORD")
53: * @param string $urltype - the specified URL from the catalog
54: * (e.g., "publicURL")
55: */
56: public function __construct(
57: OpenStack $conn,
58: $type,
59: $name,
60: $region,
61: $urltype = RAXSDK_URL_PUBLIC,
62: $customServiceUrl = null
63: ) {
64: $this->setConnection($conn);
65: $this->service_type = $type;
66: $this->service_name = $name;
67: $this->service_region = $region;
68: $this->service_url = $customServiceUrl ?: $this->getEndpoint($type, $name, $region, $urltype);
69: }
70:
71: /**
72: * Set this service's connection.
73: *
74: * @param type $connection
75: */
76: public function setConnection($connection)
77: {
78: $this->conn = $connection;
79: }
80:
81: /**
82: * Get this service's connection.
83: *
84: * @return type
85: */
86: public function getConnection()
87: {
88: return $this->conn;
89: }
90:
91: /**
92: * Returns the URL for the Service
93: *
94: * @param string $resource optional sub-resource
95: * @param array $query optional k/v pairs for query strings
96: * @return string
97: */
98: public function url($resource = '', array $param = array())
99: {
100: $baseurl = $this->service_url;
101:
102: // use strlen instead of boolean test because '0' is a valid name
103: if (strlen($resource) > 0) {
104: $baseurl = Lang::noslash($baseurl).'/'.$resource;
105: }
106:
107: if (!empty($param)) {
108: $baseurl .= '?'.$this->MakeQueryString($param);
109: }
110:
111: return $baseurl;
112: }
113:
114: /**
115: * Returns the /extensions for the service
116: *
117: * @api
118: * @return array of objects
119: */
120: public function extensions()
121: {
122: $ext = $this->getMetaUrl('extensions');
123: return (is_object($ext) && isset($ext->extensions)) ? $ext->extensions : array();
124: }
125:
126: /**
127: * Returns the /limits for the service
128: *
129: * @api
130: * @return array of limits
131: */
132: public function limits()
133: {
134: $limits = $this->getMetaUrl('limits');
135: return (is_object($limits)) ? $limits->limits : array();
136: }
137:
138: /**
139: * Performs an authenticated request
140: *
141: * This method handles the addition of authentication headers to each
142: * request. It always adds the X-Auth-Token: header and will add the
143: * X-Auth-Project-Id: header if there is a tenant defined on the
144: * connection.
145: *
146: * @param string $url The URL of the request
147: * @param string $method The HTTP method (defaults to "GET")
148: * @param array $headers An associative array of headers
149: * @param string $body An optional body for POST/PUT requests
150: * @return \OpenCloud\HttpResult
151: */
152: public function request(
153: $url,
154: $method = 'GET',
155: array $headers = array(),
156: $body = null
157: ) {
158:
159: $headers['X-Auth-Token'] = $this->conn->Token();
160:
161: if ($tenant = $this->conn->Tenant()) {
162: $headers['X-Auth-Project-Id'] = $tenant;
163: }
164:
165: return $this->conn->request($url, $method, $headers, $body);
166: }
167:
168: /**
169: * returns a collection of objects
170: *
171: * @param string $class the class of objects to fetch
172: * @param string $url (optional) the URL to retrieve
173: * @param mixed $parent (optional) the parent service/object
174: * @return OpenCloud\Common\Collection
175: */
176: public function collection($class, $url = null, $parent = null)
177: {
178: // Set the element names
179: $collectionName = $class::JsonCollectionName();
180: $elementName = $class::JsonCollectionElement();
181:
182: // Set the parent if empty
183: if (!$parent) {
184: $parent = $this;
185: }
186:
187: // Set the URL if empty
188: if (!$url) {
189: $url = $parent->url($class::ResourceName());
190: }
191:
192: // Save debug info
193: $this->getLogger()->info(
194: '{class}:Collection({url}, {collectionClass}, {collectionName})',
195: array(
196: 'class' => get_class($this),
197: 'url' => $url,
198: 'collectionClass' => $class,
199: 'collectionName' => $collectionName
200: )
201: );
202:
203: // Fetch the list
204: $response = $this->request($url);
205:
206: $this->getLogger()->info('Response {status} [{body}]', array(
207: 'status' => $response->httpStatus(),
208: 'body' => $response->httpBody()
209: ));
210:
211: // Check return code
212: if ($response->httpStatus() > 204) {
213: throw new Exceptions\CollectionError(sprintf(
214: Lang::translate('Unable to retrieve [%s] list from [%s], status [%d] response [%s]'),
215: $class,
216: $url,
217: $response->httpStatus(),
218: $response->httpBody()
219: ));
220: }
221:
222: // Handle empty response
223: if (strlen($response->httpBody()) == 0) {
224: return new Collection($parent, $class, array());
225: }
226:
227: // Parse the return
228: $object = json_decode($response->httpBody());
229: $this->checkJsonError();
230:
231: // See if there's a "next" link
232: // Note: not sure if the current API offers links as top-level structures;
233: // might have to refactor to allow $nextPageUrl as method argument
234: // @codeCoverageIgnoreStart
235: if (isset($object->links) && is_array($object->links)) {
236: foreach($object->links as $link) {
237: if (isset($link->rel) && $link->rel == 'next') {
238: if (isset($link->href)) {
239: $nextPageUrl = $link->href;
240: } else {
241: $this->getLogger()->warning(
242: 'Unexpected [links] found with no [href]'
243: );
244: }
245: }
246: }
247: }
248: // @codeCoverageIgnoreEnd
249:
250: // How should we populate the collection?
251: $data = array();
252:
253: if (!$collectionName) {
254: // No element name, just a plain object
255: // @codeCoverageIgnoreStart
256: $data = $object;
257: // @codeCoverageIgnoreEnd
258: } elseif (isset($object->$collectionName)) {
259: if (!$elementName) {
260: // The object has a top-level collection name only
261: $data = $object->$collectionName;
262: } else {
263: // The object has element levels which need to be iterated over
264: $data = array();
265: foreach($object->$collectionName as $item) {
266: $subValues = $item->$elementName;
267: unset($item->$elementName);
268: $data[] = array_merge((array)$item, (array)$subValues);
269: }
270: }
271: }
272:
273: $collectionObject = new Collection($parent, $class, $data);
274:
275: // if there's a $nextPageUrl, then we need to establish a callback
276: // @codeCoverageIgnoreStart
277: if (!empty($nextPageUrl)) {
278: $collectionObject->setNextPageCallback(array($this, 'Collection'), $nextPageUrl);
279: }
280: // @codeCoverageIgnoreEnd
281:
282: return $collectionObject;
283: }
284:
285: /**
286: * returns the Region associated with the service
287: *
288: * @api
289: * @return string
290: */
291: public function region()
292: {
293: return $this->service_region;
294: }
295:
296: /**
297: * returns the serviceName associated with the service
298: *
299: * This is used by DNS for PTR record lookups
300: *
301: * @api
302: * @return string
303: */
304: public function name()
305: {
306: return $this->service_name;
307: }
308:
309: /**
310: * Returns a list of supported namespaces
311: *
312: * @return array
313: */
314: public function namespaces()
315: {
316: return (isset($this->_namespaces) && is_array($this->_namespaces)) ? $this->_namespaces : array();
317: }
318:
319: /**
320: * Given a service type, name, and region, return the url
321: *
322: * This function ensures that services are represented by an entry in the
323: * service catalog, and NOT by an arbitrarily-constructed URL.
324: *
325: * Note that it will always return the first match found in the
326: * service catalog (there *should* be only one, but you never know...)
327: *
328: * @param string $type The OpenStack service type ("compute" or
329: * "object-store", for example
330: * @param string $name The name of the service in the service catlog
331: * @param string $region The region of the service
332: * @param string $urltype The URL type; defaults to "publicURL"
333: * @return string The URL of the service
334: */
335: private function getEndpoint($type, $name, $region, $urltype = 'publicURL')
336: {
337: $catalog = $this->getConnection()->serviceCatalog();
338:
339: // Search each service to find The One
340: foreach ($catalog as $service) {
341: // Find the service by comparing the type ("compute") and name ("openstack")
342: if (!strcasecmp($service->type, $type) && !strcasecmp($service->name, $name)) {
343: foreach($service->endpoints as $endpoint) {
344: // Only set the URL if:
345: // a. It is a regionless service (i.e. no region key set)
346: // b. The region matches the one we want
347: if (isset($endpoint->$urltype) &&
348: (!isset($endpoint->region) || !strcasecmp($endpoint->region, $region))
349: ) {
350: $url = $endpoint->$urltype;
351: }
352: }
353: }
354: }
355:
356: // error if not found
357: if (empty($url)) {
358: throw new Exceptions\EndpointError(sprintf(
359: 'No endpoints for service type [%s], name [%s], region [%s] and urlType [%s]',
360: $type,
361: $name,
362: $region,
363: $urltype
364: ));
365: }
366:
367: return $url;
368: }
369:
370: /**
371: * Constructs a specified URL from the subresource
372: *
373: * Given a subresource (e.g., "extensions"), this constructs the proper
374: * URL and retrieves the resource.
375: *
376: * @param string $resource The resource requested; should NOT have slashes
377: * at the beginning or end
378: * @return \stdClass object
379: */
380: private function getMetaUrl($resource)
381: {
382: $urlBase = $this->getEndpoint(
383: $this->service_type,
384: $this->service_name,
385: $this->service_region,
386: RAXSDK_URL_PUBLIC
387: );
388:
389: $url = Lang::noslash($urlBase) . '/' . $resource;
390:
391: $response = $this->request($url);
392:
393: // check for NOT FOUND response
394: if ($response->httpStatus() == 404) {
395: return array();
396: }
397:
398: // @codeCoverageIgnoreStart
399: if ($response->httpStatus() >= 300) {
400: throw new Exceptions\HttpError(sprintf(
401: Lang::translate('Error accessing [%s] - status [%d], response [%s]'),
402: $urlBase,
403: $response->httpStatus(),
404: $response->httpBody()
405: ));
406: }
407: // @codeCoverageIgnoreEnd
408:
409: // we're good; proceed
410: $object = json_decode($response->httpBody());
411:
412: $this->checkJsonError();
413:
414: return $object;
415: }
416:
417: /**
418: * Get all associated resources for this service.
419: *
420: * @access public
421: * @return void
422: */
423: public function getResources()
424: {
425: return $this->resources;
426: }
427:
428: /**
429: * Internal method for accessing child namespace from parent scope.
430: *
431: * @return type
432: */
433: protected function getCurrentNamespace()
434: {
435: $namespace = get_class($this);
436: return substr($namespace, 0, strrpos($namespace, '\\'));
437: }
438:
439: /**
440: * Resolves fully-qualified classname for associated local resource.
441: *
442: * @param string $resourceName
443: * @return string
444: */
445: protected function resolveResourceClass($resourceName)
446: {
447: $className = substr_count($resourceName, '\\')
448: ? $resourceName
449: : $this->getCurrentNamespace() . '\\Resource\\' . ucfirst($resourceName);
450:
451: if (!class_exists($className)) {
452: throw new Exceptions\UnrecognizedServiceError(sprintf(
453: '%s resource does not exist, please try one of the following: %s',
454: $resourceName,
455: implode(', ', $this->getResources())
456: ));
457: }
458:
459: return $className;
460: }
461:
462: /**
463: * Factory method for instantiating resource objects.
464: *
465: * @access public
466: * @param string $resourceName
467: * @param mixed $info (default: null)
468: * @return object
469: */
470: public function resource($resourceName, $info = null)
471: {
472: $className = $this->resolveResourceClass($resourceName);
473: return new $className($this, $info);
474: }
475:
476: /**
477: * Factory method for instantiate a resource collection.
478: *
479: * @param string $resourceName
480: * @param string|null $url
481: * @return Collection
482: */
483: public function resourceList($resourceName, $url = null, $service = null)
484: {
485: $className = $this->resolveResourceClass($resourceName);
486: return $this->collection($className, $url, $service);
487: }
488:
489: }
490: