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 )