File Coverage

blib/lib/Mojo/GoogleAnalytics.pm
Criterion Covered Total %
statement 144 157 91.7
branch 47 62 75.8
condition 31 58 53.4
subroutine 25 30 83.3
pod 9 9 100.0
total 256 316 81.0


line stmt bran cond sub pod time code
1             package Mojo::GoogleAnalytics;
2 5     5   655281 use Mojo::Base -base;
  5         56  
  5         36  
3              
4 5     5   2142 use Mojo::Collection;
  5         10827  
  5         168  
5 5     5   1441 use Mojo::File 'path';
  5         66952  
  5         282  
6 5     5   1908 use Mojo::GoogleAnalytics::Report;
  5         17  
  5         34  
7 5     5   2134 use Mojo::JSON qw(decode_json false true);
  5         91964  
  5         326  
8 5     5   2217 use Mojo::Promise;
  5         469201  
  5         47  
9 5     5   2364 use Mojo::JWT;
  5         9114  
  5         37  
10 5     5   2608 use Mojo::UserAgent;
  5         395640  
  5         38  
11              
12 5   50 5   256 use constant DEBUG => $ENV{MOJO_GA_DEBUG} || 0;
  5         13  
  5         12502  
13              
14             our $VERSION = '0.04';
15              
16             our %QUERY_SORT_ORDER = (asc => 'ASCENDING', desc => 'DESCENDING', x => 'SORT_ORDER_UNSPECIFIED');
17              
18             our %QUERY_TRANSLATOR = (
19             'eq' => [qw(dimension EXACT)],
20             '^' => [qw(dimension BEGINS_WITH)],
21             '$' => [qw(dimension ENDS_WITH)],
22             '=~' => [qw(dimension REGEXP)],
23             'substr' => [qw(dimension PARTIAL)],
24             '==' => [qw(metric EQUAL)],
25             '>' => [qw(metric GREATER_THAN)],
26             '<' => [qw(metric LESS_THAN)],
27             );
28              
29             has authorization => sub { +{} };
30             has client_email => sub { Carp::confess('client_email is required') };
31             has client_id => sub { Carp::confess('client_id is required') };
32             has private_key => sub { Carp::confess('private_key is required') };
33             has ua => sub { Mojo::UserAgent->new(max_redirects => 3) };
34             has view_id => '';
35              
36             sub authorize {
37 2     2 1 6 my ($self, $cb) = @_;
38 2 100       6 my @ua_args = $self->_authorize_ua_args or return $self;
39              
40 1 50       4 if ($cb) {
41 0     0   0 $self->ua->post(@ua_args, sub { $self->$cb($self->_process_authorize_response($_[1])) });
  0         0  
42             }
43             else {
44 1         10 my ($err, $res) = $self->_process_authorize_response($self->ua->post(@ua_args));
45 1 50       4 die $err if $err;
46             }
47              
48 1         6 return $self;
49             }
50              
51             sub authorize_p {
52 2     2 1 4 my $self = shift;
53              
54 2 100       5 return Mojo::Promise->new->resolve unless my @ua_args = $self->_authorize_ua_args;
55             return $self->ua->post_p(@ua_args)->then(sub {
56 1     1   65985 my $err = $self->_process_authorize_response($_[0]);
57 1 50       13 return $err ? Mojo::Promise->new->reject($err) : ();
58 1         5 });
59             }
60              
61             sub batch_get {
62 3     3 1 810 my ($self, $query, $cb) = @_;
63 3         7 my @ua_args;
64              
65 3 50       14 @ua_args = (Mojo::URL->new($self->{batch_get_uri}), {},
66             json => {reportRequests => ref $query eq 'ARRAY' ? $query : [$query]});
67              
68 3 100       162 if ($cb) {
69             my $p = $self->authorize_p->then(sub {
70 1     1   201 warn "[GoogleAnalytics] Getting analytics data from $ua_args[0] ...\n", if DEBUG;
71 1         13 $ua_args[1] = {Authorization => $self->authorization->{header}};
72 1         14 return $self->ua->post_p(@ua_args);
73             })->then(sub {
74 1     1   2688 my $res = $self->_process_batch_get_response($query, shift);
75 1 50       5 return ref $cb ? $self->$cb('', $res) : $res;
76             })->catch(sub {
77 0 0   0   0 return ref $cb ? $self->$cb(shift, {}) : shift;
78 1         5 });
79              
80 1 50       244 return ref $cb ? $self : $p;
81             }
82             else {
83 2         7 $ua_args[1] = {Authorization => $self->authorize->authorization->{header}};
84 2         12 warn "[GoogleAnalytics] Getting analytics data from $ua_args[0] ...\n", if DEBUG;
85 2         7 my ($err, $res) = $self->_process_batch_get_response($query, $self->ua->post(@ua_args));
86 2 100       17 die $err if $err;
87 1         46 return $res;
88             }
89             }
90              
91             sub batch_get_p {
92 1     1 1 3 shift->batch_get(shift, 1);
93             }
94              
95             sub from_file {
96 0     0 1 0 my ($self, $file) = @_;
97 0         0 my $attrs = decode_json(path($file)->slurp);
98              
99 0         0 for my $attr (keys %$attrs) {
100 0   0     0 $self->{$attr} ||= $attrs->{$attr};
101 0         0 warn qq([Mojo::GoogleAnalytics] Read "$attr" from $file\n) if DEBUG;
102             }
103              
104 0         0 return $self;
105             }
106              
107             sub get_report {
108 0     0 1 0 my ($self, $query, $cb) = @_;
109 0         0 return $self->batch_get($self->_query_translator(%$query), $cb);
110             }
111              
112             sub get_report_p {
113 0     0 1 0 my ($self, $query) = @_;
114 0         0 $self->batch_get_p($self->_query_translator(%$query));
115             }
116              
117             sub new {
118 2     2 1 174 my $class = shift;
119 2 50       11 my $file = @_ % 2 ? shift : undef;
120 2         19 my $self = $class->SUPER::new(@_);
121              
122 2 50       23 $self->from_file($file) if $file;
123 2   50     19 $self->{token_uri} ||= 'https://accounts.google.com/o/oauth2/token';
124 2   50     11 $self->{auth_scope} ||= 'https://www.googleapis.com/auth/analytics.readonly';
125 2   50     11 $self->{batch_get_uri} ||= 'https://analyticsreporting.googleapis.com/v4/reports:batchGet';
126 2 100       8 $self->mock if $ENV{TEST_MOJO_GA_BATCH_GET_DIR};
127              
128 2         10 return $self;
129             }
130              
131             sub mock {
132 1     1 1 3 my ($self, $args) = @_;
133 1   33     7 $self->{batch_get_dir} = $args->{batch_get_dir} // $ENV{TEST_MOJO_GA_BATCH_GET_DIR} // File::Spec->tmpdir;
      33        
134              
135 1         509 require Mojolicious;
136 1         45102 my $server = $self->ua->server;
137 1 50       54 $server->app(Mojolicious->new) unless $server->app;
138              
139 1         12693 my $mock_r = $server->app->routes;
140 1         13 Scalar::Util::weaken($self);
141 1         3 for my $name (qw(batch_get_uri token_uri)) {
142 2         497 my $cb = $self->can("_mocked_action_$name");
143 2         14 $self->{$name} = sprintf '/mocked/ga%s', Mojo::URL->new($self->{$name})->path;
144 2 50 50 4   578 $mock_r->any($self->{$name} => $args->{$name} || sub { $self->$cb(@_) })->name($name) unless $mock_r->lookup($name);
  4         27545  
145             }
146              
147 1         321 return $self;
148             }
149              
150             sub _authorize_ua_args {
151 4     4   6 my $self = shift;
152 4         7 my $time = time;
153 4         12 my $prev = $self->authorization;
154 4         14 my ($jwt, @ua_args);
155              
156 4         5 warn "[GoogleAnalytics] Authorization exp: @{[$prev->{exp} ? $prev->{exp} : -1]} < $time\n" if DEBUG;
157 4 100 66     28 return if $prev->{exp} and $time < $prev->{exp};
158              
159 2         8 $ua_args[0] = Mojo::URL->new($self->{token_uri});
160 2         97 $jwt = Mojo::JWT->new->algorithm('RS256')->secret($self->private_key);
161              
162             $jwt->claims({
163             aud => $ua_args[0]->to_string,
164             exp => $time + 3600,
165             iat => $time,
166             iss => $self->client_email,
167             scope => $self->{auth_scope},
168 2         52 });
169              
170 2         474 push @ua_args, (form => {grant_type => 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion => $jwt->encode});
171 2         8653 warn "[GoogleAnalytics] Authenticating with $ua_args[0] ...\n", if DEBUG;
172              
173 2         18 return @ua_args;
174             }
175              
176             sub _mocked_action_batch_get_uri {
177 3     3   7 my ($self, $c) = @_;
178 3         11 my $file = Mojo::File::path($self->{batch_get_dir}, sprintf '%s.json', Mojo::Util::md5_sum($c->req->text));
179              
180 3         279 warn "[GoogleAnalytics] Reading dummy response file $file (@{[-r $file ? 1 : 0]})\n" if DEBUG;
181 3 100       15 return $c->render(data => $file->slurp) if -r $file;
182 1         45 return $c->render(json => {error => {message => qq(Could not read dummy response file "$file".)}}, status => 500);
183             }
184              
185             sub _mocked_action_token_uri {
186 1     1   12 my ($self, $c) = @_;
187 1         9 $c->render(json => {access_token => 'some-dummy-token', expires_in => 3600, token_type => 'Bearer'});
188             }
189              
190             sub _process_authorize_response {
191 2     2   3013 my ($self, $tx) = @_;
192 2         8 my $err = $tx->error;
193 2         33 my $res = $tx->res->json;
194 2         698 my $url = $tx->req->url;
195              
196 2 100       19 if ($err) {
197             $err = sprintf '%s >>> %s (%s)', $url, $res->{error_description} || $err->{message} || 'Unknown error',
198 1   50     14 $err->{code} || 0;
      50        
199 1         209 warn "[GoogleAnalytics] $err\n", if DEBUG;
200             }
201             else {
202 1         2 warn "[GoogleAnalytics] Authenticated with $url\n", if DEBUG;
203             $self->authorization(
204 1         9 {exp => time + ($res->{expires_in} - 600), header => "$res->{token_type} $res->{access_token}"});
205             }
206              
207 2   100     19 return $err // '';
208             }
209              
210             sub _process_batch_get_response {
211 3     3   4554 my ($self, $query, $tx) = @_;
212 3         8 my $as_list = ref $query eq 'ARRAY';
213 3         7 my $url = $tx->req->url;
214 3   50     20 my $res = $tx->res->json || {};
215 3   66     745 my $err = $res->{error} || $tx->error;
216 3   66     42 my $reports = $res->{reports} || ($as_list ? $query : [{}]);
217              
218             @$reports = map {
219 3         7 $_->{error} = $err;
  3         7  
220 3 50       9 $_->{query} = $as_list ? shift @$query : $query, $_->{tx} = $tx;
221 3         21 Mojo::GoogleAnalytics::Report->new($_);
222             } @$reports;
223              
224 3 100       41 if ($err) {
225 1   50     13 $err = sprintf '%s >>> %s (%s)', $url, $err->{message} || 'Unknown error', $err->{code} || 0;
      50        
226             }
227              
228 3 50 100     363 return $err || '', $as_list ? Mojo::Collection->new(@$reports) : $reports->[0];
229             }
230              
231             sub _query_translator {
232 6     6   4759 my ($self, %query) = @_;
233              
234 6 100       8 for my $filter (@{delete($query{filters}) || []}) {
  6         29  
235 8         86 my ($not, $op) = $filter->[1] =~ /^(\!)?(.*)$/;
236 8   50     23 my $group_op = $QUERY_TRANSLATOR{$op} || [dimension => $op];
237              
238 8 100       22 if ($group_op->[0] eq 'metric') {
239 3 50       5 push @{$query{metricFilterClauses}[0]{filters}},
  3         11  
240             {
241             metricName => $filter->[0],
242             not => $not ? true : false,
243             operator => $group_op->[1],
244             comparisonValue => "$filter->[2]",
245             };
246             }
247             else {
248 5 100       8 push @{$query{dimensionFilterClauses}[0]{filters}},
  5         23  
249             {
250             dimensionName => $filter->[0],
251             not => $not ? true : false,
252             operator => $group_op->[1],
253             expressions => $filter->[2],
254             };
255             }
256             }
257              
258 6 100       15 for my $order_by (@{delete($query{order_by}) || []}) {
  6         20  
259 3         20 my ($field, $order) = $order_by =~ /^(\S+)\s*(asc|desc)?$/;
260 3   33     25 $order = $QUERY_SORT_ORDER{$order || 'x'} || $QUERY_SORT_ORDER{x};
261 3         5 push @{$query{orderBys}}, {fieldName => $1, sortOrder => $order};
  3         12  
262             }
263              
264 6 100       14 if (my $d = delete $query{interval}) {
265 2   100     11 $query{dateRanges} = [{startDate => $d->[0], endDate => $d->[1] || '1daysAgo'}];
266             }
267              
268 3         8 $query{dimensions} = [map { +{name => $_} } split /,/, $query{dimensions}]
269 6 100 66     21 if $query{dimensions} and not ref $query{dimensions};
270 3         7 $query{metrics} = [map { +{expression => $_} } split /,/, $query{metrics}]
271 6 100 66     20 if $query{metrics} and not ref $query{metrics};
272 6 100       13 $query{pageSize} = delete $query{rows} if exists $query{rows};
273 6   33     24 $query{viewId} ||= $self->view_id;
274              
275 6         65 return \%query;
276             }
277              
278             1;
279              
280             =encoding utf8
281              
282             =head1 NAME
283              
284             Mojo::GoogleAnalytics - Extract data from Google Analytics using Mojo UserAgent
285              
286             =head1 SYNOPSIS
287              
288             my $ga = Mojo::GoogleAnalytics->new("/path/to/credentials.json");
289             my $report = $ga->batch_get({
290             viewId => "ga:123456789",
291             dateRanges => [{startDate => "7daysAgo", endDate => "1daysAgo"}],
292             dimensions => [{name => "ga:country"}, {name => "ga:browser"}],
293             metrics => [{expression => "ga:pageviews"}, {expression => "ga:sessions"}],
294             orderBys => [{fieldName => "ga:pageviews", sortOrder => "DESCENDING"}],
295             pageSize => 10,
296             });
297              
298             print $report->rows_to_table(as => "text");
299              
300             =head1 DESCRIPTION
301              
302             L is a Google Analytics client which allow you to
303             extract data non-blocking.
304              
305             This module is work in progress and currently EXPERIMENTAL. Let me know if you
306             start using it or has any feedback regarding the API.
307              
308             =head1 ATTRIBUTES
309              
310             =head2 authorization
311              
312             $hash_ref = $self->authorization;
313              
314             Holds authorization data, extracted by L. This can be useful to set
315             from a cache if L objects are created and destroyed
316             frequently, but with the same credentials.
317              
318             =head2 client_email
319              
320             $str = $self->client_email;
321              
322             Example: "some-app@some-project.iam.gserviceaccount.com".
323              
324             =head2 client_id
325              
326             $str = $self->client_id;
327              
328             Example: "103742165385019792511".
329              
330             =head2 private_key
331              
332             $str = $self->private_key;
333              
334             Holds the content of a pem file that looks like this:
335              
336             -----BEGIN PRIVATE KEY-----
337             ...
338             ...
339             -----END PRIVATE KEY-----
340              
341             =head2 ua
342              
343             $ua = $self->ua;
344             $self = $self->ua(Mojo::UserAgent->new);
345              
346             Holds a L object.
347              
348             =head2 view_id
349              
350             $str = $self->view_id;
351             $self = $self->view_id("ga:123456789");
352              
353             Default C, used by L.
354              
355             =head1 METHODS
356              
357             =head2 authorize
358              
359             $self = $self->authorize;
360             $self = $self->authorize(sub { my ($self, $err) = @_; });
361              
362             This method will set L. Note that this method is automatically
363             called from inside of L, unless already authorized.
364              
365             =head2 authorize_p
366              
367             $promise = $self->authorize_p;
368              
369             Same as L, but returns a L.
370              
371             =head2 batch_get
372              
373             $report = $self->batch_get(\%query);
374             $self = $self->batch_get(\%query, sub { my ($self, $err, $report) = @_ });
375              
376             Used to extract data from Google Analytics. C<$report> will be a
377             L if C<$query> is an array ref, and a single
378             L object if C<$query> is a hash.
379              
380             C<$err> is a string on error and false value on success.
381              
382             =head2 batch_get_p
383              
384             $promise = $self->batch_get_p(\%query);
385              
386             Same as L, but returns a L.
387              
388             =head2 from_file
389              
390             $self = $self->from_file("/path/to/credentials.json");
391              
392             Used to load attributes from a JSON credentials file, generated from
393             L. Example file:
394              
395             {
396             "type": "service_account",
397             "project_id": "cool-project-238176",
398             "private_key_id": "01234abc6780dc2a3284851423099daaad8cff92",
399             "private_key": "-----BEGIN PRIVATE KEY-----...\n-----END PRIVATE KEY-----\n",
400             "client_email": "some-name@cool-project-238176.iam.gserviceaccount.com",
401             "client_id": "103742165385019792511",
402             "auth_uri": "https://accounts.google.com/o/oauth2/auth",
403             "token_uri": "https://accounts.google.com/o/oauth2/token",
404             }
405              
406             Note: The JSON credentials file will probably contain more fields than is
407             listed above.
408              
409             =head2 get_report
410              
411             $report = $self->get_report(\%query);
412             $self = $self->get_report(\%query, sub { my ($self, $err, $report) = @_ });
413              
414             This method is the same as L, but will do some translations on the
415             input queries before passing it on to L. Example:
416              
417             $self->get_report({
418             dimensions => "ga:productName",
419             metrics => "ga:productListClicks,ga:productListViews",
420             interval => [qw(7daysAgo 1daysAgo)],
421             order_by => ["ga:productListClicks desc"],
422             filters => [ ["ga:currencyCode" => "eq" => ["USD"]] ],
423             });
424              
425             =over 2
426              
427             =item * dimensions
428              
429             C will be translated from a comma separated string, or passed on
430             directly to Google Analytics if not. The example above results in this query:
431              
432             dimensions => [{name => "ga:productName"}]
433              
434             =item * filters
435              
436             C is a simpler version of C and
437             C. The format is:
438              
439             filters => [ [$fieldName, $operator, $value] ]
440              
441             The C<$operator> will be used to determine if the expression should go into
442             C or C.
443              
444             Input operator | Filter group | Analytics operator
445             ---------------|-----------------------|----------------------
446             eq | dimensionFilterClause | EXACT
447             ^ | dimensionFilterClause | BEGINS_WITH
448             $ | dimensionFilterClause | ENDS_WITH
449             =~ | dimensionFilterClause | REGEXP
450             substr | dimensionFilterClause | PARTIAL
451             == | metricFilterClause | EQUAL
452             > | metricFilterClause | GREATER_THAN
453             < | metricFilterClause | LESS_THAN
454              
455             The filter will be "NOT" if the operator is prefixed with "!".
456              
457             =item * interval
458              
459             C can be used as a simpler version of C. The example above
460             results in:
461              
462             dateRanges => [{startDate => "7daysAgo", endDate => "1daysAgo"}]
463              
464             Note that C will default to "1daysAgo" if not present.
465              
466             =item * metrics
467              
468             C will be translated from a comma separated string, or passed on
469             directly to Google Analytics if not. The example above results in this query:
470              
471             metrics => [{name => "ga:productListClicks"}, {name => "ga:productListViews"}]
472              
473             =item * order_by
474              
475             C can be used as a simpler version to C. The example above
476             results in:
477              
478             orderBys => [{fieldName => "ga:productListClicks", sortOrder => "DESCENDING'}]
479              
480             The sort order can be "asc" or "desc". Will result in "SORT_ORDER_UNSPECIFIED"
481             unless present.
482              
483             =item * rows
484              
485             Alias for C.
486              
487             =item * viewId
488              
489             C will be set from L if not present in the query.
490              
491             =back
492              
493             =head2 get_report_p
494              
495             $promise = $selfg->get_report_p(\%query);
496              
497             Same as L, but returns a L.
498              
499             =head2 new
500              
501             $self = Mojo::GoogleAnalytics->new(%attrs);
502             $self = Mojo::GoogleAnalytics->new(\%attrs);
503             $self = Mojo::GoogleAnalytics->new("/path/to/credentials.json");
504              
505             Used to construct a new L object. Calling C with
506             a single argument will cause L to be called with that argument.
507              
508             =head2 mock
509              
510             $self = $self->mock;
511             $self = $self->mock({batch_get_dir => "/path/to/some/dir"});
512             $self = $self->mock({batch_get_uri => sub { my ($self, $c) = @_; }, token_uri => sub { my ($self, $c) = @_; }});
513              
514             This method is useful when you want to test your application, but you don't
515             want to ask Google for reports. C will be automatically called by
516             L if the C environment variable i set. The
517             arguments passed on to this method can be:
518              
519             =over 2
520              
521             =item * batch_get_dir
522              
523             Need to be an absolute path to a directory with the dummy response files for
524             L.
525              
526             Defaults to C environment variable.
527              
528             =item * batch_get_uri
529              
530             A code ref that is used as an L action. The default code ref
531             provided by this module will look for a response file in C with
532             the name C<$md5_sum.json>, where the MD5 sum is calculated from the JSON
533             request body. It will respond with an error message with the full path of the
534             expected file, unless the file could be read.
535              
536             =item * token_uri
537              
538             A code ref that is used as an L action. The default code ref will
539             respond with a dummy bearer token and log you in.
540              
541             =back
542              
543             =head1 AUTHOR
544              
545             Jan Henning Thorsen
546              
547             =head1 COPYRIGHT AND LICENSE
548              
549             This program is free software, you can redistribute it and/or modify it under
550             the terms of the Artistic License version 2.0.
551              
552             =cut