cannikin-gattica 0.2.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
data/History.txt CHANGED
@@ -1,3 +1,10 @@
1
+ == 0.3.0
2
+ * Support for filters (filters are all AND'ed together, no OR yet)
3
+
4
+ == 0.2.1
5
+ * More robust error checking on HTTP calls
6
+ * Added to_xml to get raw XML output from Google
7
+
1
8
  == 0.2.0 / 2009-04-27
2
9
  * Changed initialization format: pass a hash of options rather than individual email, password and profile_id
3
10
  * Can initialize with a valid token and use that instead of requiring email/password each time
data/README.rdoc CHANGED
@@ -71,6 +71,19 @@ also pass in single values as arrays too, if you wish):
71
71
  :metrics => ['pageviews','visits'],
72
72
  :sort => ['-pageviews']})
73
73
 
74
+ == Filters
75
+ Filters can be pretty complex as far as GA is concerned. You can filter on either dimensions or metrics
76
+ or both. And then your filters can be ANDed together or they can ORed together. There are also rules,
77
+ which are not immediately apparent, about what you can filter and how.
78
+
79
+ By default filters passed to a +get+ are ANDed together. This means that all filters need to match for
80
+ the result to be returned.
81
+
82
+ :filter => ['browser == Firefox','pageviews >= 10000']
83
+
84
+ This says "return only results where the 'browser' dimension contains the word 'Firefox' and the
85
+ 'pageviews' metric is greater than or equal to 10,000.
86
+
74
87
  == Notes on Authentication
75
88
  === Authentication Token
76
89
  Google recommends not re-authenticating each time you do a request against the API. To accomplish
data/VERSION.yml CHANGED
@@ -1,4 +1,4 @@
1
1
  ---
2
2
  :major: 0
3
- :minor: 2
4
- :patch: 0
3
+ :minor: 3
4
+ :patch: 1
data/examples/example.rb CHANGED
@@ -3,7 +3,8 @@ require '../lib/gattica'
3
3
  # authenticate with the API via email/password
4
4
  ga = Gattica.new({:email => 'username@gmail.com', :password => 'password'})
5
5
 
6
- # or, initialize via a pre-existing token (does not authenticate, but will throw an error on subsequent calls [like ga.accounts] if the token is invalid)
6
+ # or, initialize via a pre-existing token. This initialization does not authenticate immediately,
7
+ # but will throw an error on subsequent calls (like ga.accounts) if the token is invalid
7
8
  # ga = Gattica.new({:token => 'DQAAAJYAAACN-JMelka5I0Fs-T6lF53eUSfUooeHgcKc1iEdc0wkDS3w8GaXY7LjuUB_4vmzDB94HpScrULiweW_xQsU8yyUgdInDIX7ZnHm8_o0knf6FWSR90IoAZGsphpqteOjZ3O0NlNt603GgG7ylvGWRSeHl1ybD38nysMsKJR-dj0LYgIyPMvtgXLrqr_20oTTEExYbrDSg5_q84PkoLHUcODZ' })
8
9
 
9
10
  # get the list of accounts you have access to with that username and password
@@ -14,15 +15,28 @@ accounts = ga.accounts
14
15
  # property you're most used to seeing in GA, looks like UA123456-1)
15
16
  ga.profile_id = accounts.first.profile_id
16
17
 
17
- # puts ga.token
18
+ # If you're using Gattica with a web app you'll want to save the authorization token
19
+ # and use that on subsequent requests (Google recommends not re-authenticating each time)
20
+ # ga.token
18
21
 
19
22
  # now get the number of page views by browser for Janurary 2009
20
- # note that as of right now, Gattica does not support filtering
21
23
  data = ga.get({ :start_date => '2009-01-01',
22
24
  :end_date => '2009-01-31',
23
25
  :dimensions => ['browser'],
24
26
  :metrics => ['pageviews'],
25
27
  :sort => ['-pageviews'] })
26
-
27
- # write the data out as CSV
28
+
29
+ # output the data as CSV
28
30
  puts data.to_csv
