File Coverage

blib/lib/WebService/GoogleAPI/Client.pm
Criterion Covered Total %
statement 106 149 71.1
branch 46 86 53.4
condition 5 11 45.4
subroutine 16 21 76.1
pod 2 6 33.3
total 175 273 64.1


line stmt bran cond sub pod time code
1 3     3   4759 use strictures;
  3         7  
  3         21  
2 3     3   533 use 5.14.0;
  3         12  
3              
4             package WebService::GoogleAPI::Client;
5              
6             our $VERSION = '0.27'; # VERSION
7              
8 3     3   1525 use Data::Dump qw/pp/;
  3         16069  
  3         192  
9 3     3   24 use Moo;
  3         8  
  3         20  
10 3     3   2406 use WebService::GoogleAPI::Client::UserAgent;
  3         13  
  3         38  
11 3     3   1939 use WebService::GoogleAPI::Client::Discovery;
  3         12  
  3         159  
12 3     3   24 use WebService::GoogleAPI::Client::AuthStorage::GapiJSON;
  3         12  
  3         71  
13 3     3   1424 use WebService::GoogleAPI::Client::AuthStorage::ServiceAccount;
  3         10  
  3         95  
14 3     3   22 use Carp;
  3         8  
  3         159  
15 3     3   22 use CHI;
  3         11  
  3         57  
16 3     3   14 use Mojo::Util;
  3         8  
  3         6897  
17              
18             #TODO- batch requests. The only thing necessary is to send a
19             #multipart request as I wrote in that e-mail.
20             #
21             #TODO- allow using promises instead of just calling. Also allow
22             # for full access to the tx instead of assuming it's always
23             # returning the res. Perhaps mix in something that delegates the
24             # json method to the res?
25              
26             # ABSTRACT: Google API Discovery and SDK
27              
28             # FROM MCE POD --

Bank Queuing Model

