File Coverage

blib/lib/Dubber/API.pm
Criterion Covered Total %
statement 52 166 31.3
branch 0 58 0.0
condition 0 10 0.0
subroutine 26 42 61.9
pod n/a
total 78 276 28.2


line stmt bran cond sub pod time code
1             package Dubber::API;
2              
3             # ABSTRACT: Interact with the Dubber Call Recording platform API
4              
5 1     1   608 use strict;
  1         2  
  1         25  
6 1     1   3 use warnings;
  1         2  
  1         39  
7              
8             our $VERSION = '0.011'; # VERSION
9             our $AUTHORITY = 'cpan:NIGELM'; # AUTHORITY
10              
11 1     1   397 use Mouse;
  1         23396  
  1         3  
12 1     1   944 use Method::Signatures;
  1         37296  
  1         8  
13 1     1   363 use Cpanel::JSON::XS;
  1         2  
  1         60  
14 1     1   433 use Crypt::Digest::MD5 qw[md5_b64];
  1         3859  
  1         50  
15 1     1   403 use Crypt::Digest::SHA256 qw[sha256_hex];
  1         529  
  1         46  
16 1     1   730 use DateTime;
  1         411182  
  1         56  
17 1     1   525 use HTTP::Request;
  1         14732  
  1         42  
18 1     1   419 use LWP::ConnCache;
  1         1093  
  1         30  
19 1     1   614 use LWP::UserAgent;
  1         19502  
  1         35  
20 1     1   9 use Try::Tiny;
  1         2  
  1         55  
21 1     1   5 use URI;
  1         12  
  1         192  
22              
23             with 'Web::API';
24              
25             # ------------------------------------------------------------------------
26              
27              
28             # ------------------------------------------------------------------------
29              
30             has api_version => (
31             is => 'ro',
32             isa => 'Num',
33             default => sub {'1'},
34             );
35              
36             has region => (
37             is => 'ro',
38             isa => 'Str',
39             default => sub {'sandbox'},
40             );
41              
42             # ------------------------------------------------------------------------
43              
44             has client_id => (is => 'ro', isa=> 'Str', required => 1);
45             has client_secret => (is => 'ro', isa=> 'Str', required => 1);
46             has auth_id => (is => 'ro', isa=> 'Str', required => 1);
47             has auth_secret => (is => 'ro', isa=> 'Str', required => 1);
48             has max_part_size => (is => 'ro', isa=> 'Int', default => sub { 5 * 1024 * 1024});
49             # ------------------------------------------------------------------------
50             has _auth_token => (is => 'rw', isa => 'Str', predicate => '_has_auth_token', clearer => 'clear_auth_token',);
51             has _auth_refresh_token => (is => 'rw', isa => 'Str',);
52             has _auth_token_expiry => (is => 'rw', isa => 'DateTime',);
53              
54 1 0   1   93766 method _new_auth_token ($refresh_token?) {
  0     0      
  0            
  0            
55 0           my $uri = URI->new( $self->base_url . '/token' );
56 0           my $request = HTTP::Request->new( 'POST', $uri );
57 0           $request->header(
58             'Accept' => 'application/json',
59             'Content-type' => 'application/x-www-form-urlencoded'
60             );
61              
62             # token request content
63 0           my $content = { client_id => $self->client_id, client_secret => $self->client_secret };
64 0 0         if ($refresh_token) {
65              
66             # refresh request content...
67 0           $content->{refresh_token} = $refresh_token;
68 0           $content->{grant_type} = 'refresh_token';
69             }
70             else {
71 0           $content->{username} = $self->auth_id;
72 0           $content->{password} = $self->auth_secret;
73 0           $content->{grant_type} = 'password';
74             }
75 0           $request->content( $self->encode( $content, 'application/x-www-form-urlencoded' ) );
76              
77             # send and decode query
78 0           $self->_clear_state;
79 0           my $response = $self->request($request);
80 0           my $answer = $self->format_response($response);
81 0           $self->_clear_state;
82              
83             # unpack components
84 0           $self->_auth_token( $answer->{content}{access_token} );
85 0           $self->_auth_refresh_token( $answer->{content}{refresh_token} );
86 0           $self->_auth_token_expiry( DateTime->now->add( seconds => $answer->{content}{expires_in} - 20 ) );
87              
88             # return the token
89 0           return $answer->{content}{access_token};
90             }
91              
92             # ------------------------------------------------------------------------
93 1 0   1   926 method is_authenticated () {
  0     0      
  0            
94 0 0 0       return 1 if ( ( $self->_has_auth_token ) and ( DateTime->now < $self->_auth_token_expiry ) );
95 0           return;
96             }
97              
98             # ------------------------------------------------------------------------
99 1 0   1   652 method auth_token () {
  0     0      
  0            
100 0 0         if ( $self->_has_auth_token ) {
101 0 0         if ( DateTime->now > $self->_auth_token_expiry ) {
102 0           $self->_new_auth_token( $self->_auth_refresh_token );
103             }
104             }
105             else {
106 0           $self->_new_auth_token();
107             }
108 0           return $self->_auth_token;
109             }
110              
111             # ------------------------------------------------------------------------
112 1 0   1   719 method auth_lifetime_seconds () {
  0     0      
  0            
113 0 0         return 0 unless ( $self->is_authenticated );
114 0           my $diff = $self->_auth_token_expiry->delta_ms( DateTime->now );
115 0           return ( abs( $diff->minutes * 60 ) + abs( $diff->seconds ) );
116             }
117              
118             # ------------------------------------------------------------------------
119             has header => (
120             is => 'rw',
121             isa => 'HashRef',
122             lazy_build => 1,
123             );
124              
125 1 0   1   680 method _build_header () {
  0     0      
  0            
126 0           return { Authorization => 'Bearer ' . $self->auth_token };
127             }
128              
129             # ------------------------------------------------------------------------
130             has connection_cache => (
131             is => 'ro',
132             isa => 'LWP::ConnCache',
133             lazy_build => 1,
134             );
135              
136 1 0   1   646 method _build_connection_cache () { return LWP::ConnCache->new( total_capacity => 5 ); }
  0     0      
  0            
  0            
