File Coverage

blib/lib/Mojo/GoogleAnalytics.pm
Criterion Covered Total %
statement 139 156 89.1
branch 44 60 73.3
condition 28 58 48.2
subroutine 24 30 80.0
pod 9 9 100.0
total 244 313 77.9


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