29              
30              
31             has 'debug' => (is => 'rw', default => 0, lazy => 1);
32             has 'ua' => (
33             handles => [qw/do_autorefresh auth_storage get_access_token scopes user/],
34             is => 'ro',
35             default => sub { WebService::GoogleAPI::Client::UserAgent->new(debug => shift->debug) },
36             lazy => 1,
37             );
38              
39             sub get_scopes_as_array {
40 0     0 0 0 carp 'get_scopes_as_array has deprecated in favor of the shorter "scopes"';
41 0         0 return $_[0]->scopes;
42             }
43              
44             has 'chi' => (
45             is => 'rw',
46             default => sub {
47             CHI->new(driver => 'File', max_key_length => 512, namespace => __PACKAGE__);
48             },
49             lazy => 1
50             );
51              
52             has 'discovery' => (
53             handles => [
54             qw/ discover_all
55             get_method_details
56             get_api_document service_exists
57             methods_available_for_google_api_id list_api_ids /
58             ],
59             is => 'ro',
60             default => sub {
61             my $self = shift;
62             return WebService::GoogleAPI::Client::Discovery->new(
63             debug => $self->debug,
64             ua => $self->ua,
65             chi => $self->chi
66             );
67             },
68             lazy => 1,
69             );
70              
71             ## provides a way of augmenting constructor (new) without overloading it
72             ## see https://metacpan.org/pod/distribution/Moose/lib/Moose/Manual/Construction.pod if like me you an new to Moose
73              
74              
75             #NOTE- in terms of implementing google's ADC
76             # (see https://cloud.google.com/docs/authentication/production and also
77             # https://github.com/googleapis/python-cloud-core/blob/master/google/cloud/client.py)
78             # I looked into it and based on that, it seems that every environment has different reqs,
79             # so it's a maintenance liability
80             sub BUILD {
81 2     2 0 5892 my ($self, $params) = @_;
82              
83 2         27 my ($storage, $file);
84 2 50       16 if ($params->{auth_storage}) {
    50          
    0          
    0          
85 0         0 $storage = $params->{auth_storage};
86             } elsif ($file = $params->{gapi_json}) {
87 2         19 $storage = WebService::GoogleAPI::Client::AuthStorage::GapiJSON->new(path => $file);
88             } elsif ($file = $params->{service_account}) {
89             $storage = WebService::GoogleAPI::Client::AuthStorage::ServiceAccount->new(
90             path => $file,
91             scopes => $params->{scopes}
92 0         0 );
93             } elsif ($file = $ENV{GOOGLE_APPLICATION_CREDENTIALS}) {
94             $storage = WebService::GoogleAPI::Client::AuthStorage::ServiceAccount->new(
95             path => $file,
96             scopes => $params->{scopes}
97 0         0 );
98             }
99 2 50       55 $self->auth_storage($storage) if $storage;
100              
101 2 50       120 $self->user($params->{user}) if (defined $params->{user});
102             }
103              
104              
105             ## ASSUMPTIONS:
106             ## - no complex parameter checking ( eg required mediaUpload in endpoint gmail.users.messages.send ) so user assumes responsiiblity
107             ## TODO: Exceeding a rate limit will cause an HTTP 403 or HTTP 429 Too Many Requests response and your app should respond by retrying with exponential backoff. (https://developers.google.com/gmail/api/v1/reference/quota)
108             ## follows the method spec reqeust reference to the api spec schema api_discovery_struct->{schemas}{Message};
109             ## THE METHOD SPEC CONTAINS
110             ## 'request' => {
111             ## '$ref' => 'Message'
112             ## },
113             ## THE SCHEMA api_discovery_struct->{schemas}{Message} CONTAINS
114             ## 'id' => 'Message',
115             ## 'properties' => {
116             ## 'raw' => {
117             ## {annotations}{required}[ 'gmail.users.drafts.create','gmail.users.drafts.update','gmail.users.messages.insert','gmail.users.messages.send' ]
118              
119             ## NB - uses the ua api_query to execute the server request
120             ##################################################
121             sub api_query {
122 0     0 1 0 my ($self, @params_array) = @_;
123              
124             ## TODO - find a more elgant idiom to do this - pulled this off top of head for quick imeplementation
125 0         0 my $params = {};
126 0 0 0     0 if (scalar(@params_array) == 1 && ref($params_array[0]) eq 'HASH') {
127 0         0 $params = $params_array[0];
128             } else {
129 0         0 $params = {@params_array}; ## what happens if not even count
130             }
131 0 0       0 carp(pp $params) if $self->debug > 10;
132              
133             croak "Missing neccessary scopes to access $params->{api_endpoint_id}"
134 0 0       0 unless $self->has_scope_to_access_api_endpoint($params->{api_endpoint_id});
135              
136             ## used to collect pre-query validation errors - if set we return a response
137             # with 418 I'm a teapot
138 0         0 my @teapot_errors = ();
139             ## pre-query validation if api_id parameter is included
140             @teapot_errors = $self->_process_params($params)
141 0 0       0 if (defined $params->{api_endpoint_id});
142              
143             ## either as param or from discovery
144 0 0       0 if (not defined $params->{path}) {
145 0         0 push @teapot_errors, 'path is a required parameter';
146 0         0 $params->{path} = '';
147             }
148             push @teapot_errors, "Path '$params->{path}' includes unfilled variable after processing"
149 0 0       0 if ($params->{path} =~ /\{.+\}/xms);
150             ## carp and include in 418 TEAPOT ERROR - response body with @teapot errors
151 0 0       0 if (@teapot_errors > 0) {
152 0 0       0 carp(join("\n", @teapot_errors)) if $self->debug;
153 0         0 return Mojo::Message::Response->new(
154             content_type => 'text/plain',
155             code => 418,
156             message => 'Teapot Error - Reqeust blocked before submitting to server with pre-query validation errors',
157             body => join("\n", @teapot_errors)
158             );
159             } else {
160             ## query looks good - send to user agent to execute
161 0         0 return $self->ua->validated_api_query($params);
162             }
163             }
164             ##################################################
165              
166             ##################################################
167             ## _ensure_api_spec_has_defined_fields is really only used to allow carping without undef warnings if needed
168             sub _ensure_api_spec_has_defined_fields {
169 11     11   54 my ($self, $api_discovery_struct) = @_;
170             ## Ensure API Discovery has expected fields defined
171 11         54 foreach my $expected_key (
172             qw/path title ownerName version id discoveryVersion
173             revision description documentationLink rest/
174             ) {
175             $api_discovery_struct->{$expected_key} = ''
176 110 100       306 unless defined $api_discovery_struct->{$expected_key};
177             }
178             $api_discovery_struct->{canonicalName} = $api_discovery_struct->{title}
179 11 100       54 unless defined $api_discovery_struct->{canonicalName};
180 11         33 return $api_discovery_struct;
181             }
182             ##################################################
183              
184             ##################################################
185             sub _process_params_for_api_endpoint_and_return_errors {
186 0     0   0 warn '_process_params_for_api_endpoint_and_return_errors has been deprecated. Please use _process_params';
187 0         0 _process_params(@_);
188             }
189              
190             sub _process_params {
191             ## nb - api_endpoint is a param - param key values are modified through this sub
192 12     12   23483 my ($self, $params) = @_;
193              
194             croak('this should never happen - this method is internal only!')
195 12 50       64 unless defined $params->{api_endpoint_id};
196              
197             ## $api_discovery_struct requried for service base URL
198             my $api_discovery_struct
199 12         463 = $self->_ensure_api_spec_has_defined_fields($self->discovery->get_api_document($params->{api_endpoint_id}));
200             ## remove trailing '/' from baseUrl
201 11         126 $api_discovery_struct->{baseUrl} =~ s/\/$//sxmg;
202              
203             ## if can get discovery data for google api endpoint then continue to perform
204             # detailed checks
205 11         341 my $method_discovery_struct = $self->get_method_details($params->{api_endpoint_id});
206              
207             #save away original path so we can know if it's fiddled with
208             #later
209 10         59 $method_discovery_struct->{origPath} = $method_discovery_struct->{path};
210              
211             ## allow optional user callback pre-processing of method_discovery_struct
212 1         599 $method_discovery_struct = &{ $params->{cb_method_discovery_modify} }($method_discovery_struct)
213             if (defined $params->{cb_method_discovery_modify}
214 10 100 66     63 && ref($params->{cb_method_discovery_modify}) eq 'CODE');
215              
216 10         70 my @teapot_errors = (); ## errors are pushed into this as encountered
217             $params->{method} = $method_discovery_struct->{httpMethod} || 'GET'
218 10 100 50     80 if (not defined $params->{method});
219             push(@teapot_errors,
220             "method mismatch - you requested a $params->{method} which conflicts with discovery spec requirement for $method_discovery_struct->{httpMethod}"
221 10 50       374 ) if ($params->{method} !~ /^$method_discovery_struct->{httpMethod}$/sxim);
222              
223             ## Set default path iff not set by user - NB - will prepend baseUrl later
224 10 100       69 $params->{path} = $method_discovery_struct->{path} unless $params->{path};
225 10 50       33 push @teapot_errors, 'path is a required parameter' unless $params->{path};
226              
227 10         81 push @teapot_errors,
228             $self->_interpolate_path_parameters_append_query_params_and_return_errors($params, $method_discovery_struct);
229              
230 10         46 $params->{path} =~ s/^\///sxmg; ## remove leading '/' from path
231             $params->{path} = "$api_discovery_struct->{baseUrl}/$params->{path}"
232 10 100       170 unless $params->{path} =~ /^$api_discovery_struct->{baseUrl}/ixsmg; ## prepend baseUrl if required
233              
234             ## if errors - add detail available in the discovery struct for the method and service to aid debugging
235 10 100       95 push @teapot_errors,
236             qq{ $api_discovery_struct->{title} $api_discovery_struct->{rest} API into $api_discovery_struct->{ownerName} $api_discovery_struct->{canonicalName} $api_discovery_struct->{version} with id $method_discovery_struct->{id} as described by discovery document version $api_discovery_struct->{discoveryVersion} revision $api_discovery_struct->{revision} with documentation at $api_discovery_struct->{documentationLink} \nDescription: $method_discovery_struct->{description}\n}
237             if @teapot_errors;
238              
239 10         6170 return @teapot_errors;
240             }
241             ##################################################
242              
243             #small subs to convert between these_types to theseTypes of params
244             #TODO- should probs move this into a Util module
245 43 50   43 0 112 sub camel { shift if @_ > 1; $_[0] =~ s/ _(\w) /\u$1/grx }
  43         232  
