File Coverage

blib/lib/WebService/Rackspace/CloudFiles/Object.pm
Criterion Covered Total %
statement 24 165 14.5
branch 0 84 0.0
condition 0 42 0.0
subroutine 8 24 33.3
pod 9 9 100.0
total 41 324 12.6


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