File Coverage

blib/lib/Backblaze/B2V2Client.pm
Criterion Covered Total %
statement 21 190 11.0
branch 0 72 0.0
condition 0 42 0.0
subroutine 7 22 31.8
pod 12 15 80.0
total 40 341 11.7


line stmt bran cond sub pod time code
1             package Backblaze::B2V2Client;
2             # API client library for V2 of the API to Backblaze B2 object storage
3             # Allows for creating/deleting buckets, listing files in buckets, and uploading/downloading files
4              
5             $Backblaze::B2V2Client::VERSION = '1.2';
6              
7             # our dependencies:
8 1     1   710 use Cpanel::JSON::XS;
  1         2  
  1         53  
9 1     1   437 use Digest::SHA qw(sha1_hex);
  1         2468  
  1         75  
10 1     1   423 use MIME::Base64;
  1         511  
  1         47  
11 1     1   671 use Path::Tiny;
  1         9304  
  1         60  
12 1     1   507 use URI::Escape;
  1         1283  
  1         58  
13 1     1   687 use WWW::Mechanize;
  1         137507  
  1         40  
14              
15             # I wish I could apply this to my diet.
16 1     1   10 use strict;
  1         2  
  1         1904  
17              
18             # object constructor; will automatically authorize this session
19             sub new {
20 0     0 1   my $class = shift;
21            
22             # required args are the account ID and application_key
23 0           my ($application_key_id, $application_key) = @_;
24            
25             # cannot operate without these
26 0 0 0       if (!$application_key_id || !$application_key) {
27 0           die "ERROR: Cannot create B2V5Client object without both application_key_id and application_key arguments.\n";
28             }
29            
30             # initiate class with my keys + WWW::Mechanize object
31 0           my $self = bless {
32             'application_key_id' => $application_key_id,
33             'application_key' => $application_key,
34             'mech' => WWW::Mechanize->new(
35             timeout => 60,
36             autocheck => 0,
37             cookie_jar => {},
38             keep_alive => 1,
39             ),
40             }, $class;
41              
42             # now start our B2 session via method below
43 0           $self->b2_authorize_account(); # this adds more goodness to $self for use in the other methods
44              
45 0           return $self;
46             }
47              
48             # method to start your backblaze session: authorize the account and get your api URL's
49             sub b2_authorize_account {
50 0     0 0   my $self = shift;
51            
52             # prepare our authorization header
53 0           my $encoded_auth_string = encode_base64($self->{application_key_id}.':'.$self->{application_key});
54              
55             # add that header in
56 0           $self->{mech}->add_header( 'Authorization' => 'Basic '.$encoded_auth_string );
57            
58             # call the b2_talker() method to authenticate our session
59 0           $self->b2_talker('url' => 'https://api.backblazeb2.com/b2api/v2/b2_authorize_account' );
60              
61             # if we succeeded, load in our authentication and prepare to proceed
62 0 0         if ($self->{current_status} eq 'OK') {
63              
64 0           $self->{account_id} = $self->{b2_response}{accountId};
65 0           $self->{api_url} = $self->{b2_response}{apiUrl};
66 0           $self->{account_authorization_token} = $self->{b2_response}{authorizationToken};
67 0           $self->{download_url} = $self->{b2_response}{downloadUrl};
68            
69             # ready!
70            
71             # otherwise, not ready!
72             } else {
73 0           $self->{b2_login_error} = 1;
74             }
75              
76             }
77              
78             # method to download a file by ID; probably most commonly used
79             sub b2_download_file_by_id {
80 0     0 1   my $self = shift;
81              
82             # required arg is the file ID
83             # option arg is a target directory to auto-save the new file into
84 0           my ($file_id, $save_to_location) = @_;
85              
86 0 0         if (!$file_id) {
87 0           $self->error_tracker('The file_id must be provided for b2_download_file_by_id().');
88 0           return;
89             }
90            
91             # send the request, as a GET
92             $self->b2_talker(
93             'url' => $self->{download_url}.'/b2api/v2/b2_download_file_by_id?fileId='.$file_id,
94             'authorization' => $self->{account_authorization_token},
95 0           );
96            
97             # if the file was found, you will have the relevant headers in %{ $self->{b2_response} }
98             # as well as the file's contents in $self->{b2_response}{file_contents}
99              
100             # if they provided a save-to location (a directory) and the file was found, let's save it out
101 0 0 0       if ($self->{current_status} eq 'OK' && $save_to_location) {
102 0           $self->save_downloaded_file($save_to_location);
103             }
104              
105              
106             }
107              
108             # method to download a file via the bucket name + file name
109             sub b2_download_file_by_name {
110 0     0 1   my $self = shift;
111              
112             # required args are the bucket name and file name
113 0           my ($bucket_name, $file_name, $save_to_location) = @_;
114              
115 0 0 0       if (!$bucket_name || !$file_name) {
116 0           $self->error_tracker('The bucket_name and file_name must be provided for b2_download_file_by_name().');
117 0           return;
118             }
119            
120             # send the request, as a GET
121             $self->b2_talker(
122             'url' => $self->{download_url}.'/file/'.uri_escape($bucket_name).'/'.uri_escape($file_name),
123             'authorization' => $self->{account_authorization_token},
124 0           );
125            
126            
127             # if the file was found, you will have the relevant headers in %{ $self->{b2_response} }
128             # as well as the file's contents in $self->{b2_response}{file_contents}
129              
130             # if they provided a save-to location (a directory) and the file was found, let's save it out
131 0 0 0       if ($self->{current_status} eq 'OK' && $save_to_location) {
132 0           $self->save_downloaded_file($save_to_location);
133             }
134              
135             }
136              
137             # method to save downloaded files into a target location
138             # only call after successfully calling b2_download_file_by_id() or b2_download_file_by_name()
139             sub save_downloaded_file {
140 0     0 0   my $self = shift;
141            
142             # required arg is a valid directory on this file system
143 0           my ($save_to_location) = @_;
144            
145             # error out if that location don't exist
146 0 0 0       if (!$save_to_location || !(-d "$save_to_location") ) {
147 0           $self->error_tracker("Can not auto-save file without a valid location. $save_to_location");
148 0           return;
149             }
150            
151             # make sure they actually downloaded a file
152 0 0 0       if ( !$self->{b2_response}{'X-Bz-File-Name'} || !length($self->{b2_response}{file_contents}) ) {
153 0           $self->error_tracker("Can not auto-save without first downloading a file.");
154 0           return;
155             }
156            
157             # still here? do the save
158            
159             # add the filename
160 0           $save_to_location .= '/'.$self->{b2_response}{'X-Bz-File-Name'};
161            
162             # i really love Path::Tiny
163 0           path($save_to_location)->spew_raw( $self->{b2_response}{file_contents} );
164              
165             }
166              
167             # method to upload a file into Backblaze B2
168             sub b2_upload_file {
169 0     0 1   my $self = shift;
170            
171 0           my (%args) = @_;
172             # this must include valid entries for 'new_file_name' and 'bucket_name'
173             # and it has to include either the raw file contents in 'file_contents'
174             # or a valid location in 'file_location'
175             # also, you can include 'content_type' (which would be the MIME Type'
176             # if you do not want B2 to auto-determine the MIME/content-type
177              
178             # did they provide a file location or path?
179 0 0 0       if ($args{file_location} && -e "$args{file_location}") {
180 0           $args{file_contents} = path( $args{file_location} )->slurp_raw;
181            
182             # if they didn't provide a file-name, use the one on this file
183 0           $args{new_file_name} = path( $args{file_location} )->basename;
184             }
185            
186             # were these file contents either provided or found?
187 0 0         if (!length($args{file_contents})) {
188 0           $self->error_tracker(qq{You must provide either a valid 'file_location' or 'file_contents' arg for b2_upload_file().});
189 0           return;
190             }
191              
192             # check the other needed args
193 0 0 0       if (!$args{bucket_name} || !$args{new_file_name}) {
194 0           $self->error_tracker(qq{You must provide 'bucket_name' and 'new_file_name' args for b2_upload_file().});
195 0           return;
196             }
197            
198             # default content-type
199 0   0       $args{content_type} ||= 'b2/x-auto';
200            
201             # OK, let's continue: get the upload URL and authorization token for this bucket
202 0           $self->b2_get_upload_url( $args{bucket_name} );
203            
204             # send the special request
205             $self->b2_talker(
206             'url' => $self->{bucket_info}{ $args{bucket_name} }{upload_url},
207             'authorization' => $self->{bucket_info}{ $args{bucket_name} }{authorization_token},
208             'file_contents' => $args{file_contents},
209             'special_headers' => {
210             'X-Bz-File-Name' => uri_escape( $args{new_file_name} ),
211             'X-Bz-Content-Sha1' => sha1_hex( $args{file_contents} ),
212             'Content-Type' => $args{content_type},
213             },
214 0           );
215            
216             # b2_talker will handle the rest
217            
218             }
219              
220             # method to get the information needed to upload into a specific B2 bucket
221             sub b2_get_upload_url {
222 0     0 1   my $self = shift;
223            
224             # the bucket name is required
225 0           my ($bucket_name) = @_;
226            
227             # bucket_name is required
228 0 0         if (!$bucket_name) {
229 0           $self->error_tracker('The bucket_name must be provided for b2_get_upload_url().');
230 0           return;
231             }
232              
233             # no need to proceed if we already have done for this bucket this during this session
234             # return if $self->{bucket_info}{$bucket_name}{upload_url};
235             # COMMENTED OUT: It seems like B2 wants a new upload_url endpoint for each upload,
236             # and we may want to upload multiple files into each bucket...so this won't work
237              
238             # if we don't have the info for the bucket name, retrieve the bucket's ID
239 0 0         if (ref($self->{buckets}{$bucket_name}) ne 'HASH') {
240 0           $self->b2_list_buckets($bucket_name);
241             }
242              
243             # send the request
244             $self->b2_talker(
245             'url' => $self->{api_url}.'/b2api/v2/b2_get_upload_url',
246             'authorization' => $self->{account_authorization_token},
247             'post_params' => {
248             'bucketId' => $self->{buckets}{$bucket_name}{bucket_id},
249             },
250 0           );
251            
252             # if we succeeded, get the info for this bucket
253 0 0         if ($self->{current_status} eq 'OK') {
254              
255             $self->{bucket_info}{$bucket_name} = {
256             'upload_url' => $self->{b2_response}{uploadUrl},
257             'authorization_token' => $self->{b2_response}{authorizationToken},
258 0           };
259            
260             }
261              
262             }
263              
264             # method to get information on one bucket or all buckets
265             # specify the bucket-name to search by name
266             sub b2_list_buckets {
267 0     0 1   my $self = shift;
268            
269             # optional first arg is a target bucket name
270             # optional second arg tells us to auto-create a bucket, if the name is provided but it was not found
271 0           my ($bucket_name, $auto_create_bucket) = @_;
272              
273             # send the request
274             $self->b2_talker(
275             'url' => $self->{api_url}.'/b2api/v2/b2_list_buckets',
276             'authorization' => $self->{account_authorization_token},
277             'post_params' => {
278             'accountId' => $self->{account_id},
279 0           'bucketName' => $bucket_name,
280             },
281             );
282            
283             # if we succeeded, load in all the found buckets to $self->{buckets}
284             # that will be a hash of info, keyed by name
285            
286 0 0         if ($self->{current_status} eq 'OK') {
287 0           foreach my $bucket_info (@{ $self->{b2_response}{buckets} }) {
  0            
288 0           $bucket_name = $$bucket_info{bucketName};
289              
290             $self->{buckets}{$bucket_name} = {
291             'bucket_id' => $$bucket_info{bucketId},
292             'bucket_type' => $$bucket_info{bucketType},
293 0           };
294             }
295             }
296              
297             # if that bucket was not found, maybe they want to go ahead and create it?
298 0 0 0       if ($bucket_name && !$self->{buckets}{$bucket_name} && $auto_create_bucket) {
      0        
299 0           $self->b2_bucket_maker($bucket_name);
300             # this will call back to me and get the info
301             }
302            
303             }
304              
305             # method to retrieve file names / info from a bucket
306             # this client library is bucket-name-centric, so it looks for the bucket name as a arg
307             # if there are more than 1000 files, then call this repeatedly
308             sub b2_list_file_names {
309 0     0 1   my $self = shift;
310            
311 0           my ($bucket_name) = @_;
312            
313             # bucket_name is required
314 0 0         if (!$bucket_name) {
315 0           $self->error_tracker('The bucket_name must be provided for b2_list_file_names().');
316 0           return;
317             }
318              
319             # we need the bucket ID
320             # if we don't have the info for the bucket name, retrieve the bucket's ID
321 0 0         if (ref($self->{buckets}{$bucket_name}) ne 'HASH') {
322 0           $self->b2_list_buckets($bucket_name);
323             }
324            
325             # retrieve the files
326             $self->b2_talker(
327             'url' => $self->{api_url}.'/b2api/v2/b2_list_file_names',
328             'authorization' => $self->{account_authorization_token},
329             'post_params' => {
330             'accountId' => $self->{account_id},
331             'bucketId' => $self->{buckets}{$bucket_name}{bucket_id},
332             'startFileName' => $self->{buckets}{$bucket_name}{next_file_name},
333             },
334 0           );
335              
336             # if we succeeded, read in the files
337 0 0         if ($self->{current_status} eq 'OK') {
338 0           $self->{buckets}{$bucket_name}{next_file_name} = $self->{b2_response}{nextFileName};
339              
340             # i am not going to waste the CPU cycles de-camelizing these sub-keys
341             # add to our possibly-started array of file info for this bucket
342             push(
343 0           @{ $self->{buckets}{$bucket_name}{files} },
344 0           @{ $self->{b2_response}{files} }
  0            
345             );
346             }
347              
348            
349             }
350              
351             # method to get info for a specific file
352             # I assume you have the File ID for the file
353             sub b2_get_file_info {
354 0     0 1   my $self = shift;
355            
356             # required arg is the file ID
357 0           my ($file_id) = @_;
358              
359 0 0         if (!$file_id) {
360 0           $self->error_tracker('The file_id must be provided for b2_get_file_info().');
361 0           return;
362             }
363            
364             # kick out if we already have it
365 0 0         return if ref($self->{file_info}{$file_id}) eq 'HASH';
366              
367             # retrieve the file information
368             $self->b2_talker(
369             'url' => $self->{api_url}.'/b2api/v2/b2_get_file_info',
370             'authorization' => $self->{account_authorization_token},
371 0           'post_params' => {
372             'fileId' => $file_id,
373             },
374             );
375              
376             # if we succeeded, read in the information
377 0 0         if ($self->{current_status} eq 'OK') {
378             # i am not going to waste the CPU cycles de-camelizing these sub-keys
379 0           $self->{file_info}{$file_id} = $self->{b2_response};
380             }
381              
382             }
383              
384             # combo method to create a bucket
385             sub b2_bucket_maker {
386 0     0 1   my $self = shift;
387            
388 0           my ($bucket_name) = @_;
389            
390             # can't proceed without the bucket_name
391 0 0         if (!$bucket_name) {
392 0           $self->error_tracker('The bucket_name must be provided for b2_bucket_maker().');
393 0           return;
394             }
395            
396             # create the bucket...
397             $self->b2_talker(
398             'url' => $self->{api_url}.'/b2api/v2/b2_create_bucket',
399             'authorization' => $self->{account_authorization_token},
400             'post_params' => {
401             'accountId' => $self->{account_id},
402 0           'bucketName' => $bucket_name,
403             'bucketType' => 'allPrivate',
404             },
405             );
406            
407 0 0         if ($self->{current_status} eq 'OK') { # if successful...
408            
409             # stash our new bucket into $self->{buckets}
410             $self->{buckets}{$bucket_name} = {
411             'bucket_id' => $self->{b2_response}{bucketId},
412 0           'bucket_type' => 'allPrivate',
413             };
414              
415             }
416            
417             }
418              
419             # method to delete a bucket -- please don't use ;)
420             sub b2_delete_bucket {
421 0     0 1   my $self = shift;
422            
423 0           my ($bucket_name) = @_;
424            
425             # bucket_id is required
426 0 0         if (!$bucket_name) {
427 0           $self->error_tracker('The bucket_name must be provided for b2_delete_bucket().');
428 0           return;
429             }
430            
431             # resolve that bucket_name to a bucket_id
432 0           $self->b2_list_buckets($bucket_name);
433              
434             # send the request
435             $self->b2_talker(
436             'url' => $self->{api_url}.'/b2api/v2/b2_delete_bucket',
437             'authorization' => $self->{account_authorization_token},
438             'post_params' => {
439             'accountId' => $self->{account_id},
440             'bucketId' => $self->{buckets}{$bucket_name}{bucket_id},
441             },
442 0           );
443              
444             }
445              
446             # method to delete a stored file object. B2 thinks of these as 'versions,'
447             # but if you use unique names, one version = one file
448             sub b2_delete_file_version {
449 0     0 1   my $self = shift;
450              
451             # required arguments are the file_name and file_id for the target file
452 0           my ($file_name, $file_id) = @_;
453            
454             # bucket_id is required
455 0 0 0       if (!$file_name || !$file_id) {
456 0           $self->error_tracker('The file_name and file_id args must be provided for b2_delete_file_version().');
457 0           return;
458             }
459              
460             # send the request
461             $self->b2_talker(
462             'url' => $self->{api_url}.'/b2api/v2/b2_delete_file_version',
463             'authorization' => $self->{account_authorization_token},
464 0           'post_params' => {
465             'fileName' => $file_name,
466             'fileId' => $file_id,
467             },
468             );
469            
470            
471             }
472              
473             # generic method to handle communication to B2
474             sub b2_talker {
475 0     0 1   my $self = shift;
476            
477             # args hash must include 'url' for the target API endpoint URL
478             # most other requests will also include a 'post_params' hashref, and 'authorization' value for the header
479             # for the b2_upload_file function, there will be several other headers + a file_contents arg
480 0           my (%args) = @_;
481              
482 0 0         if (!$args{url}) {
483 0           $self->error_tracker('Can not use b2_talker() without an endpoint URL.');
484             }
485              
486             # if they sent an Authorization header, set that value
487 0 0         if ($args{authorization}) {
488 0           $self->{mech}->delete_header( 'Authorization' );
489 0           $self->{mech}->add_header( 'Authorization' => $args{authorization} );
490             }
491              
492 0           my ($response, $response_code, $error_message, $header, @header_keys);
493            
494             # short-circuit if we had difficulty logging in previously
495 0 0         if ($self->{b2_login_error}) {
496              
497             # track the error / set current state
498 0           $self->error_tracker("Problem logging into Backblaze. Please check the 'errors' array in this object.", $args{url});
499              
500 0           return;
501             }
502              
503             # are we uploading a file?
504 0 0         if ($args{url} =~ /b2_upload_file/) {
    0          
505            
506             # add the special headers
507 0           @header_keys = keys %{ $args{special_headers} };
  0            
508 0           foreach $header (@header_keys) {
509 0           $self->{mech}->delete_header( $header );
510 0           $self->{mech}->add_header( $header => $args{special_headers}{$header} );
511             }
512            
513             # now upload the file
514 0           eval {
515 0           $response = $self->{mech}->post( $args{url}, content => $args{file_contents} );
516              
517             # we want this to be 200
518 0           $response_code = $response->{_rc};
519              
520 0           $self->{b2_response} = decode_json( $self->{mech}->content() );
521            
522             };
523            
524             # remove those special headers, cleaned-up for next time
525 0           foreach $header (@header_keys) {
526 0           $self->{mech}->delete_header( $header );
527             }
528            
529             # if not uploading and they sent POST params, we are doing a POST
530             } elsif (ref($args{post_params}) eq 'HASH') {
531 0           eval {
532             # send the POST
533 0           $response = $self->{mech}->post( $args{url}, content => encode_json($args{post_params}) );
534              
535             # we want this to be 200
536 0           $response_code = $response->code;
537              
538             # decode results
539 0           $self->{b2_response} = decode_json( $self->{mech}->content() );
540             };
541            
542             # otherwise, we are doing a GET
543             } else {
544              
545             # attempt the GET
546 0           eval {
547 0           $response = $self->{mech}->get( $args{url} );
548            
549             # we want this to be 200
550 0           $response_code = $response->code;
551              
552             # did we download a file?
553 0 0         if ($response->header( 'X-Bz-File-Name' )) {
    0          
554            
555             # grab those needed headers
556 0           foreach $header ('Content-Length','Content-Type','X-Bz-File-Id','X-Bz-File-Name','X-Bz-Content-Sha1') {
557 0           $self->{b2_response}{$header} = $response->header( $header );
558             }
559            
560             # and the file itself
561 0           $self->{b2_response}{file_contents} = $self->{mech}->content();
562            
563             } elsif ($response_code eq '200') { # no, regular JSON, decode results
564 0           $self->{b2_response} = decode_json( $self->{mech}->content() );
565             }
566             };
567             }
568            
569             # there is a problem if there is a problem
570 0 0 0       if ($@ || $response_code ne '200') {
571 0 0         if ($self->{b2_response}{message}) {
572 0           $error_message = 'API Message: '.$self->{b2_response}{message};
573             } else {
574 0           $error_message = 'Error: '.$@;
575             }
576            
577             # track the error / set current state
578 0           $self->error_tracker($error_message, $args{url}, $response_code);
579              
580             # otherwise, we are in pretty good shape
581             } else {
582            
583 0           $self->{current_status} = 'OK';
584             }
585              
586             }
587              
588             # for tracking errors into $self->{errrors}[];
589             sub error_tracker {
590 0     0 0   my $self = shift;
591            
592 0           my ($error_message, $url, $response_code) = @_;
593             # required is the error message; optional is the URL we were trying to call,
594             # and the HTTP status code returned in that API call
595            
596 0 0         return if !$error_message;
597            
598             # defaults
599 0   0       $url ||= 'N/A';
600 0   0       $response_code ||= 'N/A';
601            
602             # we must currently be in an error state
603 0           $self->{current_status} = 'Error';
604            
605             # track the error
606 0           push(@{ $self->{errors} }, {
  0            
607             'error_message' => $error_message,
608             'url' => $url,
609             'response_code' => $response_code,
610             });
611            
612             }
613              
614             1;
615              
616             __END__