File Coverage

blib/lib/WebService/Rackspace/CloudFiles/Object.pm
Criterion Covered Total %
statement 24 169 14.2
branch 0 84 0.0
condition 0 42 0.0
subroutine 8 25 32.0
pod 9 9 100.0
total 41 329 12.4


line stmt bran cond sub pod time code
1             package WebService::Rackspace::CloudFiles::Object;
2 3     3   48 use Moo;
  3         22  
  3         18  
3 3     3   1020 use MooX::StrictConstructor;
  3         8  
  3         30  
4 3     3   2596 use Types::Standard qw(Bool Str StrMatch Num Int HashRef InstanceOf);
  3         6  
  3         33  
5 3     3   3871 use Digest::MD5 qw(md5_hex);
  3         57  
  3         235  
6 3     3   1213 use Digest::MD5::File qw(file_md5_hex);
  3         29161  
  3         38  
7 3     3   1668 use File::stat;
  3         13845  
  3         19  
8 3     3   211 use Carp qw(confess);
  3         6  
  3         133  
9 3     3   1343 use WebService::Rackspace::CloudFiles::DateTime;
  3         10  
  3         7875  
10              
11             has 'cloudfiles' =>
12             ( is => 'ro', isa => InstanceOf['WebService::Rackspace::CloudFiles'], required => 1 );
13             has 'container' =>
14             ( is => 'ro', isa => InstanceOf['WebService::Rackspace::CloudFiles::Container'], required => 1 );
15             has 'name' => ( is => 'ro', isa => Str, required => 1 );
16             has 'etag' => ( is => 'rw', isa => StrMatch[qr/^[a-z0-9]{32}$/] );
17             has 'size' => ( is => 'rw', isa => Int );
18             has 'content_type' =>
19             ( is => 'rw', isa => Str, default => 'binary/octet-stream' );
20              
21             has 'last_modified' => (
22             is => 'rw',
23             isa => InstanceOf['WebService::Rackspace::CloudFiles::DateTime'],
24             coerce => sub {
25             my $val = shift;
26             $val = DateTime::Format::HTTP->parse_datetime($val) unless ref $val;
27             bless $val, 'WebService::Rackspace::CloudFiles::DateTime';
28             return $val;
29             }
30             );
31              
32             has 'cache_value' => (
33             is => 'rw',
34             isa => Bool,
35             required => 1,
36             default => 0
37             );
38              
39             has 'always_check_etag' => (
40             is => 'rw',
41             isa => Bool,
42             required => 1,
43             default => 1
44             );
45              
46              
47             has 'object_metadata' => (
48             is => 'rw',
49             isa => HashRef,
50             required => 0,
51             default => sub {
52             return {};
53             }
54             );
55              
56             has 'value' => (
57             is => 'rw',
58             required => 0,
59             default => undef,
60             );
61              
62             has 'local_filename' => (
63             is => 'rw',
64             isa => Str,
65             required => 0
66             );
67              
68              
69              
70              
71             __PACKAGE__->meta->make_immutable;
72              
73             sub _url {
74 0     0     my ($self) = @_;
75 0           my $url
76             = $self->cloudfiles->storage_url . '/'
77             . $self->container->name . '/'
78             . $self->name;
79 0           utf8::downgrade($url);
80 0           return $url;
81             }
82              
83             # get the CDN management url for this object (for edge purge)
84             sub _management_url {
85 0     0     my ($self) = @_;
86 0           my $url
87             = $self->cloudfiles->cdn_management_url . "/"
88             . $self->container->name . '/'
89             . $self->name;
90 0           utf8::downgrade($url);
91 0           return $url;
92             }
93              
94              
95             sub _cdn_url {
96 0     0     my($self,$ssl) = @_;
97 0   0       $ssl ||= 0;
98 0 0         return sprintf('%s/%s',
99             $ssl ? $self->container->cdn_ssl_uri : $self->container->cdn_uri,
100             $self->name);
101             }
102              
103             sub cdn_url {
104 0     0 1   return shift->_cdn_url;
105             }
106              
107             sub cdn_ssl_url {
108 0     0 1   return shift->_cdn_url(1);
109             }
110              
111             sub head {
112 0     0 1   my $self = shift;
113 0           my $request = HTTP::Request->new( 'HEAD', $self->_url,
114             [ 'X-Auth-Token' => $self->cloudfiles->token ] );
115 0           my $response = $self->cloudfiles->_request($request);
116 0 0         confess 'Object ' . $self->name . ' not found' if $response->code == 404;
117 0 0         confess 'Unknown error' unless $response->is_success;
118 0           $self->_set_attributes_from_response($response);
119 0           return $response->content;
120             }
121              
122             sub get {
123 0     0 1   my ($self, $force_retrieval) = @_;
124            
125 0 0 0       if (!$force_retrieval && $self->cache_value() && defined($self->value()) ) {
      0        
126 0           return $self->value();
127             } else {
128 0           my $request = HTTP::Request->new( 'GET', $self->_url,
129             [ 'X-Auth-Token' => $self->cloudfiles->token ] );
130 0           my $response = $self->cloudfiles->_request($request);
131 0 0         confess 'Object ' . $self->name . ' not found' if $response->code == 404;
132 0 0         confess 'Unknown error' if !$response->is_success;
133 0 0         confess 'Data corruption error'
134             if $response->header('ETag') ne md5_hex( $response->content );
135 0           $self->_set_attributes_from_response($response);
136 0 0         if ($self->cache_value()) {
137 0           $self->value($response->content);
138             }
139 0           return $response->content();
140             }
141             }
142              
143             sub get_filename {
144 0     0 1   my ( $self, $filename, $force_retrieval ) = @_;
145            
146             ## if we aren't forcing retrieval, and we are caching values, and we have a local_filename
147             ## defined and it matches the filename we were just given, and the local_filename actually
148             ## exists on the filesystem... then we can think about using the cached value.
149            
150 0 0 0       if (!$force_retrieval && $self->cache_value() && defined($self->local_filename()) &&
      0        
      0        
      0        
151             $self->local_filename() eq $filename && -e $self->local_filename() ) {
152            
153             ## in order to do this, we have to at least verify that the file we have matches
154             ## the file on cloud-files. Best way to do that is to load the metadata and
155             ## compare the etags.
156 0           $self->head();
157 0 0         if ($self->etag() eq file_md5_hex($filename)) {
158             ## our local data matches what's in the cloud, we don't have to re-download
159 0           return $self->local_filename();
160             }
161             }
162            
163             ## if we are here, we have to download the file.
164 0           my $request = HTTP::Request->new( 'GET', $self->_url,
165             [ 'X-Auth-Token' => $self->cloudfiles->token ] );
166 0           my $response = $self->cloudfiles->_request( $request, $filename );
167              
168 0 0         confess 'Object ' . $self->name . ' not found' if $response->code == 404;
169 0 0         confess 'Unknown error' if !$response->is_success;
170 0 0         confess 'Data corruption error' unless $self->_validate_local_file( $filename,
171             $response->header('Content-Length'),
172             $response->header('ETag') );
173 0           $self->_set_attributes_from_response($response);
174 0           my $last_modified = $self->last_modified->epoch;
175              
176             # make sure the file has the same last modification time
177 0           utime $last_modified, $last_modified, $filename;
178 0 0         if ($self->cache_value()) {
179 0           $self->local_filename($filename);
180             }
181 0           return $filename;
182             }
183              
184              
185              
186             sub delete {
187 0     0 1   my $self = shift;
188 0           my $request = HTTP::Request->new( 'DELETE', $self->_url,
189             [ 'X-Auth-Token' => $self->cloudfiles->token ] );
190 0           my $response = $self->cloudfiles->_request($request);
191 0 0         confess 'Object ' . $self->name . ' not found' if $response->code == 404;
192 0 0         confess 'Unknown error' if !$response->is_success;
193             }
194              
195             sub purge_cdn {
196 0     0 1   my ($self, @emails) = @_;
197 0           my $request = HTTP::Request->new( 'DELETE', $self->_management_url,
198             [ 'X-Auth-Token' => $self->cloudfiles->token,
199             'X-Purge-Email' => join ', ', @emails] );
200 0           my $response = $self->cloudfiles->_request($request);
201 0 0         confess 'Not Found' if $response->code == 404;
202 0 0         confess 'Unauthorized request' if $response->code == 403;
203 0 0         confess 'Unknown error' if !$response->is_success;
204             }
205              
206             sub put {
207 0     0 1   my ( $self, $value ) = @_;
208 0           my $name = $self->name;
209 0           my $md5_hex = md5_hex($value);
210 0           my $size = length($value);
211              
212 0           my $request = HTTP::Request->new(
213             'PUT',
214             $self->_url,
215             $self->_prepare_headers($md5_hex, $size),
216             $value
217             );
218 0           my $response = $self->cloudfiles->_request($request);
219            
220 0 0         if ($response->is_success) {
221             ## since the value was set successfully, we can set all our instance data appropriately.
222            
223 0           $self->etag($md5_hex);
224 0           $self->size($size);
225 0 0         if ($self->cache_value) {
226 0           $self->value($value);
227             }
228 0           return;
229             }
230 0 0         confess 'Missing Content-Length or Content-Type header'
231             if $response->code == 412;
232 0 0         confess 'Data corruption error' if $response->code == 422;
233 0 0         confess 'Data corruption error' if $response->header('ETag') ne $md5_hex;
234 0 0         confess 'Unknown error' unless $response->is_success;
235             }
236              
237             sub put_filename {
238 0     0 1   my ( $self, $filename ) = @_;
239 0           my $name = $self->name;
240              
241 0           my $md5_hex = file_md5_hex($filename);
242 0   0       my $stat = stat($filename) || confess("No $filename: $!");
243 0           my $size = $stat->size;
244              
245 0           my $request = HTTP::Request->new(
246             'PUT',
247             $self->_url,
248             $self->_prepare_headers($md5_hex, $size),
249             $self->_content_sub($filename),
250             );
251 0           my $response = $self->cloudfiles->_request($request);
252            
253 0 0         if ($response->is_success) {
254 0           $self->etag($md5_hex);
255 0           $self->size($size);
256 0 0         if ($self->cache_value) {
257 0           $self->local_filename($filename);
258             }
259             }
260            
261 0 0         confess 'Missing Content-Length or Content-Type header'
262             if $response->code == 412;
263 0 0         confess 'Data corruption error' if $response->code == 422;
264 0 0 0       confess 'Data corruption error' if !defined($response->header('ETag')) ||
265             ($response->header('ETag') ne $md5_hex);
266 0 0         confess 'Unknown error' unless $response->is_success;
267             }
268              
269             my %Supported_headers = (
270             map { $_ => 1 }
271             'Content-Encoding',
272             'Content-Disposition',
273             'X-Object-Manifest',
274             'Access-Control-Allow-Origin',
275             'Access-Control-Allow-Credentials',
276             'Access-Control-Expose-Headers',
277             'Access-Control-Max-Age',
278             'Access-Control-Allow-Methods',
279             'Access-Control-Allow-Headers',
280             'Origin',
281             'Access-Control-Request-Method',
282             'Access-Control-Request-Headers',
283             );
284              
285             sub _prepare_headers {
286 0     0     my ($self, $etag, $size) = @_;
287 0           my $headers = HTTP::Headers->new();
288            
289 0           $headers->header('X-Auth-Token' => $self->cloudfiles->token );
290 0           $headers->header('Content-length' => $size );
291 0           $headers->header('ETag' => $etag );
292 0           $headers->header('Content-Type' => $self->content_type);
293            
294 0           my $header_field;
295 0           foreach my $key (keys %{$self->object_metadata}) {
  0            
296 0           $header_field = $key;
297             $header_field = 'X-Object-Meta-' . $header_field
298 0 0         unless $Supported_headers{$header_field};
299             # make _'s -'s for header sending.
300 0           $header_field =~ s/_/-/g;
301            
302 0           $headers->header($header_field => $self->object_metadata->{$key});
303             }
304 0           return $headers;
305             }
306              
307              
308             sub _content_sub {
309 0     0     my $self = shift;
310 0           my $filename = shift;
311 0           my $stat = stat($filename);
312 0           my $remaining = $stat->size;
313 0   0       my $blksize = $stat->blksize || 4096;
314              
315 0 0 0       confess "$filename not a readable file with fixed size"
      0        
316             unless -r $filename and ( -f _ || $remaining );
317 0 0         my $fh = IO::File->new( $filename, 'r' )
318             or confess "Could not open $filename: $!";
319 0           $fh->binmode;
320              
321             return sub {
322 0     0     my $buffer;
323              
324             # upon retries the file is closed and we must reopen it
325 0 0         unless ( $fh->opened ) {
326 0 0         $fh = IO::File->new( $filename, 'r' )
327             or confess "Could not open $filename: $!";
328 0           $fh->binmode;
329 0           $remaining = $stat->size;
330             }
331              
332             # warn "read remaining $remaining";
333 0 0         unless ( my $read = $fh->read( $buffer, $blksize ) ) {
334              
335             # warn "read $read buffer $buffer remaining $remaining";
336 0 0 0       confess
337             "Error while reading upload content $filename ($remaining remaining) $!"
338             if $! and $remaining;
339              
340             # otherwise, we found EOF
341 0 0         $fh->close
342             or confess "close of upload content $filename failed: $!";
343 0   0       $buffer ||= ''
344             ; # LWP expects an emptry string on finish, read returns 0
345             }
346 0           $remaining -= length($buffer);
347 0           return $buffer;
348 0           };
349             }
350              
351             sub _set_attributes_from_response {
352 0     0     my ( $self, $response ) = @_;
353            
354 0           $self->etag( $response->header('ETag') );
355 0           $self->size( $response->header('Content-Length') );
356 0           $self->content_type( $response->header('Content-Type') );
357 0           $self->last_modified( $response->header('Last-Modified') );
358 0           my $metadata = {};
359 0           foreach my $headername ($response->headers->header_field_names) {
360 0 0         if ($headername =~ /^x-object-meta-(.*)/i) {
361 0           my $key = $1;
362             ## undo our _ to - translation
363 0           $key =~ s/-/_/g;
364 0           $metadata->{lc($key)} = $response->header($headername);
365             }
366             }
367 0           $self->object_metadata($metadata);
368             }
369              
370             sub _validate_local_file {
371 0     0     my ($self, $localfile, $size, $md5) = @_;
372            
373 0           my $stat = stat($localfile);
374 0           my $localsize = $stat->size;
375            
376             # first check size, if they are different, we don't need to bother with
377             # an expensive md5 calculation on the whole file.
378 0 0         if ($size != $localsize ) {
379 0           return 0;
380             }
381            
382 0 0 0       if ($self->always_check_etag && ($md5 ne file_md5_hex($localfile))) {
383 0           return 0;
384             }
385 0           return 1;
386             }
387              
388             1;
389              
390             __END__
391              
392             =head1 NAME
393              
394             WebService::Rackspace::CloudFiles::Object - Represent a Cloud Files object
395              
396             =head1 SYNOPSIS
397              
398             # To create a new object
399             my $xxx = $container->object( name => 'XXX' );
400             $xxx->put('this is the value');
401              
402             # To create a new object with the contents of a local file
403             my $yyy = $container->object( name => 'YYY', content_type => 'text/plain' );
404             $yyy->put_filename('README');
405              
406             # To fetch an object:
407             my $xxx2 = $container->object( name => 'XXX' );
408             my $value = $xxx2->get;
409             say 'has name ' . $xxx2->name;
410             say 'has md5 ' . $xxx2->etag;
411             say 'has size ' . $xxx2->size;
412             say 'has content type ' . $xxx2->content_type;
413             say 'has last_modified ' . $xxx2->last_modified;
414              
415             # To download an object to a local file
416             $yyy->get_filename('README.downloaded');
417              
418             =head1 DESCRIPTION
419              
420             This class represents an object in Cloud Files. It is created by
421             calling object or objects on a L<WebService::Rackspace::CloudFiles::Container> object.
422              
423             =head1 METHODS
424              
425             =head2 name
426              
427             Returns the name of the object.
428              
429             say 'has name ' . $object->name;
430              
431             =head2 head
432              
433             Fetches the metadata of the object:
434              
435             $object->head;
436            
437            
438             =head2 always_check_etag
439              
440             When set to true, forces md5 calculation on every file download and
441             compares it to the provided etag. This can be a very expensive operation,
442             especially on larger files. Setting always_check_etag to false will avoid the
443             checksum on the file and will validate the file transfer was complete by
444             comparing the file sizes after download. Defaults to true.
445              
446             =head2 cache_value
447              
448             When set to true, any values retrieved from the server will be cached
449             within the object, this allows you to continue to use the value
450             without re-retrieving it from CloudFiles repeatedly. Defaults to false.
451              
452             =head2 get
453              
454             Fetches the metadata and content of an object:
455              
456             my $value = $object->get;
457              
458             If cache_value is enabled, will not re-retrieve the value from CloudFiles.
459             To force re-retrieval, pass true to the get routine:
460              
461             my $value = $object->get(1);
462              
463             =head2 get_filename
464              
465             Downloads the content of an object to a local file,
466             checks the integrity of the file, sets metadata in the object
467             and sets the last modified time of the file to the same as the object.
468              
469             $object->get_filename('README.downloaded');
470              
471             If cache_value is enabled and the file has already been retrieved and is
472             present on the filesystem with the filename provided, and the file size and
473             md5 hash of the local file match what is in CloudFiles, the file will not
474             be re-retrieved and the local file will be returned as-is. To force a
475             re-fetch of the file, pass a true value as the second arg to get_filename():
476              
477             $object->get_filename('README.downloaded',1);
478              
479             =head2 delete
480              
481             Deletes an object:
482              
483             $object->delete;
484              
485             =head2 purge_cdn
486              
487             Purges an object in a CDN enabled container without having to wait for the TTL
488             to expire.
489              
490             $object->purge_cdn;
491              
492             Purging an object in a CDN enabled container may take long time. So you can
493             optionally provide one or more emails to be notified after the object is
494             fully purged.
495              
496             my @emails = ('foo@example.com', 'bar@example.com');
497             $object->purge_cdn(@emails);
498              
499             =head2 put
500              
501             Creates a new object:
502              
503             my $xxx = $container->object( name => 'XXX' );
504             $xxx->put('this is the value');
505              
506             =head2 put_filename
507              
508             Creates a new object with the contents of a local file:
509              
510             my $yyy = $container->object( name => 'YYY', content_type => 'text/plain' );
511             $yyy->put_filename('README');
512              
513             =head2 etag
514              
515             Returns the entity tag of the object, which is its MD5:
516              
517             say 'has md5 ' . $object->etag;
518              
519             =head2 size
520              
521             Return the size of an object in bytes:
522              
523             say 'has size ' . $object->size;
524              
525             =head2 content_type
526              
527             Return the content type of an object:
528              
529             say 'has content type ' . $object->content_type;
530              
531             =head2 last_modified
532              
533             Return the last modified time of an object as a L<DateTime> object:
534              
535             say 'has last_modified ' . $object->last_modified;
536            
537             =head2 object_metadata
538              
539             Sets or returns a hashref of metadata to be stored along with the file
540             in CloudFiles. This hashref must containe key => value pairs and values
541             must be scalar type, if you require storage of complex data, you will need
542             to flatten it in some way prior to setting it here. Also, due to the way
543             that CloudFiles works with metadata, when retrieved from CloudFiles, your
544             keys will be lowercase. Note that since underscores are not permitted in
545             keys within CloudFiles, any underscores are translated to dashes when
546             transmitted to CloudFiles. They are re-translated when they are retrieved.
547             This is mentioned only because if you access your data through a different
548             language or interface, your metadata keys will contain dashes instead of
549             underscores.
550              
551             =head2 cdn_url
552              
553             Retrieve HTTP CDN url to object.
554              
555             =head2 cdn_ssl_url
556              
557             Retrieve HTTPS CDN url to object.
558              
559             =head2 cloudfiles
560              
561             =head2 container
562              
563             =head2 value
564              
565             =head1 SEE ALSO
566              
567             L<WebService::Rackspace::CloudFiles>, L<WebService::Rackspace::CloudFiles::Container>.
568              
569             =head1 AUTHORS
570              
571             Christiaan Kras <ckras@cpan.org>.
572             Leon Brocard <acme@astray.com>.
573              
574             =head1 COPYRIGHT
575              
576             Copyright (C) 2010-2011, Christiaan Kras
577             Copyright (C) 2008-9, Leon Brocard
578              
579             =head1 LICENSE
580              
581             This module is free software; you can redistribute it or modify it
582             under the same terms as Perl itself.