137              
138             # ------------------------------------------------------------------------
139             has json_coder => (
140             is => 'ro',
141             isa => 'Cpanel::JSON::XS',
142             lazy_build => 1,
143             );
144              
145 1 0   1   615 method _build_json_coder () { return Cpanel::JSON::XS->new->utf8; }
  0     0      
  0            
  0            
146              
147             # ------------------------------------------------------------------------
148             has endpoints => (
149             is => 'ro',
150             default => sub {
151             { root => { path => '/' },
152              
153             # Group Methods (Group Authentication Required)
154             get_group_details => { path => 'groups/:group_id' },
155             get_group_accounts => { path => 'groups/:group_id/accounts' },
156             create_child_group => { path => 'groups/:group_id/groups', method => 'POST' },
157             create_account => { path => 'accounts', method => 'POST' },
158             get_group_unidentified_recordings => { path => 'groups/:group_id/unidentified_recordings' },
159             create_group_unidentified_recording =>
160             { path => 'groups/:group_id/unidentified_recordings', method => 'POST' },
161              
162             # Account Methods
163             get_account_details => { path => 'accounts/:account_id' },
164             update_account_details => { path => 'accounts/:account_id', method => 'PUT' },
165              
166             # Recording Methods
167             get_account_recordings => { path => 'accounts/:account_id/recordings', method => 'GET' },
168             create_recording => { path => 'accounts/:account_id/recordings', method => 'POST' },
169             get_recording_details => { path => 'recordings/:recording_id', method => 'GET' },
170             get_recording_waveform => { path => 'recordings/:recording_id/waveform', method => 'GET' },
171             delete_recording => { path => 'recordings/:recording_id', method => 'DELETE' },
172             update_recording_metadata => { path => 'recordings/:recording_id/metadata', method => 'PUT' },
173             add_recording_tags => { path => 'recordings/:recording_id/tags', method => 'POST' },
174             delete_recording_tags => { path => 'recordings/:recording_id/tags', method => 'DELETE' },
175              
176             # Multipart Recording Methods
177             create_multipart_recording => { path => 'accounts/:account_id/recordings', method => 'POST' },
178             get_recording_upload_part => {
179             path => 'recordings/:recording_id/upload',
180             method => 'GET',
181              
182             #mandatory => [qw(part_number content_md5 content_sha256)]
183             },
184             put_complete_multipart_recording_upload =>
185              
186             #{ path => 'recordings/:recording_id/complete_upload', method => 'PUT', mandatory => [qw(parts)] },
187             { path => 'recordings/:recording_id/complete_upload', method => 'PUT', },
188             abort_multipart_recording_upload => { path => 'recordings/:recording_id', method => 'DELETE' },
189              
190             # User Methods
191             get_account_users => { path => 'accounts/:account_id/users', method => 'GET' },
192             create_account_user => { path => 'accounts/:account_id/users', method => 'POST' },
193             get_user_details => { path => 'users/:user_id', method => 'GET' },
194             delete_user => { path => 'users/:user_id', method => 'DELETE' },
195             update_user => { path => 'users/:user_id', method => 'PUT' },
196              
197             # Profile Methods
198             get_profile => { path => 'profile', method => 'GET' },
199              
200             # Notification (Rest Hook) Methods
201             create_group_notification => { path => 'groups/:group_id/notifications', method => 'POST' }
202             , # (Group Authentication Only)
203             get_group_notifications => { path => 'groups/:group_id/notifications', method => 'GET' }
204             , # (Group Authentication Only)
205             create_account_notification => { path => 'accounts/:account_id/notifications', method => 'POST' },
206             get_account_notifications => { path => 'accounts/:account_id/notifications', method => 'GET' },
207             get_notification_details => { path => 'notifications/:notification_id', method => 'GET' },
208             update_notification => { path => 'notifications/:notification_id', method => 'PUT' },
209             activate_notification => { path => 'notifications/:notification_id/activate', method => 'POST' },
210             release_unclaimed_notification => { path => 'notifications/:notification_id/unclaimed', method => 'GET' },
211             delete_notification => { path => 'notifications/:notification_id', method => 'DELETE' },
212              
213             # Dub.Point (Group Authentication Required)
214             get_group_unidentified_dub_points =>
215             { path => 'groups/:group_id/unidentified_dub_points', method => 'GET' },
216             get_account_dub_points => { path => 'accounts/:account_id/dub_points', method => 'GET' },
217             create_account_dub_point => { path => 'accounts/:account_id/dub_points', method => 'POST' },
218             get_dub_point_details => { path => 'dub_points/:dub_point_id', method => 'GET' },
219             find_dub_point => {
220             path => 'dub_points/find',
221             method => 'GET',
222             mandatory => [qw(external_type service_provider external_group external_identifier)]
223             },
224              
225             # OAuth 2 Methods
226             revoke_access_token => { path => 'revoke', method => 'POST' },
227              
228             };
229             },
230             );
231              
232 1 0   1   856 method commands () { return $self->endpoints; }
  0     0      
  0            
  0            
