File Coverage

blib/lib/Mojo/GoogleAnalytics.pm
Criterion Covered Total %
statement 57 126 45.2
branch 19 46 41.3
condition 13 50 26.0
subroutine 11 22 50.0
pod 5 5 100.0
total 105 249 42.1


line stmt bran cond sub pod time code
1             package Mojo::GoogleAnalytics;
2 3     3   34959 use Mojo::Base -base;
  3         10  
  3         21  
3              
4 3     3   2166 use Mojo::Collection;
  3         468940  
  3         155  
5 3     3   1843 use Mojo::File 'path';
  3         70407  
  3         206  
6 3     3   1425 use Mojo::GoogleAnalytics::Report;
  3         9  
  3         25  
7 3     3   1583 use Mojo::JSON qw(decode_json false true);
  3         49394  
  3         221  
8 3     3   1742 use Mojo::JWT;
  3         5670  
  3         23  
9 3     3   1766 use Mojo::UserAgent;
  3         637548  
  3         66  
10              
11 3   50 3   206 use constant DEBUG => $ENV{MOJO_GA_DEBUG} || 0;
  3         11  
  3         9204  
12              
13             our $VERSION = '0.02';
14              
15             our %QUERY_SORT_ORDER = (asc => 'ASCENDING', desc => 'DESCENDING', x => 'SORT_ORDER_UNSPECIFIED');
16              
17             our %QUERY_TRANSLATOR = (
18             'eq' => [qw(dimension EXACT)],
19             '^' => [qw(dimension BEGINS_WITH)],
20             '$' => [qw(dimension ENDS_WITH)],
21             '=~' => [qw(dimension REGEXP)],
22             'substr' => [qw(dimension PARTIAL)],
23             '==' => [qw(metric EQUAL)],
24             '>' => [qw(metric GREATER_THAN)],
25             '<' => [qw(metric LESS_THAN)],
26             );
27              
28             has authorization => sub { +{} };
29             has client_email => sub { Carp::confess('client_email is required') };
30             has client_id => sub { Carp::confess('client_id is required') };
31             has private_key => sub { Carp::confess('private_key is required') };
32             has ua => sub { Mojo::UserAgent->new(max_redirects => 3) };
33             has view_id => '';
34              
35             sub authorize {
36 0     0 1 0 my ($self, $cb) = @_;
37 0         0 my $prev = $self->authorization;
38 0         0 my $time = time;
39 0         0 my ($jwt, @ua_args);
40              
41 0         0 warn "[RG::Google] Authorization exp: @{[$prev->{exp} ? $prev->{exp} : -1]} < $time\n" if DEBUG;
42              
43 0 0 0     0 if ($prev->{exp} and $time < $prev->{exp}) {
44 0 0       0 $self->$cb('') if $cb;
45 0         0 return $self;
46             }
47              
48 0         0 $ua_args[0] = Mojo::URL->new($self->{token_uri});
49 0         0 $jwt = Mojo::JWT->new->algorithm('RS256')->secret($self->private_key);
50              
51             $jwt->claims(
52             {
53             aud => $ua_args[0]->to_string,
54             exp => $time + 3600,
55             iat => $time,
56             iss => $self->client_email,
57             scope => $self->{auth_scope},
58             }
59 0         0 );
60              
61 0         0 push @ua_args, (form => {grant_type => 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion => $jwt->encode});
62 0         0 warn "[RG::Google] Authenticating with $ua_args[0] ...\n", if DEBUG;
63              
64 0 0       0 if ($cb) {
65             Mojo::IOLoop->delay(
66 0     0   0 sub { $self->ua->post(@ua_args, shift->begin) },
67 0     0   0 sub { $self->$cb($self->_process_authorize_response(pop)) },
68 0         0 );
69             }
70             else {
71 0         0 my ($err, $res) = $self->_process_authorize_response($self->ua->post(@ua_args));
72 0 0       0 die $err if $err;
73             }
74              
75 0         0 return $self;
76             }
77              
78             sub batch_get {
79 0     0 1 0 my ($self, $query, $cb) = @_;
80 0         0 my @ua_args;
81              
82 0 0       0 @ua_args = (Mojo::URL->new($self->{batch_get_uri}), {},
83             json => {reportRequests => ref $query eq 'ARRAY' ? $query : [$query]});
84              
85 0 0       0 if ($cb) {
86             Mojo::IOLoop->delay(
87 0     0   0 sub { $self->authorize(shift->begin) },
88             sub {
89 0     0   0 my ($delay, $err) = @_;
90 0 0       0 return $self->$cb($err, {}) if $err;
91 0         0 warn "[RG::Google] Getting analytics data from $ua_args[0] ...\n", if DEBUG;
92 0         0 $ua_args[1] = {Authorization => $self->authorization->{header}};
93 0         0 $self->ua->post(@ua_args, $delay->begin);
94             },
95 0     0   0 sub { $self->$cb($self->_process_batch_get_response($query, pop)) }
96 0         0 );
97 0         0 return $self;
98             }
99             else {
100 0         0 $ua_args[1] = {Authorization => $self->authorize->authorization->{header}};
101 0         0 warn "[RG::Google] Getting analytics data from $ua_args[0] ...\n", if DEBUG;
102 0         0 my ($err, $res) = $self->_process_batch_get_response($query, $self->ua->post(@ua_args));
103 0 0       0 die $err if $err;
104 0         0 return $res;
105             }
106             }
107              
108             sub from_file {
109 0     0 1 0 my ($self, $file) = @_;
110 0         0 my $attrs = decode_json(path($file)->slurp);
111              
112 0         0 for my $attr (keys %$attrs) {
113 0   0     0 $self->{$attr} ||= $attrs->{$attr};
114 0         0 warn qq([Mojo::GoogleAnalytics] Read "$attr" from $file\n) if DEBUG;
115             }
116              
117 0         0 return $self;
118             }
119              
120             sub get_report {
121 0     0 1 0 my ($self, $query, $cb) = @_;
122 0         0 return $self->batch_get($self->_query_translator(%$query), $cb);
123             }
124              
125             sub new {
126 1     1 1 121 my $class = shift;
127 1 50       19 my $file = @_ % 2 ? shift : undef, my $self = $class->SUPER::new(@_);
128 1 50       15 $self->from_file($file) if $file;
129 1         5 return _defaults($self);
130             }
131              
132             sub _defaults {
133 1   50 1   13 $_[0]->{token_uri} ||= 'https://accounts.google.com/o/oauth2/token';
134 1   50     8 $_[0]->{auth_scope} ||= 'https://www.googleapis.com/auth/analytics.readonly';
135 1   50     11 $_[0]->{batch_get_uri} ||= 'https://analyticsreporting.googleapis.com/v4/reports:batchGet';
136 1         5 $_[0];
137             }
138              
139             sub _process_authorize_response {
140 0     0   0 my ($self, $tx) = @_;
141 0         0 my $err = $tx->error;
142 0         0 my $res = $tx->res->json;
143 0         0 my $url = $tx->req->url;
144              
145 0 0       0 if ($err) {
146             $err = sprintf '%s >>> %s (%s)', $url, $res->{error_description} || $err->{message} || 'Unknown error',
147 0   0     0 $err->{code} || 0;
      0        
148 0         0 warn "[RG::Google] $err\n", if DEBUG;
149             }
150             else {
151 0         0 warn "[RG::Google] Authenticated with $url\n", if DEBUG;
152             $self->authorization(
153 0         0 {exp => time + ($res->{expires_in} - 600), header => "$res->{token_type} $res->{access_token}"});
154             }
155              
156 0   0     0 return $err // '';
157             }
158              
159             sub _process_batch_get_response {
160 0     0   0 my ($self, $query, $tx) = @_;
161 0         0 my $as_list = ref $query eq 'ARRAY';
162 0         0 my $url = $tx->req->url;
163 0   0     0 my $res = $tx->res->json || {};
164 0   0     0 my $err = $res->{error} || $tx->error;
165 0   0     0 my $reports = $res->{reports} || ($as_list ? $query : [{}]);
166              
167             @$reports = map {
168 0         0 $_->{error} = $err;
  0         0  
169 0 0       0 $_->{query} = $as_list ? shift @$query : $query, $_->{tx} = $tx;
170 0         0 Mojo::GoogleAnalytics::Report->new($_);
171             } @$reports;
172              
173 0 0       0 if ($err) {
174 0   0     0 $err = sprintf '%s >>> %s (%s)', $url, $err->{message} || 'Unknown error', $err->{code} || 0;
      0        
175             }
176              
177 0 0 0     0 return $err || '', $as_list ? Mojo::Collection->new(@$reports) : $reports->[0];
178             }
179              
180             sub _query_translator {
181 6     6   8400 my ($self, %query) = @_;
182              
183 6 100       17 for my $filter (@{delete($query{filters}) || []}) {
  6         42  
184 8         115 my ($not, $op) = $filter->[1] =~ /^(\!)?(.*)$/;
185 8   50     33 my $group_op = $QUERY_TRANSLATOR{$op} || [dimension => $op];
186              
187 8 100       28 if ($group_op->[0] eq 'metric') {
188 3 50       6 push @{$query{metricFilterClauses}[0]{filters}},
  3         17  
189             {
190             metricName => $filter->[0],
191             not => $not ? true : false,
192             operator => $group_op->[1],
193             comparisonValue => "$filter->[2]",
194             };
195             }
196             else {
197 5 100       9 push @{$query{dimensionFilterClauses}[0]{filters}},
  5         32  
198             {
199             dimensionName => $filter->[0],
200             not => $not ? true : false,
201             operator => $group_op->[1],
202             expressions => $filter->[2],
203             };
204             }
205             }
206              
207 6 100       25 for my $order_by (@{delete($query{order_by}) || []}) {
  6         42  
208 3         25 my ($field, $order) = $order_by =~ /^(\S+)\s*(asc|desc)?$/;
209 3   33     21 $order = $QUERY_SORT_ORDER{$order || 'x'} || $QUERY_SORT_ORDER{x};
210 3         13 push @{$query{orderBys}}, {fieldName => $1, sortOrder => $order};
  3         21  
211             }
212              
213 6 100       28 if (my $d = delete $query{interval}) {
214 2   100     18 $query{dateRanges} = [{startDate => $d->[0], endDate => $d->[1] || '1daysAgo'}];
215             }
216              
217 3         12 $query{dimensions} = [map { +{name => $_} } split /,/, $query{dimensions}]
218 6 100 66     48 if $query{dimensions} and not ref $query{dimensions};
219 3         13 $query{metrics} = [map { +{expression => $_} } split /,/, $query{metrics}]
220 6 100 66     35 if $query{metrics} and not ref $query{metrics};
221 6 100       23 $query{pageSize} = delete $query{rows} if exists $query{rows};
222 6   33     45 $query{viewId} ||= $self->view_id;
223              
224 6         107 return \%query;
225             }
226              
227             1;
228              
229             =encoding utf8
230              
231             =head1 NAME
232              
233             Mojo::GoogleAnalytics - Extract data from Google Analytics using Mojo UserAgent
234              
235             =head1 SYNOPSIS
236              
237             my $ga = Mojo::GoogleAnalytics->new("/path/to/credentials.json");
238             my $report = $ga->batch_get({
239             viewId => "ga:152749100",
240             dateRanges => [{startDate => "7daysAgo", endDate => "1daysAgo"}],
241             dimensions => [{name => "ga:country"}, {name => "ga:browser"}],
242             metrics => [{expression => "ga:pageviews"}, {expression => "ga:sessions"}],
243             orderBys => [{fieldName => "ga:pageviews", sortOrder => "DESCENDING"}],
244             pageSize => 10,
245             });
246              
247             print $report->rows_to_table(as => "text");
248              
249             =head1 DESCRIPTION
250              
251             L is a Google Analytics client which allow you to
252             extract data non-blocking.
253              
254             This module is work in progress and currently EXPERIMENTAL. Let me know if you
255             start using it or has any feedback regarding the API.
256              
257             =head1 ATTRIBUTES
258              
259             =head2 authorization
260              
261             $hash_ref = $self->authorization;
262              
263             Holds authorization data, extracted by L. This can be useful to set
264             from a cache if L objects are created and destroyed
265             frequently, but with the same credentials.
266              
267             =head2 client_email
268              
269             $str = $self->client_email;
270              
271             Example: "some-app@some-project.iam.gserviceaccount.com".
272              
273             =head2 client_id
274              
275             $str = $self->client_id;
276              
277             Example: "103742165385019792511".
278              
279             =head2 private_key
280              
281             $str = $self->private_key;
282              
283             Holds the content of a pem file that looks like this:
284              
285             -----BEGIN PRIVATE KEY-----
286             ...
287             ...
288             -----END PRIVATE KEY-----
289              
290             =head2 ua
291              
292             $ua = $self->ua;
293             $self = $self->ua(Mojo::UserAgent->new);
294              
295             Holds a L object.
296              
297             =head2 view_id
298              
299             $str = $self->view_id;
300             $self = $self->view_id("ga:152749100");
301              
302             Default C, used by L.
303              
304             =head1 METHODS
305              
306             =head2 authorize
307              
308             $self = $self->authorize;
309             $self = $self->authorize(sub { my ($self, $err) = @_; });
310              
311             This method will set L. Note that this method is automatically
312             called from inside of L, unless already authorized.
313              
314             =head2 batch_get
315              
316             $report = $self->batch_get($query);
317             $self = $self->batch_get($query, sub { my ($self, $err, $report) = @_ });
318              
319             Used to extract data from Google Analytics. C<$report> will be a
320             L if C<$query> is an array ref, and a single
321             L object if C<$query> is a hash.
322              
323             C<$err> is a string on error and false value on success.
324              
325             =head2 from_file
326              
327             $self = $self->from_file("/path/to/credentials.json");
328              
329             Used to load attributes from a JSON credentials file, generated from
330             L. Example file:
331              
332             {
333             "type": "service_account",
334             "project_id": "cool-project-238176",
335             "private_key_id": "01234abc6780dc2a3284851423099daaad8cff92",
336             "private_key": "-----BEGIN PRIVATE KEY-----...\n-----END PRIVATE KEY-----\n",
337             "client_email": "some-name@cool-project-238176.iam.gserviceaccount.com",
338             "client_id": "103742165385019792511",
339             "auth_uri": "https://accounts.google.com/o/oauth2/auth",
340             "token_uri": "https://accounts.google.com/o/oauth2/token",
341             }
342              
343             Note: The JSON credentials file will probably contain more fields than is
344             listed above.
345              
346             =head2 get_report
347              
348             $report = $self->get_report($query);
349             $self = $self->get_report($query, sub { my ($self, $err, $report) = @_ });
350              
351             This method is the same as L, but will do some translations on the
352             input queries before passing it on to L. Example:
353              
354             $self->get_report({
355             dimensions => "ga:productName",
356             metrics => "ga:productListClicks,ga:productListViews",
357             interval => [qw(7daysAgo 1daysAgo)],
358             order_by => ["ga:productListClicks desc"],
359             filters => [ ["ga:currencyCode" => "eq" => ["USD"]] ],
360             });
361              
362             =over 2
363              
364             =item * dimensions
365              
366             C will be translated from a comma separated string, or passed on
367             directly to Google Analytics if not. The example above results in this query:
368              
369             dimensions => [{name => "ga:productName"}]
370              
371             =item * filters
372              
373             C is a simpler version of C and
374             C. The format is:
375              
376             filters => [ [$fieldName, $operator, $value] ]
377              
378             The C<$operator> will be used to determine if the expression should go into
379             C or C.
380              
381             Input operator | Filter group | Analytics operator
382             ---------------|-----------------------|----------------------
383             eq | dimensionFilterClause | EXACT
384             ^ | dimensionFilterClause | BEGINS_WITH
385             $ | dimensionFilterClause | ENDS_WITH
386             =~ | dimensionFilterClause | REGEXP
387             substr | dimensionFilterClause | PARTIAL
388             == | metricFilterClause | EQUAL
389             > | metricFilterClause | GREATER_THAN
390             < | metricFilterClause | LESS_THAN
391              
392             The filter will be "NOT" if the operator is prefixed with "!".
393              
394             =item * interval
395              
396             C can be used as a simpler version of C. The example above
397             results in:
398              
399             dateRanges => [{startDate => "7daysAgo", endDate => "1daysAgo"}]
400              
401             Note that C will default to "1daysAgo" if not present.
402              
403             =item * metrics
404              
405             C will be translated from a comma separated string, or passed on
406             directly to Google Analytics if not. The example above results in this query:
407              
408             metrics => [{name => "ga:productListClicks"}, {name => "ga:productListViews"}]
409              
410             =item * order_by
411              
412             C can be used as a simpler version to C. The example above
413             results in:
414              
415             orderBys => [{fieldName => "ga:productListClicks", sortOrder => "DESCENDING'}]
416              
417             The sort order can be "asc" or "desc". Will result in "SORT_ORDER_UNSPECIFIED"
418             unless present.
419              
420             =item * rows
421              
422             Alias for C.
423              
424             =item * viewId
425              
426             C will be set from L if not present in the query.
427              
428             =back
429              
430             =head2 new
431              
432             $self = Mojo::GoogleAnalytics->new(%attrs);
433             $self = Mojo::GoogleAnalytics->new(\%attrs);
434             $self = Mojo::GoogleAnalytics->new("/path/to/credentials.json");
435              
436             Used to construct a new L object. Calling C with
437             a single argument will cause L to be called with that argument.
438              
439             =head1 AUTHOR
440              
441             Jan Henning Thorsen
442              
443             =head1 COPYRIGHT AND LICENSE
444              
445             This program is free software, you can redistribute it and/or modify it under
446             the terms of the Artistic License version 2.0.
447              
448             =cut