31
+
32
+ # a little more complex example with filtering. Show all pageviews by Firefox browsers
33
+ # (any version) where the number of page views is greater than 100
34
+ data = ga.get({ :start_date => '2009-01-01',
35
+ :end_date => '2009-01-31',
36
+ :dimensions => ['browser','browserVersion'],
37
+ :metrics => ['pageviews'],
38
+ :sort => ['-pageviews'],
39
+ :filters => ['browser == Firefox', 'pageviews > 100'] })
40
+
41
+ # write the data out as CSV in "short" format (doesn't include id, updated or title parameters)
42
+ puts data.to_csv(:short)
data/lib/gattica.rb CHANGED
@@ -4,9 +4,11 @@ $:.unshift File.dirname(__FILE__) # for use/testing when no gem is installed
4
4
  require 'net/http'
5
5
  require 'net/https'
6
6
  require 'uri'
7
+ require 'cgi'
7
8
  require 'logger'
8
9
  require 'rubygems'
9
10
  require 'hpricot'
11
+ require 'yaml'
10
12
 
11
13
  # internal
12
14
  require 'gattica/core_extensions'
@@ -24,7 +26,7 @@ require 'gattica/data_point'
24
26
 
25
27
  module Gattica
26
28
 
27
- VERSION = '0.2.0'
29
+ VERSION = '0.3.1'
28
30
 
29
31
  # Creates a new instance of Gattica::Engine and gets us going. Please see the README for usage docs.
30
32
  #
@@ -34,8 +36,8 @@ module Gattica
34
36
  Engine.new(*args)
35
37
  end
36
38
 
37
- # The real meat of Gattica, deals with talking to GA, returning and parsing results. You automatically
38
- # get an instance of this when you go Gattica.new()
39
+ # The real meat of Gattica, deals with talking to GA, returning and parsing results. You actually get
40
+ # an instance of this when you go Gattica.new()
39
41
 
40
42
  class Engine
41
43
 
@@ -44,6 +46,8 @@ module Gattica
44
46
  SECURE = true
45
47
  DEFAULT_ARGS = { :start_date => nil, :end_date => nil, :dimensions => [], :metrics => [], :filters => [], :sort => [] }
46
48
  DEFAULT_OPTIONS = { :email => nil, :password => nil, :token => nil, :profile_id => nil, :debug => false, :headers => {}, :logger => Logger.new(STDOUT) }
49
+ FILTER_METRIC_OPERATORS = %w{ == != > < >= <= }
50
+ FILTER_DIMENSION_OPERATORS = %w{ == != =~ !~ =@ ~@ }
47
51
 
48
52
  attr_reader :user
49
53
  attr_accessor :profile_id, :token
@@ -65,7 +69,7 @@ module Gattica
65
69
 
66
70
  @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=
67
71
  @user_accounts = nil # filled in later if the user ever calls Gattica::Engine#accounts
68
- @headers = {} # headers used for any HTTP requests (Google requires a special 'Authorization' header)
72
+ @headers = {}.merge(@options[:headers]) # headers used for any HTTP requests (Google requires a special 'Authorization' header which is set any time @token is set)
69
73
 
70
74
  # save an http connection for everyone to use
71
75
  @http = Net::HTTP.new(SERVER, PORT)
@@ -73,18 +77,15 @@ module Gattica
73
77
  @http.set_debug_output $stdout if @options[:debug]
74
78
 
75
79
  # authenticate
76
- if @options[:email] && @options[:password] # email and password: authenticate and get a token from Google's ClientLogin
80
+ if @options[:email] && @options[:password] # email and password: authenticate, get a token from Google's ClientLogin, save it for later
77
81
  @user = User.new(@options[:email], @options[:password])
78
- @auth = Auth.new(@http, user, { :source => 'gattica-'+VERSION }, { 'User-Agent' => 'Ruby Net::HTTP' })
82
+ @auth = Auth.new(@http, user)
79
83
  self.token = @auth.tokens[:auth]
80
- elsif @options[:token] # use an existing token (this also sets the headers for any HTTP requests we make)
84
+ elsif @options[:token] # use an existing token
81
85
  self.token = @options[:token]
82
86
  else # no login or token, you can't do anything
83
87
  raise GatticaError::NoLoginOrToken, 'You must provide an email and password, or authentication token'
84
88
  end
85
-
86
- # the user can provide their own additional headers - merge them into the ones that Gattica requires
87
- @headers = @headers.merge(@options[:headers])
88
89
 
89
90
  # 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
90
91
  end