233              
234             # ------------------------------------------------------------------------
235 1 0   1   3427 method upload_recording_mp3_file ($account_id, $call_metadata, $mp3_file_or_data) {
  0 0   0      
  0 0          
  0 0          
  0            
  0            
  0            
  0            
  0            
236 0           my @data_parts = $self->_split_recording_data($mp3_file_or_data);
237              
238             # create the recording object
239 0           my $res = $self->create_recording( account_id => $account_id, %{$call_metadata} );
  0            
240 0 0         if ( $res->{code} eq '201' ) {
241 0           my $recording_id = $res->{content}{id};
242 0           my $return_status;
243             try {
244 0     0     my @etag_parts;
245 0           my $part_number = 0;
246 0           foreach my $part_data (@data_parts) {
247 0           my $md5_b64 = md5_b64($part_data);
248 0           my $sha256_hex = sha256_hex($part_data);
249 0           ++$part_number;
250              
251             # request to upload a recording part
252 0           my $upload_req_res = $self->get_recording_upload_part(
253             recording_id => $recording_id,
254             part_number => $part_number,
255             content_md5 => $md5_b64,
256             content_sha256 => $sha256_hex
257             );
258 0 0         if ( $upload_req_res->{code} eq '200' ) {
259              
260             # upload the recording part
261 0           my $put_uri = URI->new( $upload_req_res->{content}{url} );
262 0           my $put_request = HTTP::Request->new( 'PUT', $put_uri );
263             $put_request->header(
264             'Accept' => 'application/json',
265             'Content-type' => 'audio/mpeg', # apparently right!
266             'Authorization' => $upload_req_res->{content}{authorization},
267             'X-Amz-Date' => $upload_req_res->{content}{'X-Amz-Date'},
268             'Host' => $upload_req_res->{content}{Host},
269 0           'Content-Md5' => $md5_b64,
270             'X-Amz-Content-Sha256' => $sha256_hex
271             );
272 0           $put_request->content($part_data);
273 0           my $put_response = $self->request($put_request);
274 0 0         if ( $put_response->is_success ) {
275 0           push( @etag_parts, { part_number => $part_number, e_tag => $put_response->header('ETag') } );
276             }
277             else {
278 0           die "Unable to PUT recording upload - $!";
279             }
280             }
281             else {
282 0           die "Unable to request recording upload - $!";
283             }
284             }
285 0           $return_status = $self->put_complete_multipart_recording_upload(
286             recording_id => $recording_id,
287             parts => \@etag_parts
288             );
289             }
290             catch {
291             # it failed - delete the part uploaded chunk
292 0     0     $self->abort_multipart_recording_upload( recording_id => $recording_id );
293 0           };
294 0           return $return_status;
295             }
296 0           return $res;
297             }
298              
299             # ------------------------------------------------------------------------
300 1 0   1   2499 method _split_recording_data ($file_or_data) {
  0 0   0      
  0            
  0            
  0            
301 0           my $data;
302 0 0 0       if ( ref($file_or_data) and $file_or_data->isa('Path::Tiny') ) {
303 0 0         my $stat = $file_or_data->stat or die "File $file_or_data does not exist - $!\n";
304 0           $data = $file_or_data->slurp_raw;
305             }
306             else {
307 0           $data = $file_or_data;
308             }
309              
310             # split into chunks of $max_part_size
311 0 0         my $template =
312             sprintf( 'A%d', $self->max_part_size ) x int( length($data) / $self->max_part_size )
313             . ( length($data) % $self->max_part_size )
314             ? 'A*'
315             : '';
316 0           my @chunks = unpack( $template, $data );
317 0           return @chunks;
318             }
319              
320             # ------------------------------------------------------------------------
321 1 0   1   2010 method BUILD ($args) {
  0 0   0      
  0            
  0            
  0            
322 0   0       $self->user_agent( __PACKAGE__ . ' ' . ( $Dubber::API::VERSION || '' ) );
323 0           $self->base_url( 'https://api.dubber.net/' . $self->region . '/v' . $self->api_version );
324 0           $self->content_type('application/json');
325 0   0 0     $self->decoder( sub { $self->json_coder->decode( shift || '{}' ) } );
  0            
326             }
327              
328             # ------------------------------------------------------------------------
329 1 0   1   755 method _build_agent () {
  0     0      
  0            
330 0           return LWP::UserAgent->new(
331             agent => $self->user_agent,
332             cookie_jar => $self->cookies,
333             timeout => $self->timeout,
334             con_cache => $self->connection_cache,
335             keep_alive => 1,
336             ssl_opts => { verify_hostname => $self->strict_ssl },
337             );
338             }
339              
340             # ------------------------------------------------------------------------
341 1 0   1   675 method _clear_state () { $self->clear_decoded_response; $self->clear_response; }
  0     0      
  0            
  0            
  0            
342              
343             # ------------------------------------------------------------------------
344              
345             __PACKAGE__->meta->make_immutable;
346              
347             1;
348              
349             __END__
350              
351             =pod
352              
353             =encoding UTF-8
354              
355             =head1 NAME
356              
357             Dubber::API - Interact with the Dubber Call Recording platform API
358              
359             =head1 VERSION
360              
361             version 0.011
362              
363             This is undocumented to an amazing degree at present!
364              
365             =head1 AUTHOR
366              
367             Nigel Metheringham <nigelm@cpan.org>
368              
369             =head1 COPYRIGHT AND LICENSE
370              
371             This software is copyright (c) 2017 by Nigel Metheringham.
372              
373             This is free software; you can redistribute it and/or modify it under
374             the same terms as the Perl 5 programming language system itself.
375              
376             =cut