Mojo-GoogleAnalytics
view release on metacpan or search on metacpan
lib/Mojo/GoogleAnalytics.pm view on Meta::CPAN
package Mojo::GoogleAnalytics;
use Mojo::Base -base;
use Mojo::Collection;
use Mojo::File 'path';
use Mojo::GoogleAnalytics::Report;
use Mojo::JSON qw(decode_json false true);
use Mojo::Promise;
use Mojo::JWT;
use Mojo::UserAgent;
use constant DEBUG => $ENV{MOJO_GA_DEBUG} || 0;
our $VERSION = '0.04';
our %QUERY_SORT_ORDER = (asc => 'ASCENDING', desc => 'DESCENDING', x => 'SORT_ORDER_UNSPECIFIED');
our %QUERY_TRANSLATOR = (
'eq' => [qw(dimension EXACT)],
'^' => [qw(dimension BEGINS_WITH)],
'$' => [qw(dimension ENDS_WITH)],
'=~' => [qw(dimension REGEXP)],
'substr' => [qw(dimension PARTIAL)],
'==' => [qw(metric EQUAL)],
'>' => [qw(metric GREATER_THAN)],
'<' => [qw(metric LESS_THAN)],
);
has authorization => sub { +{} };
has client_email => sub { Carp::confess('client_email is required') };
has client_id => sub { Carp::confess('client_id is required') };
has private_key => sub { Carp::confess('private_key is required') };
has ua => sub { Mojo::UserAgent->new(max_redirects => 3) };
has view_id => '';
sub authorize {
my ($self, $cb) = @_;
my @ua_args = $self->_authorize_ua_args or return $self;
if ($cb) {
$self->ua->post(@ua_args, sub { $self->$cb($self->_process_authorize_response($_[1])) });
}
else {
my ($err, $res) = $self->_process_authorize_response($self->ua->post(@ua_args));
die $err if $err;
}
return $self;
}
sub authorize_p {
my $self = shift;
return Mojo::Promise->new->resolve unless my @ua_args = $self->_authorize_ua_args;
return $self->ua->post_p(@ua_args)->then(sub {
my $err = $self->_process_authorize_response($_[0]);
return $err ? Mojo::Promise->new->reject($err) : ();
});
}
sub batch_get {
my ($self, $query, $cb) = @_;
my @ua_args;
@ua_args = (Mojo::URL->new($self->{batch_get_uri}), {},
json => {reportRequests => ref $query eq 'ARRAY' ? $query : [$query]});
if ($cb) {
my $p = $self->authorize_p->then(sub {
warn "[GoogleAnalytics] Getting analytics data from $ua_args[0] ...\n", if DEBUG;
$ua_args[1] = {Authorization => $self->authorization->{header}};
return $self->ua->post_p(@ua_args);
})->then(sub {
my $res = $self->_process_batch_get_response($query, shift);
return ref $cb ? $self->$cb('', $res) : $res;
})->catch(sub {
return ref $cb ? $self->$cb(shift, {}) : shift;
});
return ref $cb ? $self : $p;
}
else {
$ua_args[1] = {Authorization => $self->authorize->authorization->{header}};
warn "[GoogleAnalytics] Getting analytics data from $ua_args[0] ...\n", if DEBUG;
my ($err, $res) = $self->_process_batch_get_response($query, $self->ua->post(@ua_args));
die $err if $err;
return $res;
}
}
sub batch_get_p {
shift->batch_get(shift, 1);
lib/Mojo/GoogleAnalytics.pm view on Meta::CPAN
$self->{$attr} ||= $attrs->{$attr};
warn qq([Mojo::GoogleAnalytics] Read "$attr" from $file\n) if DEBUG;
}
return $self;
}
sub get_report {
my ($self, $query, $cb) = @_;
return $self->batch_get($self->_query_translator(%$query), $cb);
}
sub get_report_p {
my ($self, $query) = @_;
$self->batch_get_p($self->_query_translator(%$query));
}
sub new {
my $class = shift;
my $file = @_ % 2 ? shift : undef;
my $self = $class->SUPER::new(@_);
$self->from_file($file) if $file;
$self->{token_uri} ||= 'https://accounts.google.com/o/oauth2/token';
$self->{auth_scope} ||= 'https://www.googleapis.com/auth/analytics.readonly';
$self->{batch_get_uri} ||= 'https://analyticsreporting.googleapis.com/v4/reports:batchGet';
$self->mock if $ENV{TEST_MOJO_GA_BATCH_GET_DIR};
return $self;
}
sub mock {
my ($self, $args) = @_;
$self->{batch_get_dir} = $args->{batch_get_dir} // $ENV{TEST_MOJO_GA_BATCH_GET_DIR} // File::Spec->tmpdir;
require Mojolicious;
my $server = $self->ua->server;
$server->app(Mojolicious->new) unless $server->app;
my $mock_r = $server->app->routes;
Scalar::Util::weaken($self);
for my $name (qw(batch_get_uri token_uri)) {
my $cb = $self->can("_mocked_action_$name");
$self->{$name} = sprintf '/mocked/ga%s', Mojo::URL->new($self->{$name})->path;
$mock_r->any($self->{$name} => $args->{$name} || sub { $self->$cb(@_) })->name($name) unless $mock_r->lookup($name);
}
return $self;
}
sub _authorize_ua_args {
my $self = shift;
my $time = time;
my $prev = $self->authorization;
my ($jwt, @ua_args);
warn "[GoogleAnalytics] Authorization exp: @{[$prev->{exp} ? $prev->{exp} : -1]} < $time\n" if DEBUG;
return if $prev->{exp} and $time < $prev->{exp};
$ua_args[0] = Mojo::URL->new($self->{token_uri});
$jwt = Mojo::JWT->new->algorithm('RS256')->secret($self->private_key);
$jwt->claims({
aud => $ua_args[0]->to_string,
exp => $time + 3600,
iat => $time,
iss => $self->client_email,
scope => $self->{auth_scope},
});
push @ua_args, (form => {grant_type => 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion => $jwt->encode});
warn "[GoogleAnalytics] Authenticating with $ua_args[0] ...\n", if DEBUG;
return @ua_args;
}
sub _mocked_action_batch_get_uri {
my ($self, $c) = @_;
my $file = Mojo::File::path($self->{batch_get_dir}, sprintf '%s.json', Mojo::Util::md5_sum($c->req->text));
warn "[GoogleAnalytics] Reading dummy response file $file (@{[-r $file ? 1 : 0]})\n" if DEBUG;
return $c->render(data => $file->slurp) if -r $file;
return $c->render(json => {error => {message => qq(Could not read dummy response file "$file".)}}, status => 500);
}
sub _mocked_action_token_uri {
my ($self, $c) = @_;
$c->render(json => {access_token => 'some-dummy-token', expires_in => 3600, token_type => 'Bearer'});
}
sub _process_authorize_response {
my ($self, $tx) = @_;
my $err = $tx->error;
my $res = $tx->res->json;
my $url = $tx->req->url;
if ($err) {
$err = sprintf '%s >>> %s (%s)', $url, $res->{error_description} || $err->{message} || 'Unknown error',
$err->{code} || 0;
warn "[GoogleAnalytics] $err\n", if DEBUG;
}
else {
warn "[GoogleAnalytics] Authenticated with $url\n", if DEBUG;
$self->authorization(
{exp => time + ($res->{expires_in} - 600), header => "$res->{token_type} $res->{access_token}"});
}
return $err // '';
}
sub _process_batch_get_response {
my ($self, $query, $tx) = @_;
my $as_list = ref $query eq 'ARRAY';
my $url = $tx->req->url;
my $res = $tx->res->json || {};
my $err = $res->{error} || $tx->error;
my $reports = $res->{reports} || ($as_list ? $query : [{}]);
@$reports = map {
$_->{error} = $err;
$_->{query} = $as_list ? shift @$query : $query, $_->{tx} = $tx;
lib/Mojo/GoogleAnalytics.pm view on Meta::CPAN
$query{metrics} = [map { +{expression => $_} } split /,/, $query{metrics}]
if $query{metrics} and not ref $query{metrics};
$query{pageSize} = delete $query{rows} if exists $query{rows};
$query{viewId} ||= $self->view_id;
return \%query;
}
1;
=encoding utf8
=head1 NAME
Mojo::GoogleAnalytics - Extract data from Google Analytics using Mojo UserAgent
=head1 SYNOPSIS
my $ga = Mojo::GoogleAnalytics->new("/path/to/credentials.json");
my $report = $ga->batch_get({
viewId => "ga:123456789",
dateRanges => [{startDate => "7daysAgo", endDate => "1daysAgo"}],
dimensions => [{name => "ga:country"}, {name => "ga:browser"}],
metrics => [{expression => "ga:pageviews"}, {expression => "ga:sessions"}],
orderBys => [{fieldName => "ga:pageviews", sortOrder => "DESCENDING"}],
pageSize => 10,
});
print $report->rows_to_table(as => "text");
=head1 DESCRIPTION
L<Mojo::GoogleAnalytics> is a Google Analytics client which allow you to
extract data non-blocking.
This module is work in progress and currently EXPERIMENTAL. Let me know if you
start using it or has any feedback regarding the API.
=head1 ATTRIBUTES
=head2 authorization
$hash_ref = $self->authorization;
Holds authorization data, extracted by L</authorize>. This can be useful to set
from a cache if L<Mojo::GoogleAnalytics> objects are created and destroyed
frequently, but with the same credentials.
=head2 client_email
$str = $self->client_email;
Example: "some-app@some-project.iam.gserviceaccount.com".
=head2 client_id
$str = $self->client_id;
Example: "103742165385019792511".
=head2 private_key
$str = $self->private_key;
Holds the content of a pem file that looks like this:
-----BEGIN PRIVATE KEY-----
...
...
-----END PRIVATE KEY-----
=head2 ua
$ua = $self->ua;
$self = $self->ua(Mojo::UserAgent->new);
Holds a L<Mojo::UserAgent> object.
=head2 view_id
$str = $self->view_id;
$self = $self->view_id("ga:123456789");
Default C<viewId>, used by L</get_report>.
=head1 METHODS
=head2 authorize
$self = $self->authorize;
$self = $self->authorize(sub { my ($self, $err) = @_; });
This method will set L</authorization>. Note that this method is automatically
called from inside of L</batch_get>, unless already authorized.
=head2 authorize_p
$promise = $self->authorize_p;
Same as L</authorize>, but returns a L<Mojo::Promise>.
=head2 batch_get
$report = $self->batch_get(\%query);
$self = $self->batch_get(\%query, sub { my ($self, $err, $report) = @_ });
Used to extract data from Google Analytics. C<$report> will be a
L<Mojo::Collection> if C<$query> is an array ref, and a single
L<Mojo::GoogleAnalytics::Report> object if C<$query> is a hash.
C<$err> is a string on error and false value on success.
=head2 batch_get_p
$promise = $self->batch_get_p(\%query);
Same as L</batch_get>, but returns a L<Mojo::Promise>.
=head2 from_file
$self = $self->from_file("/path/to/credentials.json");
Used to load attributes from a JSON credentials file, generated from
L<https://console.developers.google.com/apis/credentials>. Example file:
{
"type": "service_account",
"project_id": "cool-project-238176",
"private_key_id": "01234abc6780dc2a3284851423099daaad8cff92",
"private_key": "-----BEGIN PRIVATE KEY-----...\n-----END PRIVATE KEY-----\n",
"client_email": "some-name@cool-project-238176.iam.gserviceaccount.com",
"client_id": "103742165385019792511",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
}
Note: The JSON credentials file will probably contain more fields than is
listed above.
=head2 get_report
$report = $self->get_report(\%query);
$self = $self->get_report(\%query, sub { my ($self, $err, $report) = @_ });
This method is the same as L</batch_get>, but will do some translations on the
input queries before passing it on to L</batch_get>. Example:
$self->get_report({
dimensions => "ga:productName",
metrics => "ga:productListClicks,ga:productListViews",
interval => [qw(7daysAgo 1daysAgo)],
order_by => ["ga:productListClicks desc"],
filters => [ ["ga:currencyCode" => "eq" => ["USD"]] ],
});
=over 2
=item * dimensions
C<dimensions> will be translated from a comma separated string, or passed on
directly to Google Analytics if not. The example above results in this query:
dimensions => [{name => "ga:productName"}]
=item * filters
C<filters> is a simpler version of C<dimensionFilterClauses> and
C<metricFilterClauses>. The format is:
filters => [ [$fieldName, $operator, $value] ]
The C<$operator> will be used to determine if the expression should go into
C<dimensionFilterClauses> or C<metricFilterClauses>.
Input operator | Filter group | Analytics operator
---------------|-----------------------|----------------------
eq | dimensionFilterClause | EXACT
^ | dimensionFilterClause | BEGINS_WITH
$ | dimensionFilterClause | ENDS_WITH
=~ | dimensionFilterClause | REGEXP
substr | dimensionFilterClause | PARTIAL
== | metricFilterClause | EQUAL
> | metricFilterClause | GREATER_THAN
< | metricFilterClause | LESS_THAN
The filter will be "NOT" if the operator is prefixed with "!".
=item * interval
C<interval> can be used as a simpler version of C<dateRanges>. The example above
( run in 0.669 second using v1.01-cache-2.11-cpan-39bf76dae61 )