@@ -108,12 +109,8 @@ module Gattica
108
109
 
109
110
  def accounts
110
111
  # if we haven't retrieved the user's accounts yet, get them now and save them
111
- if @accts.nil?
112
- # TODO: move these calls into their own method so we can encapsulate all the errors in one place
113
- response, data = @http.get('/analytics/feeds/accounts/default', @headers)
114
- if response.code == '401'
115
- raise GatticaError::InvalidToken, 'The token you have provided is invalid or has expired'
116
- end
112
+ if @user_accounts.nil?
113
+ data = do_http_get('/analytics/feeds/accounts/default')
117
114
  xml = Hpricot(data)
118
115
  @user_accounts = xml.search(:entry).collect { |entry| Account.new(entry) }
119
116
  end
@@ -158,23 +155,15 @@ module Gattica
158
155
  def get(args={})
159
156
  args = validate_and_clean(DEFAULT_ARGS.merge(args))
160
157
  query_string = build_query_string(args,@profile_id)
161
- @logger.debug(query_string)
162
- # TODO: move these calls into their own method so we can encapsulate all the errors in one place
163
- response, data = @http.get("/analytics/feeds/data?#{query_string}", @headers)
164
- if response.code == '401'
165
- raise GatticaError::InvalidToken, 'The token you have provided is invalid or has expired'
166
- end
167
- begin
168
- response.value
169
- rescue Net::HTTPServerException => e
170
- raise GatticaError::AnalyticsError, data.to_s + " (status code: #{e.message})"
171
- end
158
+ @logger.debug(query_string) if @debug
159
+ data = do_http_get("/analytics/feeds/data?#{query_string}")
172
160
  return DataSet.new(Hpricot.XML(data))
173
161
  end
174
162
 
175
163
 
176
164
  # Since google wants the token to appear in any HTTP call's header, we have to set that header
177
- # again any time @token is changed
165
+ # again any time @token is changed so we override the default writer (note that you need to set
166
+ # @token with self.token= instead of @token=)
178
167
 
179
168
  def token=(token)
180
169
  @token = token
@@ -184,6 +173,29 @@ module Gattica
184
173
 
185
174
  private
186
175
 
176
+
177
+ # Does the work of making HTTP calls and then going through a suite of tests on the response to make
178
+ # sure it's valid and not an error
179
+
180
+ def do_http_get(query_string)
181
+ response, data = @http.get(query_string, @headers)
182
+
183
+ # error checking
184
+ if response.code != '200'
185
+ case response.code
186
+ when '400'
187
+ raise GatticaError::AnalyticsError, response.body + " (status code: #{response.code})"
188
+ when '401'
189
+ raise GatticaError::InvalidToken, "Your authorization token is invalid or has expired (status code: #{response.code})"
190
+ else # some other unknown error
191
+ raise GatticaError::UnknownAnalyticsError, response.body + " (status code: #{response.code})"
192
+ end
193
+ end
194
+
195
+ return data
196
+ end
197
+
198
+
187
199
  # Sets up the HTTP headers that Google expects (this is called any time @token is set either by Gattica
188
200
  # or manually by the user since the header must include the token)
189
201
  def set_http_headers
@@ -209,8 +221,17 @@ module Gattica
209
221
  sort[0..0] == '-' ? "-ga:#{sort[1..-1]}" : "ga:#{sort}" # if the first character is a dash, move it before the ga:
210
222
  end.join(',')
211
223
  end
224
+
225
+ # 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)
212
226
  unless args[:filters].empty? # filters are a little more complicated because they can have all kinds of modifiers
213
-
227
+ output += '&filters=' + args[:filters].collect do |filter|
228
+ match, name, operator, expression = *filter.match(/^(\w*)(\W*)(.*)$/) # splat the resulting Match object to pull out the parts automatically
229
+ unless name.empty? || operator.empty? || expression.empty? # make sure they all contain something
230
+ "ga:#{name}#{CGI::escape(operator.gsub(/ /,''))}#{CGI::escape(expression)}" # remove any whitespace from the operator before output
231
+ else
232
+ raise GatticaError::InvalidFilter, "The filter '#{filter}' is invalid. Filters should look like 'browser == Firefox' or 'browser==Firefox'"
233
+ end
234
+ end.join(';')
214
235
  end
