Skip to content

Commit

Permalink
Fixed typo in start-index parameter, refactored Engine class and defa…
Browse files Browse the repository at this point in the history
…ult settings to a separate file, added unit tests
  • Loading branch information
Chris Le committed Apr 11, 2011
1 parent 8f5157e commit 3b486a8
Show file tree
Hide file tree
Showing 7 changed files with 409 additions and 305 deletions.
12 changes: 12 additions & 0 deletions History.txt
@@ -1,3 +1,15 @@
== 0.4.3
* FIXED: Typo in start-index parameter
* Refactored Engine class into it's own file.
* Began to re-style code to wrap at 80 characters
* Added some unit tests

== 0.4.2
* Added Ruby 1.9 support (Thanks @mathieuravaux https://github.com/mathieuravaux)
* Uses hpricot 0.8.4 now. 0.8.3 segfaults.
* Added ability to change the timeout when requesting analytics from Google
* Added the ability to use max_results

== 0.3.2.scottp == 0.3.2.scottp
* scottp Added Analytics API v2 header, and basic support for "segment" argument. * scottp Added Analytics API v2 header, and basic support for "segment" argument.


Expand Down
10 changes: 5 additions & 5 deletions Rakefile
Expand Up @@ -6,11 +6,11 @@ begin
require 'jeweler' require 'jeweler'
Jeweler::Tasks.new do |gemspec| Jeweler::Tasks.new do |gemspec|
gemspec.name = "gattica" gemspec.name = "gattica"
gemspec.summary = "Gattica is a Ruby library for extracting data from the Google Analytics API." gemspec.summary = "Gattica is a straight forward Ruby Gem for getting data from the Google Analytics API."
gemspec.email = "cannikinn@gmail.com" gemspec.email = "chrisl@seerinteractive.com"
gemspec.homepage = "http://github.com/cannikin/gattica" gemspec.homepage = "http://github.com/chrisle/gattica"
gemspec.description = "Gattica is a Ruby library for extracting data from the Google Analytics API. It is simple to use, supports metrics, dimensions, sort, filters, goals, and segments" gemspec.description = "Gattica is a straight forward Ruby Gem for getting data from the Google Analytics API. It supports metrics, dimensions, sort, filters, goals, and segments"
gemspec.authors = ["Rob Cameron", "Christopher Le"] gemspec.authors = ["Christopher Le, et all"]
# Current version of hpricot 0.8.3 seg faults randomly. Use 0.8.2 instead. # Current version of hpricot 0.8.3 seg faults randomly. Use 0.8.2 instead.
# see: (https://github.com/hpricot/hpricot/issues#issue/32). # see: (https://github.com/hpricot/hpricot/issues#issue/32).
gemspec.add_dependency 'hpricot', '>=0.8.4' gemspec.add_dependency 'hpricot', '>=0.8.4'
Expand Down
291 changes: 2 additions & 289 deletions lib/gattica.rb
@@ -1,6 +1,5 @@
$:.unshift File.dirname(__FILE__) # for use/testing when no gem is installed $:.unshift File.dirname(__FILE__) # for use/testing when no gem is installed


# external
require 'net/http' require 'net/http'
require 'net/https' require 'net/https'
require 'uri' require 'uri'
Expand All @@ -10,7 +9,8 @@
require 'hpricot' require 'hpricot'
require 'yaml' require 'yaml'


# internal require 'gattica/engine'
require 'gattica/settings'
require 'gattica/version' require 'gattica/version'
require 'gattica/core_extensions' require 'gattica/core_extensions'
require 'gattica/convertible' require 'gattica/convertible'
Expand All @@ -23,301 +23,14 @@
require 'gattica/segment' require 'gattica/segment'


# Gattica is a Ruby library for talking to the Google Analytics API. # Gattica is a Ruby library for talking to the Google Analytics API.
#
# Please see the README for usage docs. # Please see the README for usage docs.

module Gattica module Gattica


# Creates a new instance of Gattica::Engine and gets us going. Please see the README for usage docs. # Creates a new instance of Gattica::Engine and gets us going. Please see the README for usage docs.
#
# ga = Gattica.new({:email => 'anonymous@anon.com', :password => 'password, :profile_id => 123456 }) # ga = Gattica.new({:email => 'anonymous@anon.com', :password => 'password, :profile_id => 123456 })


def self.new(*args) def self.new(*args)
Engine.new(*args) Engine.new(*args)
end end


# The real meat of Gattica, deals with talking to GA, returning and parsing results. You actually get
# an instance of this when you go Gattica.new()

class Engine

SERVER = 'www.google.com'
PORT = 443
SECURE = true
DEFAULT_ARGS = { :start_date => nil, :end_date => nil, :dimensions => [], :metrics => [], :filters => [], :sort => [] }
DEFAULT_OPTIONS = { :email => nil, :password => nil, :token => nil, :profile_id => nil, :debug => false, :headers => {}, :logger => Logger.new(STDOUT) }
FILTER_METRIC_OPERATORS = %w{ == != > < >= <= }
FILTER_DIMENSION_OPERATORS = %w{ == != =~ !~ =@ ~@ }

attr_reader :user
attr_accessor :profile_id, :token

# Create a user, and get them authorized.
# If you're making a web app you're going to want to save the token that's retrieved by Gattica
# so that you can use it later (Google recommends not re-authenticating the user for each and every request)
#
# ga = Gattica.new({:email => 'johndoe@google.com', :password => 'password', :profile_id => 123456})
# ga.token => 'DW9N00wenl23R0...' (really long string)
#
# Or if you already have the token (because you authenticated previously and now want to reuse that session):
#
# ga = Gattica.new({:token => '23ohda09hw...', :profile_id => 123456})

def initialize(options={})
@options = DEFAULT_OPTIONS.merge(options)
@logger = @options[:logger]

@profile_id = @options[:profile_id] # if you don't include the profile_id now, you'll have to set it manually later via Gattica::Engine#profile_id=
@user_accounts = nil # filled in later if the user ever calls Gattica::Engine#accounts
@user_segments = nil
@headers = {}.merge(@options[:headers]) # headers used for any HTTP requests (Google requires a special 'Authorization' header which is set any time @token is set)
@default_account_feed = nil # We don't want to call /analytics/feeds/accounts/default more than once so do it once and keep in in here

# save an http connection for everyone to use
@http = Net::HTTP.new(SERVER, PORT)
@http.use_ssl = SECURE
@http.set_debug_output $stdout if @options[:debug]

# Set a timeout if the option was given
if @options[:timeout]
@http.read_timeout = @options[:timeout]
end

# authenticate
if @options[:email] && @options[:password] # email and password: authenticate, get a token from Google's ClientLogin, save it for later
@user = User.new(@options[:email], @options[:password])
@auth = Auth.new(@http, user)
self.token = @auth.tokens[:auth]
elsif @options[:token] # use an existing token
self.token = @options[:token]
else # no login or token, you can't do anything
raise GatticaError::NoLoginOrToken, 'You must provide an email and password, or authentication token'
end

# TODO: check that the user has access to the specified profile and show an error here rather than wait for Google to respond with a message
end


# Returns the list of accounts the user has access to. A user may have multiple accounts on Google Analytics
# and each account may have multiple profiles. You need the profile_id in order to get info from GA. If you
# don't know the profile_id then use this method to get a list of all them. Then set the profile_id of your
# instance and you can make regular calls from then on.
#
# ga = Gattica.new({:email => 'johndoe@google.com', :password => 'password'})
# ga.accounts
# # you parse through the accounts to find the profile_id you need
# ga.profile_id = 12345678
# # now you can perform a regular search, see Gattica::Engine#get
#
# If you pass in a profile id when you instantiate Gattica::Search then you won't need to
# get the accounts and find a profile_id - you apparently already know it!
#
# See Gattica::Engine#get to see how to get some data.

def accounts
# if we haven't retrieved the user's accounts yet, get them now and save them
if @user_accounts.nil?
data = request_default_account_feed
xml = Hpricot(data)
@user_accounts = xml.search(:entry).collect { |entry| Account.new(entry) }
end
return @user_accounts
end

# Returns the list of segments available to the authenticated user.
#
# == Usage
# ga = Gattica.new({:email => 'johndoe@google.com', :password => 'password'})
# ga.segments # Look up segment id
# my_gaid = 'gaid::-5' # Non-paid Search Traffic
# ga.profile_id = 12345678 # Set our profile ID
#
# gs.get({ :start_date => '2008-01-01',
# :end_date => '2008-02-01',
# :dimensions => 'month',
# :metrics => 'views',
# :segment => my_gaid })

def segments
if @user_segments.nil?
data = request_default_account_feed
xml = Hpricot(data)
@user_segments = xml.search("dxp:segment").collect { |s| Segment.new(s) }
end
return @user_segments
end

# This is the method that performs the actual request to get data.
#
# == Usage
#
# gs = Gattica.new({:email => 'johndoe@google.com', :password => 'password', :profile_id => 123456})
# gs.get({ :start_date => '2008-01-01',
# :end_date => '2008-02-01',
# :dimensions => 'browser',
# :metrics => 'pageviews',
# :sort => 'pageviews',
# :filters => ['browser == Firefox']})
#
# == Input
#
# When calling +get+ you'll pass in a hash of options. For a description of what these mean to
# Google Analytics, see http://code.google.com/apis/analytics/docs
#
# Required values are:
#
# * +start_date+ => Beginning of the date range to search within
# * +end_date+ => End of the date range to search within
#
# Optional values are:
#
# * +dimensions+ => an array of GA dimensions (without the ga: prefix)
# * +metrics+ => an array of GA metrics (without the ga: prefix)
# * +filter+ => an array of GA dimensions/metrics you want to filter by (without the ga: prefix)
# * +sort+ => an array of GA dimensions/metrics you want to sort by (without the ga: prefix)
#
# == Exceptions
#
# If a user doesn't have access to the +profile_id+ you specified, you'll receive an error.
# Likewise, if you attempt to access a dimension or metric that doesn't exist, you'll get an
# error back from Google Analytics telling you so.

def get(args={})
args = validate_and_clean(DEFAULT_ARGS.merge(args))
query_string = build_query_string(args,@profile_id)
@logger.debug(query_string) if @debug
data = do_http_get("/analytics/feeds/data?#{query_string}")
#data = do_http_get("/analytics/feeds/data?ids=ga%3A915568&metrics=ga%3Avisits&segment=gaid%3A%3A-7&start-date=2010-03-29&end-date=2010-03-29&max-results=50")
return DataSet.new(Hpricot.XML(data))
end


# Since google wants the token to appear in any HTTP call's header, we have to set that header
# again any time @token is changed so we override the default writer (note that you need to set
# @token with self.token= instead of @token=)

def token=(token)
@token = token
set_http_headers
end

######################################################################
private

# Gets the default account feed from Google
def request_default_account_feed
if @default_account_feed.nil?
@default_account_feed = do_http_get('/analytics/feeds/accounts/default')
end
return @default_account_feed
end

# Does the work of making HTTP calls and then going through a suite of tests on the response to make
# sure it's valid and not an error

def do_http_get(query_string)
response, data = @http.get(query_string, @headers)

# error checking
if response.code != '200'
case response.code
when '400'
raise GatticaError::AnalyticsError, response.body + " (status code: #{response.code})"
when '401'
raise GatticaError::InvalidToken, "Your authorization token is invalid or has expired (status code: #{response.code})"
else # some other unknown error
raise GatticaError::UnknownAnalyticsError, response.body + " (status code: #{response.code})"
end
end

return data
end


# Sets up the HTTP headers that Google expects (this is called any time @token is set either by Gattica
# or manually by the user since the header must include the token)
def set_http_headers
@headers['Authorization'] = "GoogleLogin auth=#{@token}"
@headers['GData-Version']= '2'
end


# Creates a valid query string for GA
def build_query_string(args,profile)
output = "ids=ga:#{profile}&start-date=#{args[:start_date]}&end-date=#{args[:end_date]}"
if (start_index = args[:start_index].to_i) > 0
output += "&start#index=#{start_index}"
end
unless args[:dimensions].empty?
output += '&dimensions=' + args[:dimensions].collect do |dimension|
"ga:#{dimension}"
end.join(',')
end
unless args[:metrics].empty?
output += '&metrics=' + args[:metrics].collect do |metric|
"ga:#{metric}"
end.join(',')
end
unless args[:sort].empty?
output += '&sort=' + args[:sort].collect do |sort|
sort[0..0] == '-' ? "-ga:#{sort[1..-1]}" : "ga:#{sort}" # if the first character is a dash, move it before the ga:
end.join(',')
end
unless args[:segment].nil?
output += "&segment=#{args[:segment]}"
end
unless args[:max_results].nil?
output += "&max-results=#{args[:max_results]}"
end

# TODO: update so that in regular expression filters (=~ and !~), any initial special characters in the regular expression aren't also picked up as part of the operator (doesn't cause a problem, but just feels dirty)
unless args[:filters].empty? # filters are a little more complicated because they can have all kinds of modifiers
output += '&filters=' + args[:filters].collect do |filter|
match, name, operator, expression = *filter.match(/^(\w*)\s*([=!<>~@]*)\s*(.*)$/) # splat the resulting Match object to pull out the parts automatically
unless name.empty? || operator.empty? || expression.empty? # make sure they all contain something
"ga:#{name}#{CGI::escape(operator.gsub(/ /,''))}#{CGI::escape(expression)}" # remove any whitespace from the operator before output
else
raise GatticaError::InvalidFilter, "The filter '#{filter}' is invalid. Filters should look like 'browser == Firefox' or 'browser==Firefox'"
end
end.join(';')
end
return output
end


# Validates that the args passed to +get+ are valid
def validate_and_clean(args)

raise GatticaError::MissingStartDate, ':start_date is required' if args[:start_date].nil? || args[:start_date].empty?
raise GatticaError::MissingEndDate, ':end_date is required' if args[:end_date].nil? || args[:end_date].empty?
raise GatticaError::TooManyDimensions, 'You can only have a maximum of 7 dimensions' if args[:dimensions] && (args[:dimensions].is_a?(Array) && args[:dimensions].length > 7)
raise GatticaError::TooManyMetrics, 'You can only have a maximum of 10 metrics' if args[:metrics] && (args[:metrics].is_a?(Array) && args[:metrics].length > 10)

possible = args[:dimensions] + args[:metrics]

# make sure that the user is only trying to sort fields that they've previously included with dimensions and metrics
if args[:sort]
missing = args[:sort].find_all do |arg|
!possible.include? arg.gsub(/^-/,'') # remove possible minuses from any sort params
end
unless missing.empty?
raise GatticaError::InvalidSort, "You are trying to sort by fields that are not in the available dimensions or metrics: #{missing.join(', ')}"
end
end

# make sure that the user is only trying to filter fields that are in dimensions or metrics
if args[:filters]
missing = args[:filters].find_all do |arg|
!possible.include? arg.match(/^\w*/).to_s # get the name of the filter and compare
end
unless missing.empty?
raise GatticaError::InvalidSort, "You are trying to filter by fields that are not in the available dimensions or metrics: #{missing.join(', ')}"
end
end

return args
end


end
end end
7 changes: 3 additions & 4 deletions lib/gattica/account.rb
Expand Up @@ -9,7 +9,8 @@ class Account


include Convertible include Convertible


attr_reader :id, :updated, :title, :table_id, :account_id, :account_name, :profile_id, :web_property_id, :goals attr_reader :id, :updated, :title, :table_id, :account_id, :account_name,
:profile_id, :web_property_id, :goals


def initialize(xml) def initialize(xml)
@id = xml.at(:id).inner_html @id = xml.at(:id).inner_html
Expand All @@ -20,14 +21,12 @@ def initialize(xml)
@account_name = xml.at("dxp:property[@name='ga:accountName']").attributes['value'] @account_name = xml.at("dxp:property[@name='ga:accountName']").attributes['value']
@profile_id = xml.at("dxp:property[@name='ga:profileId']").attributes['value'].to_i @profile_id = xml.at("dxp:property[@name='ga:profileId']").attributes['value'].to_i
@web_property_id = xml.at("dxp:property[@name='ga:webPropertyId']").attributes['value'] @web_property_id = xml.at("dxp:property[@name='ga:webPropertyId']").attributes['value']

@goals = xml.search('ga:goal').collect do |goal| {
@goals = xml.search('ga:goal').collect do |goal| {
:active => goal.attributes['active'], :active => goal.attributes['active'],
:name => goal.attributes['name'], :name => goal.attributes['name'],
:number => goal.attributes['number'].to_i, :number => goal.attributes['number'].to_i,
:value => goal.attributes['value'].to_f, :value => goal.attributes['value'].to_f,
} }

end end
end end


Expand Down

0 comments on commit 3b486a8

Please sign in to comment.