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\ObjectStore\Resource;
19
20 use Guzzle\Http\EntityBody;
21 use Guzzle\Http\Exception\BadResponseException;
22 use Guzzle\Http\Exception\ClientErrorResponseException;
23 use Guzzle\Http\Message\Response;
24 use Guzzle\Http\Url;
25 use OpenCloud\Common\Constants\Size;
26 use OpenCloud\Common\Exceptions;
27 use OpenCloud\Common\Service\ServiceInterface;
28 use OpenCloud\ObjectStore\Constants\Header as HeaderConst;
29 use OpenCloud\ObjectStore\Exception\ContainerException;
30 use OpenCloud\ObjectStore\Exception\ObjectNotFoundException;
31 use OpenCloud\ObjectStore\Upload\DirectorySync;
32 use OpenCloud\ObjectStore\Upload\TransferBuilder;
33
34 /**
35 * A container is a storage compartment for your data and provides a way for you
36 * to organize your data. You can think of a container as a folder in Windows
37 * or a directory in Unix. The primary difference between a container and these
38 * other file system concepts is that containers cannot be nested.
39 *
40 * A container can also be CDN-enabled (for public access), in which case you
41 * will need to interact with a CDNContainer object instead of this one.
42 */
43 class Container extends AbstractContainer
44 {
45 const METADATA_LABEL = 'Container';
46
47 /**
48 * This is the object that holds all the CDN functionality. This Container therefore acts as a simple wrapper and is
49 * interested in storage concerns only.
50 *
51 * @var CDNContainer|null
52 */
53 private $cdn;
54
55 public function __construct(ServiceInterface $service, $data = null)
56 {
57 parent::__construct($service, $data);
58
59 // Set metadata items for collection listings
60 if (isset($data->count)) {
61 $this->metadata->setProperty('Object-Count', $data->count);
62 }
63 if (isset($data->bytes)) {
64 $this->metadata->setProperty('Bytes-Used', $data->bytes);
65 }
66 }
67
68 /**
69 * Factory method that instantiates an object from a Response object.
70 *
71 * @param Response $response
72 * @param ServiceInterface $service
73 * @return static
74 */
75 public static function fromResponse(Response $response, ServiceInterface $service)
76 {
77 $self = parent::fromResponse($response, $service);
78
79 $segments = Url::factory($response->getEffectiveUrl())->getPathSegments();
80 $self->name = end($segments);
81
82 return $self;
83 }
84
85 /**
86 * Get the CDN object.
87 *
88 * @return null|CDNContainer
89 * @throws \OpenCloud\Common\Exceptions\CdnNotAvailableError
90 */
91 public function getCdn()
92 {
93 if (!$this->isCdnEnabled()) {
94 throw new Exceptions\CdnNotAvailableError(
95 'Either this container is not CDN-enabled or the CDN is not available'
96 );
97 }
98
99 return $this->cdn;
100 }
101
102 /**
103 * It would be awesome to put these convenience methods (which are identical to the ones in the Account object) in
104 * a trait, but we have to wait for v5.3 EOL first...
105 *
106 * @return null|string|int
107 */
108 public function getObjectCount()
109 {
110 return $this->metadata->getProperty('Object-Count');
111 }
112
113 /**
114 * @return null|string|int
115 */
116 public function getBytesUsed()
117 {
118 return $this->metadata->getProperty('Bytes-Used');
119 }
120
121 /**
122 * @param $value
123 * @return mixed
124 */
125 public function setCountQuota($value)
126 {
127 $this->metadata->setProperty('Quota-Count', $value);
128
129 return $this->saveMetadata($this->metadata->toArray());
130 }
131
132 /**
133 * @return null|string|int
134 */
135 public function getCountQuota()
136 {
137 return $this->metadata->getProperty('Quota-Count');
138 }
139
140 /**
141 * @param $value
142 * @return mixed
143 */
144 public function setBytesQuota($value)
145 {
146 $this->metadata->setProperty('Quota-Bytes', $value);
147
148 return $this->saveMetadata($this->metadata->toArray());
149 }
150
151 /**
152 * @return null|string|int
153 */
154 public function getBytesQuota()
155 {
156 return $this->metadata->getProperty('Quota-Bytes');
157 }
158
159 public function delete($deleteObjects = false)
160 {
161 if ($deleteObjects === true) {
162 // Delegate to auxiliary method
163 return $this->deleteWithObjects();
164 }
165
166 try {
167 return $this->getClient()->delete($this->getUrl())->send();
168 } catch (ClientErrorResponseException $e) {
169 if ($e->getResponse()->getStatusCode() == 409) {
170 throw new ContainerException(sprintf(
171 'The API returned this error: %s. You might have to delete all existing objects before continuing.',
172 (string) $e->getResponse()->getBody()
173 ));
174 } else {
175 throw $e;
176 }
177 }
178 }
179
180 public function deleteWithObjects($secondsToWait = null)
181 {
182 // If container is empty, just delete it
183 $numObjects = (int) $this->retrieveMetadata()->getProperty('Object-Count');
184 if (0 === $numObjects) {
185 return $this->delete();
186 }
187
188 // If timeout ($secondsToWait) is not specified by caller,
189 // try to estimate it based on number of objects in container
190 if (null === $secondsToWait) {
191 $secondsToWait = round($numObjects / 2);
192 }
193
194 // Attempt to delete all objects and container
195 $endTime = time() + $secondsToWait;
196 $containerDeleted = false;
197 while ((time() < $endTime) && !$containerDeleted) {
198 $this->deleteAllObjects();
199 try {
200 $response = $this->delete();
201 $containerDeleted = true;
202 } catch (ContainerException $e) {
203 // Ignore exception and try again
204 } catch (ClientErrorResponseException $e) {
205 if ($e->getResponse()->getStatusCode() == 404) {
206 // Container has been deleted
207 $containerDeleted = true;
208 } else {
209 throw $e;
210 }
211 }
212 }
213
214 if (!$containerDeleted) {
215 throw new ContainerException('Container and all its objects could not be deleted.');
216 }
217
218 return $response;
219 }
220
221 /**
222 * Deletes all objects that this container currently contains. Useful when doing operations (like a delete) that
223 * require an empty container first.
224 *
225 * @return mixed
226 */
227 public function deleteAllObjects()
228 {
229 $paths = array();
230 $objects = $this->objectList();
231 foreach ($objects as $object) {
232 $paths[] = sprintf('/%s/%s', $this->getName(), $object->getName());
233 }
234 return $this->getService()->batchDelete($paths);
235 }
236
237 /**
238 * Creates a Collection of objects in the container
239 *
240 * @param array $params associative array of parameter values.
241 * * account/tenant - The unique identifier of the account/tenant.
242 * * container- The unique identifier of the container.
243 * * limit (Optional) - The number limit of results.
244 * * marker (Optional) - Value of the marker, that the object names
245 * greater in value than are returned.
246 * * end_marker (Optional) - Value of the marker, that the object names
247 * less in value than are returned.
248 * * prefix (Optional) - Value of the prefix, which the returned object
249 * names begin with.
250 * * format (Optional) - Value of the serialized response format, either
251 * json or xml.
252 * * delimiter (Optional) - Value of the delimiter, that all the object
253 * names nested in the container are returned.
254 * @link http://api.openstack.org for a list of possible parameter
255 * names and values
256 * @return \OpenCloud\Common\Collection
257 * @throws ObjFetchError
258 */
259 public function objectList(array $params = array())
260 {
261 $params['format'] = 'json';
262
263 return $this->getService()->resourceList('DataObject', $this->getUrl(null, $params), $this);
264 }
265
266 /**
267 * Turn on access logs, which track all the web traffic that your data objects accrue.
268 *
269 * @return \Guzzle\Http\Message\Response
270 */
271 public function enableLogging()
272 {
273 return $this->saveMetadata($this->appendToMetadata(array(
274 HeaderConst::ACCESS_LOGS => 'True'
275 )));
276 }
277
278 /**
279 * Disable access logs.
280 *
281 * @return \Guzzle\Http\Message\Response
282 */
283 public function disableLogging()
284 {
285 return $this->saveMetadata($this->appendToMetadata(array(
286 HeaderConst::ACCESS_LOGS => 'False'
287 )));
288 }
289
290 /**
291 * Enable this container for public CDN access.
292 *
293 * @param null $ttl
294 */
295 public function enableCdn($ttl = null)
296 {
297 $headers = array('X-CDN-Enabled' => 'True');
298 if ($ttl) {
299 $headers['X-TTL'] = (int) $ttl;
300 }
301
302 $this->getClient()->put($this->getCdnService()->getUrl($this->name), $headers)->send();
303 $this->refresh();
304 }
305
306 /**
307 * Disables the containers CDN function. Note that the container will still
308 * be available on the CDN until its TTL expires.
309 *
310 * @return \Guzzle\Http\Message\Response
311 */
312 public function disableCdn()
313 {
314 $headers = array('X-CDN-Enabled' => 'False');
315
316 return $this->getClient()
317 ->put($this->getCdnService()->getUrl($this->name), $headers)
318 ->send();
319 }
320
321 public function refresh($id = null, $url = null)
322 {
323 $headers = $this->createRefreshRequest()->send()->getHeaders();
324 $this->setMetadata($headers, true);
325
326 try {
327 if (null !== ($cdnService = $this->getService()->getCDNService())) {
328 $cdn = new CDNContainer($cdnService);
329 $cdn->setName($this->name);
330
331 $response = $cdn->createRefreshRequest()->send();
332
333 if ($response->isSuccessful()) {
334 $this->cdn = $cdn;
335 $this->cdn->setMetadata($response->getHeaders(), true);
336 }
337 } else {
338 $this->cdn = null;
339 }
340 } catch (ClientErrorResponseException $e) {
341 }
342 }
343
344 /**
345 * Get either a fresh data object (no $info), or get an existing one by passing in data for population.
346 *
347 * @param mixed $info
348 * @return DataObject
349 */
350 public function dataObject($info = null)
351 {
352 return new DataObject($this, $info);
353 }
354
355 /**
356 * Retrieve an object from the API. Apart from using the name as an
357 * identifier, you can also specify additional headers that will be used
358 * fpr a conditional GET request. These are
359 *
360 * * `If-Match'
361 * * `If-None-Match'
362 * * `If-Modified-Since'
363 * * `If-Unmodified-Since'
364 * * `Range' For example:
365 * bytes=-5 would mean the last 5 bytes of the object
366 * bytes=10-15 would mean 5 bytes after a 10 byte offset
367 * bytes=32- would mean all dat after first 32 bytes
368 *
369 * These are also documented in RFC 2616.
370 *
371 * @param string $name
372 * @param array $headers
373 * @return DataObject
374 */
375 public function getObject($name, array $headers = array())
376 {
377 try {
378 $response = $this->getClient()
379 ->get($this->getUrl($name), $headers)
380 ->send();
381 } catch (BadResponseException $e) {
382 if ($e->getResponse()->getStatusCode() == 404) {
383 throw ObjectNotFoundException::factory($name, $e);
384 }
385 throw $e;
386 }
387
388 return $this->dataObject()
389 ->populateFromResponse($response)
390 ->setName($name);
391 }
392
393 /**
394 * Essentially the same as {@see getObject()}, except only the metadata is fetched from the API.
395 * This is useful for cases when the user does not want to fetch the full entity body of the
396 * object, only its metadata.
397 *
398 * @param $name
399 * @param array $headers
400 * @return $this
401 */
402 public function getPartialObject($name, array $headers = array())
403 {
404 $response = $this->getClient()
405 ->head($this->getUrl($name), $headers)
406 ->send();
407
408 return $this->dataObject()
409 ->populateFromResponse($response)
410 ->setName($name);
411 }
412
413 /**
414 * Check if an object exists inside a container. Uses {@see getPartialObject()}
415 * to save on bandwidth and time.
416 *
417 * @param $name Object name
418 * @return boolean True, if object exists in this container; false otherwise.
419 */
420 public function objectExists($name)
421 {
422 try {
423 // Send HEAD request to check resource existence
424 $url = clone $this->getUrl();
425 $url->addPath((string) $name);
426 $this->getClient()->head($url)->send();
427 } catch (ClientErrorResponseException $e) {
428 // If a 404 was returned, then the object doesn't exist
429 if ($e->getResponse()->getStatusCode() === 404) {
430 return false;
431 } else {
432 throw $e;
433 }
434 }
435
436 return true;
437 }
438
439 /**
440 * Upload a single file to the API.
441 *
442 * @param $name Name that the file will be saved as in your container.
443 * @param $data Either a string or stream representation of the file contents to be uploaded.
444 * @param array $headers Optional headers that will be sent with the request (useful for object metadata).
445 * @return DataObject
446 */
447 public function uploadObject($name, $data, array $headers = array())
448 {
449 $entityBody = EntityBody::factory($data);
450
451 $url = clone $this->getUrl();
452 $url->addPath($name);
453
454 // @todo for new major release: Return response rather than populated DataObject
455
456 $response = $this->getClient()->put($url, $headers, $entityBody)->send();
457
458 return $this->dataObject()
459 ->populateFromResponse($response)
460 ->setName($name)
461 ->setContent($entityBody);
462 }
463
464 /**
465 * Upload an array of objects for upload. This method optimizes the upload procedure by batching requests for
466 * faster execution. This is a very useful procedure when you just have a bunch of unremarkable files to be
467 * uploaded quickly. Each file must be under 5GB.
468 *
469 * @param array $files With the following array structure:
470 * `name' Name that the file will be saved as in your container. Required.
471 * `path' Path to an existing file, OR
472 * `body' Either a string or stream representation of the file contents to be uploaded.
473 * @param array $headers Optional headers that will be sent with the request (useful for object metadata).
474 *
475 * @throws \OpenCloud\Common\Exceptions\InvalidArgumentError
476 * @return \Guzzle\Http\Message\Response
477 */
478 public function uploadObjects(array $files, array $commonHeaders = array())
479 {
480 $requests = $entities = array();
481
482 foreach ($files as $entity) {
483 if (empty($entity['name'])) {
484 throw new Exceptions\InvalidArgumentError('You must provide a name.');
485 }
486
487 if (!empty($entity['path']) && file_exists($entity['path'])) {
488 $body = fopen($entity['path'], 'r+');
489 } elseif (!empty($entity['body'])) {
490 $body = $entity['body'];
491 } else {
492 throw new Exceptions\InvalidArgumentError('You must provide either a readable path or a body');
493 }
494
495 $entityBody = $entities[] = EntityBody::factory($body);
496
497 // @codeCoverageIgnoreStart
498 if ($entityBody->getContentLength() >= 5 * Size::GB) {
499 throw new Exceptions\InvalidArgumentError(
500 'For multiple uploads, you cannot upload more than 5GB per '
501 . ' file. Use the UploadBuilder for larger files.'
502 );
503 }
504 // @codeCoverageIgnoreEnd
505
506 // Allow custom headers and common
507 $headers = (isset($entity['headers'])) ? $entity['headers'] : $commonHeaders;
508
509 $url = clone $this->getUrl();
510 $url->addPath($entity['name']);
511
512 $requests[] = $this->getClient()->put($url, $headers, $entityBody);
513 }
514
515 $responses = $this->getClient()->send($requests);
516
517 foreach ($entities as $entity) {
518 $entity->close();
519 }
520
521 return $responses;
522 }
523
524 /**
525 * When uploading large files (+5GB), you need to upload the file as chunks using multibyte transfer. This method
526 * sets up the transfer, and in order to execute the transfer, you need to call upload() on the returned object.
527 *
528 * @param array Options
529 * @see \OpenCloud\ObjectStore\Upload\UploadBuilder::setOptions for a list of accepted options.
530 * @throws \OpenCloud\Common\Exceptions\InvalidArgumentError
531 * @return mixed
532 */
533 public function setupObjectTransfer(array $options = array())
534 {
535 // Name is required
536 if (empty($options['name'])) {
537 throw new Exceptions\InvalidArgumentError('You must provide a name.');
538 }
539
540 // As is some form of entity body
541 if (!empty($options['path']) && file_exists($options['path'])) {
542 $body = fopen($options['path'], 'r+');
543 } elseif (!empty($options['body'])) {
544 $body = $options['body'];
545 } else {
546 throw new Exceptions\InvalidArgumentError('You must provide either a readable path or a body');
547 }
548
549 // Build upload
550 $transfer = TransferBuilder::newInstance()
551 ->setOption('objectName', $options['name'])
552 ->setEntityBody(EntityBody::factory($body))
553 ->setContainer($this);
554
555 // Add extra options
556 if (!empty($options['metadata'])) {
557 $transfer->setOption('metadata', $options['metadata']);
558 }
559 if (!empty($options['partSize'])) {
560 $transfer->setOption('partSize', $options['partSize']);
561 }
562 if (!empty($options['concurrency'])) {
563 $transfer->setOption('concurrency', $options['concurrency']);
564 }
565 if (!empty($options['progress'])) {
566 $transfer->setOption('progress', $options['progress']);
567 }
568
569 return $transfer->build();
570 }
571
572 /**
573 * Upload the contents of a local directory to a remote container, effectively syncing them.
574 *
575 * @param $path The local path to the directory.
576 */
577 public function uploadDirectory($path)
578 {
579 $sync = DirectorySync::factory($path, $this);
580 $sync->execute();
581 }
582
583 public function isCdnEnabled()
584 {
585 return ($this->cdn instanceof CDNContainer) && $this->cdn->isCdnEnabled();
586 }
587 }
588