gapi 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/gapi.rb +2 -0
- data/lib/gapi/em_oauth_middle.rb +47 -0
- data/lib/gapi/file_cache_middle.rb +50 -0
- data/lib/gapi/fixture_middle.rb +18 -0
- data/lib/gapi/login_middle.rb +52 -0
- data/lib/gapi/oauth_middle.rb +21 -0
- data/lib/gapi/service.rb +108 -0
- metadata +73 -0
data/lib/gapi.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# Uses simple_oauth gem and em to make requests
|
2
|
+
# requires ruby 1.9 as it uses a Fiber to achieve async behaviour without a callback
|
3
|
+
|
4
|
+
module Gapi
|
5
|
+
|
6
|
+
class EmOauthMiddle
|
7
|
+
|
8
|
+
# connection timeout in seconds
|
9
|
+
CONNECT_TIMEOUT_S = 10
|
10
|
+
|
11
|
+
# inactivity timeout in seconds
|
12
|
+
INACTIVITY_TIMEOUT_S = 0
|
13
|
+
|
14
|
+
# these are all strings not the access token object from ruby-oauth
|
15
|
+
def initialize( consumer_key, consumer_secret, access_token, access_secret )
|
16
|
+
@oauth_opts = {
|
17
|
+
:consumer_key => consumer_key,
|
18
|
+
:consumer_secret => consumer_secret,
|
19
|
+
:access_token => access_token,
|
20
|
+
:access_token_secret => access_secret
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
def get( domain, path, opts )
|
25
|
+
data = opts.collect {|kv| kv.join('=') }.join('&')
|
26
|
+
path = "#{path}?#{data}" if data && data.length > 0
|
27
|
+
http = http_get( "https://#{domain}#{path}" )
|
28
|
+
return http.response_header.status, http.response
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def http_get( url )
|
34
|
+
f = Fiber.current
|
35
|
+
f.nil? and raise "http_get called with no current fiber"
|
36
|
+
conn = EM::HttpRequest.new( url, :connect_timeout => CONNECT_TIMEOUT_S, :inactivity_timeout => INACTIVITY_TIMEOUT_S )
|
37
|
+
conn.use( EventMachine::Middleware::OAuth, @oauth_opts )
|
38
|
+
http = conn.get
|
39
|
+
http.callback { f.resume( http ) }
|
40
|
+
http.errback {|r| f.resume( http ) }
|
41
|
+
return Fiber.yield
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
|
3
|
+
module Gapi
|
4
|
+
|
5
|
+
# wraps another middle, caches using MD5 hexdigest of path as key
|
6
|
+
class FileCacheMiddle
|
7
|
+
|
8
|
+
def initialize( actual_middle, cache_dir, uid )
|
9
|
+
@actual_middle = actual_middle
|
10
|
+
@cache_dir = Pathname.new( cache_dir )
|
11
|
+
@uid = uid
|
12
|
+
end
|
13
|
+
|
14
|
+
def get( domain, path, opts )
|
15
|
+
data = opts.collect {|kv| kv.join('=') }.join('&')
|
16
|
+
uri = path
|
17
|
+
uri = "#{path}?#{data}" if data && data.length > 0
|
18
|
+
cache_fname = cache_filename_for_uri( uri )
|
19
|
+
code = 500
|
20
|
+
body = ""
|
21
|
+
if File.exists?( cache_fname )
|
22
|
+
File.open( cache_fname, 'rb' ) do |f|
|
23
|
+
body = f.read
|
24
|
+
code = 200
|
25
|
+
end
|
26
|
+
else
|
27
|
+
code, body = @actual_middle.get( domain, path, opts )
|
28
|
+
if 200 == code
|
29
|
+
File.open( cache_fname, 'wb' ) do |f|
|
30
|
+
f.write( body )
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
return code, body
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
# TODO consider how we'll clear down cache? Could perhaps just
|
40
|
+
# delete files created before today
|
41
|
+
def cache_filename_for_uri( path )
|
42
|
+
uid = Digest::MD5.hexdigest( "#{@uid}:#{path}" )
|
43
|
+
# NOTE: this path needs to exist with r/w permissions for webserver
|
44
|
+
@cache_dir.join( uid )
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Gapi
|
2
|
+
|
3
|
+
class LoginMiddle
|
4
|
+
|
5
|
+
def initialize( email, password, source="gapi-v1" )
|
6
|
+
@auth_token = login( email, password, "source=#{source}" )
|
7
|
+
end
|
8
|
+
|
9
|
+
def get( domain, path, opts )
|
10
|
+
http = Net::HTTP.new( domain, 443 )
|
11
|
+
http.use_ssl = true
|
12
|
+
data = opts.collect {|kv| kv.join('=') }.join('&')
|
13
|
+
path = "#{path}?#{data}" if data && data.length > 0
|
14
|
+
resp, data = http.get( path, auth_headers )
|
15
|
+
code = resp.code.to_i
|
16
|
+
body = resp.body
|
17
|
+
return code, body
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def login( email, password, source )
|
23
|
+
params = {"Email" => email, "Passwd" => password, "accountType" => "GOOGLE", "source" => source, "service" => "analytics"}
|
24
|
+
code, body = post( "www.google.com", "/accounts/ClientLogin", params );
|
25
|
+
if code == 200
|
26
|
+
body.split( "\n" ).each do |line|
|
27
|
+
if line.match( /^Auth/ )
|
28
|
+
return line
|
29
|
+
end
|
30
|
+
end
|
31
|
+
else
|
32
|
+
nil
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def auth_header
|
37
|
+
{"Authorization" => "AuthSub token=#{@auth_token}"}
|
38
|
+
end
|
39
|
+
|
40
|
+
def post( domain, path, opts )
|
41
|
+
http = Net::HTTP.new( domain, 443 )
|
42
|
+
http.use_ssl = true
|
43
|
+
data = opts.collect {|kv| kv.join('=') }.join('&')
|
44
|
+
headers = {'Content-Type' => 'application/x-www-form-urlencoded'}.merge( auth_headers )
|
45
|
+
resp, data = http.post( path, data, headers )
|
46
|
+
code = resp.code.to_i
|
47
|
+
return code, resp.body
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Gapi
|
2
|
+
|
3
|
+
class OauthMiddle
|
4
|
+
|
5
|
+
# the ruby-oauth gem access_token
|
6
|
+
def initialize( access_token )
|
7
|
+
@access_token = access_token
|
8
|
+
end
|
9
|
+
|
10
|
+
def get( domain, path, opts )
|
11
|
+
data = opts.collect {|kv| kv.join('=') }.join('&')
|
12
|
+
path = "#{path}?#{data}" if data && data.length > 0
|
13
|
+
resp, data = @access_token.get( path, {} )
|
14
|
+
code = resp.code.to_i
|
15
|
+
body = resp.body
|
16
|
+
return code, body
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
data/lib/gapi/service.rb
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'net/https'
|
3
|
+
require 'openssl'
|
4
|
+
require 'nokogiri'
|
5
|
+
require 'ostruct'
|
6
|
+
require 'cgi'
|
7
|
+
|
8
|
+
module Gapi
|
9
|
+
|
10
|
+
class Service
|
11
|
+
|
12
|
+
# middle is used to fetch using net:http, oauth, em etc
|
13
|
+
def initialize( middle )
|
14
|
+
@profile_id = nil
|
15
|
+
@middle = middle
|
16
|
+
end
|
17
|
+
|
18
|
+
# dimensions and metrics are arrays e.g. ['ga:date', 'ga:sources'] - see Google Analytics Data Export API
|
19
|
+
# filters and sort follow Google Analytics Data Export API documentation e.g. to sort most recent date first: -ga:date
|
20
|
+
# Must be called from a fiber if EmOauthMiddle used
|
21
|
+
def fetch( start_date, end_date, dimensions, metrics, filters=nil, max_results=nil, sort=nil )
|
22
|
+
opts = query_opts( start_date, end_date, dimensions, metrics, filters, max_results, sort )
|
23
|
+
code, body = @middle.get( "www.google.com", "/analytics/feeds/data", opts )
|
24
|
+
if code == 200
|
25
|
+
return code, parse( body )
|
26
|
+
else
|
27
|
+
return code, nil
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Must be called from a fiber if EmOauthMiddle used
|
32
|
+
def accounts
|
33
|
+
code, body = @middle.get( "www.google.com", "/analytics/feeds/accounts/default", {:date => Time.now.strftime("%Y-%m-%d")} )
|
34
|
+
if code == 200
|
35
|
+
return code, parse_accounts( body )
|
36
|
+
else
|
37
|
+
return code, nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def use_account_with_table_id( table_id )
|
42
|
+
@profile_id = table_id.split(':').last
|
43
|
+
end
|
44
|
+
|
45
|
+
def use_account_with_title( title )
|
46
|
+
code, accs = accounts
|
47
|
+
account = accs.find {|acc| acc.title == title}
|
48
|
+
if account
|
49
|
+
@profile_id = use_account_with_table_id( account.table_id )
|
50
|
+
else
|
51
|
+
nil
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.to_underlined( str )
|
56
|
+
str.gsub( /[a-z]([A-Z])/ ) {|m| m.insert( 1, "_" )}.downcase
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def parse( body )
|
62
|
+
doc = Nokogiri::XML(body)
|
63
|
+
results = (doc/:entry).collect do |entry|
|
64
|
+
hash = {}
|
65
|
+
# as (entry/'dxp:metric') with namespace doesn't work, instead iterate over all sub-elements
|
66
|
+
(entry/'*').each do |element|
|
67
|
+
if element.name == "dimension" or element.name == "metric"
|
68
|
+
name = Gapi::Service.to_underlined( element[:name].sub(/^ga\:/,'') )
|
69
|
+
hash[name] = element[:value]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
OpenStruct.new(hash)
|
73
|
+
end
|
74
|
+
results
|
75
|
+
end
|
76
|
+
|
77
|
+
def parse_accounts( body )
|
78
|
+
doc = Nokogiri::XML(body)
|
79
|
+
results = (doc/:entry).collect do |entry|
|
80
|
+
hash = {}
|
81
|
+
# as (entry/'dxp:metric') with namespace doesn't work, instead iterate over all sub-elements
|
82
|
+
(entry/'*').each do |element|
|
83
|
+
if element.name == "title"
|
84
|
+
hash['title'] = element.content
|
85
|
+
elsif element.name == "tableId"
|
86
|
+
hash['table_id'] = element.content
|
87
|
+
end
|
88
|
+
end
|
89
|
+
OpenStruct.new(hash)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def query_opts( start_date, end_date, dimensions, metrics, filters=nil, max_results=nil, sort=nil )
|
94
|
+
opts = {'ids' => "ga:#{@profile_id}",
|
95
|
+
'start-date' => start_date,
|
96
|
+
'end-date' => end_date,
|
97
|
+
'dimensions' => dimensions.join(','),
|
98
|
+
'metrics' => metrics.join(',')}
|
99
|
+
opts['max-results'] = max_results if max_results
|
100
|
+
opts['filters'] = filters unless filters.nil?
|
101
|
+
opts['sort'] = sort unless sort.nil?
|
102
|
+
opts
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
|
metadata
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gapi
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 29
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
- 1
|
10
|
+
version: 0.0.1
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Paul Grayson
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2012-03-27 00:00:00 +01:00
|
19
|
+
default_executable:
|
20
|
+
dependencies: []
|
21
|
+
|
22
|
+
description: Google Analytics Data Export API wrapper which supports rack style middleware for e.g. testing, caching, oauth requests, event-machine based http etc
|
23
|
+
email: paul.grayson@gmail.com
|
24
|
+
executables: []
|
25
|
+
|
26
|
+
extensions: []
|
27
|
+
|
28
|
+
extra_rdoc_files: []
|
29
|
+
|
30
|
+
files:
|
31
|
+
- ./lib/gapi/em_oauth_middle.rb
|
32
|
+
- ./lib/gapi/file_cache_middle.rb
|
33
|
+
- ./lib/gapi/fixture_middle.rb
|
34
|
+
- ./lib/gapi/login_middle.rb
|
35
|
+
- ./lib/gapi/oauth_middle.rb
|
36
|
+
- ./lib/gapi/service.rb
|
37
|
+
- ./lib/gapi.rb
|
38
|
+
has_rdoc: true
|
39
|
+
homepage: http://github.com/paulgrayson/GAPI
|
40
|
+
licenses: []
|
41
|
+
|
42
|
+
post_install_message:
|
43
|
+
rdoc_options: []
|
44
|
+
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
none: false
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
hash: 3
|
53
|
+
segments:
|
54
|
+
- 0
|
55
|
+
version: "0"
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
hash: 3
|
62
|
+
segments:
|
63
|
+
- 0
|
64
|
+
version: "0"
|
65
|
+
requirements: []
|
66
|
+
|
67
|
+
rubyforge_project:
|
68
|
+
rubygems_version: 1.6.2
|
69
|
+
signing_key:
|
70
|
+
specification_version: 3
|
71
|
+
summary: GAPI
|
72
|
+
test_files: []
|
73
|
+
|