215
236
  return output
216
237
  end
@@ -224,13 +245,26 @@ module Gattica
224
245
  raise GatticaError::TooManyDimensions, 'You can only have a maximum of 7 dimensions' if args[:dimensions] && (args[:dimensions].is_a?(Array) && args[:dimensions].length > 7)
225
246
  raise GatticaError::TooManyMetrics, 'You can only have a maximum of 10 metrics' if args[:metrics] && (args[:metrics].is_a?(Array) && args[:metrics].length > 10)
226
247
 
248
+ possible = args[:dimensions] + args[:metrics]
249
+
227
250
  # make sure that the user is only trying to sort fields that they've previously included with dimensions and metrics
228
251
  if args[:sort]
229
- possible = args[:dimensions] + args[:metrics]
230
252
  missing = args[:sort].find_all do |arg|
231
253
  !possible.include? arg.gsub(/^-/,'') # remove possible minuses from any sort params
232
254
  end
233
- raise GatticaError::InvalidSort, "You are trying to sort by fields that are not in the available dimensions or metrics: #{missing.join(', ')}" unless missing.empty?
255
+ unless missing.empty?
256
+ raise GatticaError::InvalidSort, "You are trying to sort by fields that are not in the available dimensions or metrics: #{missing.join(', ')}"
257
+ end
258
+ end
259
+
260
+ # make sure that the user is only trying to filter fields that are in dimensions or metrics
261
+ if args[:filters]
262
+ missing = args[:filters].find_all do |arg|
263
+ !possible.include? arg.match(/^\w*/).to_s # get the name of the filter and compare
264
+ end
265
+ unless missing.empty?
266
+ raise GatticaError::InvalidSort, "You are trying to filter by fields that are not in the available dimensions or metrics: #{missing.join(', ')}"
267
+ end
234
268
  end
235
269
 
236
270
  return args
data/lib/gattica/auth.rb CHANGED
@@ -10,23 +10,36 @@ module Gattica
10
10
  include Convertible
11
11
 
12
12
  SCRIPT_NAME = '/accounts/ClientLogin'
13
- HEADERS = { 'Content-Type' => 'application/x-www-form-urlencoded' }
14
- OPTIONS = { :source => '', :service => 'analytics' }
15
-
16
- attr_reader :response, :data, :tokens, :token
13
+ HEADERS = { 'Content-Type' => 'application/x-www-form-urlencoded', 'User-Agent' => 'Ruby Net::HTTP' } # Google asks that you be nice and provide a user-agent string
14
+ OPTIONS = { :source => 'gattica-'+VERSION, :service => 'analytics' } # Google asks that you provide the name of your app as a 'source' parameter in your POST
15
+
16
+ attr_reader :tokens
17
17
 
18
- # Prepare the user info along with options and header
19
- def initialize(http, user, options={}, headers={})
20
- data = OPTIONS.merge(options)
21
- data = data.merge(user.to_h)
22
- headers = HEADERS.merge(headers)
23
-
24
- @response, @data = http.post(SCRIPT_NAME, data.to_query, headers)
25
- @tokens = parse_tokens(@data)
18
+ # Try to authenticate the user
19
+ def initialize(http, user)
20
+ options = OPTIONS.merge(user.to_h)
21
+
22
+ response, data = http.post(SCRIPT_NAME, options.to_query, HEADERS)
23
+ if response.code != '200'
24
+ case response.code
25
+ when '403'
26
+ raise GatticaError::CouldNotAuthenticate, 'Your email and/or password is not recognized by the Google ClientLogin system (status code: 403)'
27
+ else
28
+ raise GatticaError::UnknownAnalyticsError, response.body + " (status code: #{response.code})"
29
+ end
30
+ end
31
+ @tokens = parse_tokens(data)
26
32
  end
27
33
 
34
+
28
35
  private
29
- # Parse the authentication tokens out of the response
36
+
37
+ # Parse the authentication tokens out of the response and makes them available as a hash
38
+ #
39
+ # tokens[:auth] => Google requires this for every request (added to HTTP headers on GET requests)
40
+ # tokens[:sid] => Not used
41
+ # tokens[:lsid] => Not used
42
+
30
43
  def parse_tokens(data)
31
44
  tokens = {}
32
45
  data.split("\n").each do |t|
