gapi 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
|