246 0 0   0 0 0 sub snake { shift if @_ > 1; $_[0] =~ s/([[:upper:]])/_\l$1/grx }
  0         0  
247              
248             ##################################################
249             sub _interpolate_path_parameters_append_query_params_and_return_errors {
250 10     10   34 my ($self, $params, $discovery_struct) = @_;
251 10         23 my @teapot_errors = ();
252              
253 10         19 my @get_query_params = ();
254              
255             #create a hash of whatever the expected params may be
256 10         23 my %path_params;
257 10         94 my $param_regex = qr/\{ \+? ([^\}]+) \}/x;
258 10 100       51 if ($params->{path} ne $discovery_struct->{origPath}) {
259              
260             #check if the path was given as a custom path. If it is, just
261             #interpolate things directly, and assume the user is responsible
262 3         56 %path_params = map { $_ => 'custom' } ($params->{path} =~ /$param_regex/xg);
  3         14  
263             } else {
264              
265             #label which param names are from the normal path and from the
266             #flat path
267 7         158 %path_params = map { $_ => 'plain' } ($discovery_struct->{path} =~ /$param_regex/xg);
  6         58  
268 7 100       29 if ($discovery_struct->{flatPath}) {
269 6         77 %path_params = (%path_params, map { $_ => 'flat' } ($discovery_struct->{flatPath} =~ /$param_regex/xg));
  13         59  
270             }
271             }
272              
273              
274             #switch the path we're dealing with to the flat path if any of
275             #the parameters match the flat path
276             $params->{path} = $discovery_struct->{flatPath}
277 9         48 if grep { $_ eq 'flat' }
278 10 100       32 map { $path_params{ camel $_} || () } keys %{ $params->{options} };
  17 100       56  
  10         65  