@@ -24,5 +24,16 @@ module Gattica
24
24
  to_h.to_query
25
25
  end
26
26
 
27
+ # Return the raw XML (if the object has a @xml instance variable, otherwise convert the object itself to xml)
28
+ def to_xml
29
+ if @xml
30
+ @xml
31
+ else
32
+ self.to_xml
33
+ end
34
+ end
35
+
36
+ alias to_yml to_yaml
37
+
27
38
  end
28
39
  end
@@ -14,5 +14,12 @@ class Hash
14
14
  def value
15
15
  self.values.first if self.length == 1
16
16
  end
17
+
18
+ def stringify_keys
19
+ inject({}) do |options, (key, value)|
20
+ options[key.to_s] = value
21
+ options
22
+ end
23
+ end
17
24
 
18
25
  end
@@ -47,6 +47,15 @@ module Gattica
47
47
  return output
48
48
  end
49
49
 
50
+
51
+ def to_yaml
52
+ { 'id' => @id,
53
+ 'updated' => @updated,
54
+ 'title' => @title,
55
+ 'dimensions' => @dimensions,
56
+ 'metrics' => @metrics }.to_yaml
57
+ end
58
+
50
59
  end
51
60
 
52
61
  end
@@ -32,14 +32,16 @@ module Gattica
32
32
  output = '"id","updated","title",'
33
33
  end
34
34
 
35
- output += @points.first.dimensions.collect do |dimension|
36
- "\"#{dimension.key.to_s}\""
37
- end.join(',')
38
- output += ','
39
- output += @points.first.metrics.collect do |metric|
40
- "\"#{metric.key.to_s}\""
41
- end.join(',')
42
- output += "\n"
35
+ unless @points.empty? # if there was at least one result
36
+ output += @points.first.dimensions.collect do |dimension|
37
+ "\"#{dimension.key.to_s}\""
38
+ end.join(',')
39
+ output += ','
40
+ output += @points.first.metrics.collect do |metric|
41
+ "\"#{metric.key.to_s}\""
42
+ end.join(',')
43
+ output += "\n"
44
+ end
43
45
 
44
46
  # get the data from each point
45
47
  @points.each do |point|
@@ -49,6 +51,16 @@ module Gattica
49
51
  return output
50
52
  end
51
53
 
54
+
55
+ def to_yaml
56
+ { 'total_results' => @total_results,
57
+ 'start_index' => @start_index,
58
+ 'items_per_page' => @items_per_page,
59
+ 'start_date' => @start_date,
60
+ 'end_date' => @end_date,
61
+ 'points' => @points}.to_yaml
62
+ end
63
+
52
64
  end
53
65
 
54
66
  end
@@ -17,4 +17,5 @@ module GatticaError
17
17
  class MissingEndDate < StandardError; end;
18
18
  # errors from Analytics
19
19
  class AnalyticsError < StandardError; end;
20
+ class UnknownAnalyticsError < StandardError; end;
20
21
  end
data/lib/gattica/user.rb CHANGED
@@ -6,10 +6,9 @@ module Gattica
6
6
 
7
7
  include Convertible
8
8
 
9
- SERVICE = 'analytics'
10
9
  attr_accessor :email, :password
11
10
 
12
- def initialize(email,password,source='')
11
+ def initialize(email,password)
13
12
  @email = email
14
13
  @password = password
15
14
  validate
data/test/test_engine.rb CHANGED
@@ -6,7 +6,7 @@ class TestEngine < Test::Unit::TestCase
6
6
  end
7
7
 
8
8
  def test_initialization
9
- assert Gattica.new({:email => 'anonymous@anon.com', :password => 'none'}) # you can initialize with a potentially invalid email and password
9
+ # assert Gattica.new({:email => 'anonymous@anon.com', :password => 'none'}) # you can initialize with a potentially invalid email and password
10
10
  assert Gattica.new({:token => 'test'}) # you can initialize with a potentially invalid token
11
11
  assert_raise GatticaError::NoLoginOrToken do Gattica.new() end # but, you must initialize with one or the other
12
12
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cannikin-gattica
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Cameron
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2009-04-27 00:00:00 -07:00
12
+ date: 2009-05-01 00:00:00 -07:00
13
13
  default_executable:
14
14
  dependencies: []
15
15