279              
280              
281             #loop through params given, placing them in the path or query,
282             #or leaving them for the request body
283 10         24 for my $param_name (keys %{ $params->{options} }) {
  10         41  
284              
285             #first check if it needs to be interpolated into the path
286 17 100 66     356 if ($path_params{$param_name} || $path_params{ camel $param_name}) {
287              
288             #pull out the value from the hash, and remove the key
289 9         27 my $param_value = delete $params->{options}{$param_name};
290              
291             #Camelize the param name if not passed in customly, allowing
292             #the user to pass in camelCase or snake_case param names.
293             #This implictly allows for a non camelCased param to be left
294             #alone in a custom param.
295 9 50       40 $param_name = camel $param_name if $path_params{ camel $param_name};
296              
297             #first deal with any param that doesn't have a plus, b/c
298             #those just get interpolated
299 9         208 $params->{path} =~ s/\{$param_name\}/$param_value/;
300              
301             #if there's a plus in the path spec, we need more work
302 9 100       142 if ($params->{path} =~ /\{ \+ $param_name \}/x) {
303 4         20 my $pattern = $discovery_struct->{parameters}{$param_name}{pattern};
304              
305             #if the given param matches google's pattern for the
306             #param, just interpolate it straight
307 4 100       141 if ($param_value =~ /$pattern/) {
308 1         25 $params->{path} =~ s/\{\+$param_name\}/$param_value/;
309             } else {
310              
311             #N.B. perhaps support passing an arrayref or csv for those
312             #params such as jobs.projects.jobs.delete which have two
313             #dynamic parts. But for now, unnecessary
314              
315             #remove the regexy parts of the pattern to interpolate it
316             #into the path, assuming the user has provided just the
317             #dynamic portion of the param.
318 3         57 $pattern =~ s/^\^ | \$$//gx;
319 3         15 my $placeholder = qr/ \[ \^ \/ \] \+ /x;
320 3         53 $params->{path} =~ s/\{\+$param_name\}/$pattern/x;
321 3         25 $params->{path} =~ s/$placeholder/$param_value/x;
322             push @teapot_errors, "Not enough parameters given for {+$param_name}."
323 3 100       35 if $params->{path} =~ /$placeholder/;
324             }
325             }
326              
327             #skip to the next run, so I don't need an else clause later
328 9         47 next; #I don't like nested if-elses
329             }
330              
331             #if it's not in the list of params, then it goes in the
332             #request body, and our work here is done
333 8 100       50 next unless $discovery_struct->{parameters}{$param_name};
334              
335             #it must be a GET type query param, so push the name and value
336             #on our param stack and take it off of the options list
337 3         17 push @get_query_params, $param_name, delete $params->{options}{$param_name};
338             }
339              
340             #if there are any query params...
341 10 100       52 if (@get_query_params) {
342              
343             #interpolate and escape the get query params built up in our
344             #former for loop
345 3 100       20 $params->{path} .= ($params->{path} =~ /\?/) ? '&' : '?';
346 3         68 $params->{path} .= Mojo::Parameters->new(@get_query_params);
347             }
348              
349             #interpolate default value for path params if not given. Needed
350             #for things like the gmail API, where userID is 'me' by default
351 10         1474 for my $param_name ($params->{path} =~ /$param_regex/g) {
352 2         9 my $param_value = $discovery_struct->{parameters}{$param_name}{default};
353 2 50       8 $params->{path} =~ s/\{$param_name\}/$param_value/ if $param_value;
354             }
355 10         88 push @teapot_errors, "Missing a parameter for {$_}." for $params->{path} =~ /$param_regex/g;
356              
357             #print pp $params;
358             #exit;
359 10         66 return @teapot_errors;
360             }
361             ##################################################
362              
363              
364             sub has_scope_to_access_api_endpoint {
365 0     0 1   my ($self, $api_ep) = @_;
366 0           my $method_spec = $self->get_method_details($api_ep);
367              
368 0 0         if (keys %$method_spec) {
369 0           my $configured_scopes = $self->scopes; ## get user scopes arrayref
370             ## create a hashindex to facilitate quick lookups
371 0           my %configured_scopes_hash = map { (s/\/$//xr, 1) } @$configured_scopes;
  0            
372             ## NB r switch as per https://www.perlmonks.org/?node_id=613280 to filter
373             #out any trailing '/'
374 0           my $granted = 0;
375             ## assume permission not granted until we find a matching scope
376 0           my $required_scope_count = 0;
377             ## if the final count of scope constraints = 0 then we will assume permission is granted - this has proven necessary for the experimental Google My Business because scopes are not defined in the current discovery data as at 14/10/18
378 0           for my $method_scope (map {s/\/$//xr} @{ $method_spec->{scopes} }) {
  0            
  0            
379 0           $required_scope_count++;
380 0 0         $granted = 1 if defined $configured_scopes_hash{$method_scope};
381 0 0         last if $granted;
382             }
383 0 0         $granted = 1 if $required_scope_count == 0;
384 0           return $granted;
385             } else {
386 0           return 0;
387             }
388              
389             }
390              
391              
392             #TODO: Consider rename to return_fetched_google_v1_apis_discovery_structure
393             #
394             #TODO - handle auth required error and resubmit request with OAUTH headers if response indicates
395             # access requires auth ( when exceed free access limits )
396              
397              
398             1;
399              
400             __END__