google-api 0.0.1.alpha → 0.0.1.beta

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
- ## v0.0.1.alpha (2012-08-21) ##
1
+ ## v0.0.1.beta (2012-09-05)
2
+
3
+ * Full test suite for all classes and modules.
4
+ * Rethink and rebuild the API integration. Now discover the API and build methods dynamically! Works with the following Google APIs:
5
+ * Calendar
6
+ * Drive
7
+ * Add logger configuration.
8
+ * Add the #google method to an oauthable object.
9
+ * Add the #patch method to OAuth2.
10
+
11
+ ## v0.0.1.alpha (2012-08-21)
2
12
 
3
13
  * First release. (be careful!)
data/README.md CHANGED
@@ -1,12 +1,10 @@
1
- **This gem is not yet released! Proceed at your own risk...**
1
+ **This gem is in its infancy! Proceed at your own risk...**
2
2
 
3
- Google API
4
- ===================
3
+ # Google API
5
4
 
6
5
  A simple but powerful ruby API wrapper for Google's services.
7
6
 
8
- Installation
9
- -------
7
+ ## Installation
10
8
 
11
9
  Add this line to your application's Gemfile:
12
10
 
@@ -21,17 +19,15 @@ Or install it yourself as:
21
19
  $ gem install google-api
22
20
 
23
21
 
24
- Before Using this Gem
25
- -------
22
+ ## Before Using this Gem
26
23
 
27
24
  Google API depends on you to authenticate each user with Google via OAuth2. We don't really care how you get authenticated, but you must request access to a User's Google account and save the Refresh Token, Access Token, and Access Token Expiration Date.
28
25
 
29
26
  As a starting point, we recommend using Omniauth for most authentication with Ruby. It is a great gem, and integrates seemlessly with most SSO sites, including Google. Check out the [Wiki](https://github.com/agrobbin/google-api/wiki/Using-Omniauth-for-Authentication) for more information.
30
27
 
31
- Usage
32
- -------
28
+ ## Usage
33
29
 
34
- This gem couldn't be easier to set up. Once you have a Client ID and Secret from Google (check out the [Wiki](https://github.com/agrobbin/google-api/wiki/Getting-a-Client-ID-and-Secret-from-Google) for instructions on how to get these), you just need to add an initializer to your Rails application that looks like this:
30
+ This gem couldn't be easier to set up. Once you have a Client ID and Secret from Google (check out the [Wiki](https://github.com/agrobbin/google-api/wiki/Getting-a-Client-ID-and-Secret-from-Google) for instructions on how to get these), you just need to add an initializer to your application that looks like this:
35
31
 
36
32
  ```ruby
37
33
  GoogleAPI.configure do |config|
@@ -64,12 +60,20 @@ Once you have set that up, making a request to the Google API is as simple as:
64
60
 
65
61
  ```ruby
66
62
  user = User.find(1)
67
- client = GoogleAPI::Client.new(user)
68
- client.drive.all
63
+ user.google.drive.files.list
69
64
  ```
70
65
 
71
66
  This will fetch all files and folders in the user's Google Drive and return them in an array of hashes.
72
67
 
68
+ ## What Google APIs can this gem be used for?
69
+
70
+ * Calendar
71
+ * Drive
72
+
73
+ ## I need to use an API that is not yet included
74
+
75
+ Open an issue, and we will do our best to integrate and fully test the API you need. Or, you can submit a pull request with the necessary updates!
76
+
73
77
  ## Contributing
74
78
 
75
79
  1. Fork it
data/google-api.gemspec CHANGED
@@ -4,17 +4,17 @@ Gem::Specification.new do |gem|
4
4
  gem.email = ["alex@robbinsweb.biz"]
5
5
  gem.summary = %q{A simple but powerful ruby API wrapper for Google's services.}
6
6
  gem.description = gem.summary
7
- gem.homepage = ""
7
+ gem.homepage = "https://github.com/agrobbin/google-api"
8
8
 
9
9
  gem.files = `git ls-files`.split($\)
10
10
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
11
11
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
12
12
  gem.name = "google-api"
13
13
  gem.require_paths = ["lib"]
14
- gem.version = "0.0.1.alpha"
14
+ gem.version = "0.0.1.beta"
15
15
 
16
- gem.add_runtime_dependency 'oauth2', '0.8.0'
17
- gem.add_runtime_dependency 'rails', '>= 3.2'
16
+ gem.add_runtime_dependency 'mime-types', '~> 1.0'
17
+ gem.add_runtime_dependency 'oauth2', '~> 0.8.0'
18
18
  gem.add_development_dependency 'bundler'
19
19
  gem.add_development_dependency 'rake'
20
20
  gem.add_development_dependency 'rspec'
@@ -25,6 +25,10 @@ module GoogleAPI
25
25
  self.oauth_access_token_expires_at = 59.minutes.from_now
26
26
  self.save
27
27
  end
28
+
29
+ define_method :google do
30
+ GoogleAPI::Client.new(self)
31
+ end
28
32
  end
29
33
 
30
34
  end
@@ -1,109 +1,135 @@
1
1
  module GoogleAPI
2
2
  class API
3
3
 
4
- FORMAT = :json
4
+ attr_reader :access_token, :api, :map
5
5
 
6
- attr_reader :access_token
7
-
8
- def initialize(access_token)
6
+ def initialize(access_token, api, map)
9
7
  @access_token = access_token
8
+ @api = api
9
+ @map = map
10
10
  end
11
11
 
12
- # The really important part of this parent class. The headers are injected here,
13
- # and the full URL is built from the ENDPOINT constant and the value passed thru
14
- # the URL parameter.
12
+ # Taking over #method_missing here allows us to chain multiple methods onto a API
13
+ # instance. If the current place we are in the API includes this method, then let's call it!
14
+ # If not, and there are multiple methods below this section of the API, build a new API
15
+ # and do it all over again.
16
+ #
17
+ # As an example:
18
+ #
19
+ # User.find(1).google.drive.files.list => we will be calling this method twice, once to find all
20
+ # methods within the files section of the API, and once to send a request to the #list API method,
21
+ # delegating to the #request method above.
15
22
  #
16
- # The response is then returned, and appropriately parsed.
17
- def request(method, url = nil, args = {})
18
- args[:headers] = headers.merge(args[:headers] || {})
19
- args[:body] = args[:body].send("to_#{format}") if args[:body].is_a?(Hash)
23
+ # User.find(1).google.drive.files.permissions.list => we will end up calling this method three times,
24
+ # until we get to the end of the chain.
25
+ # (Note: This method chain doesn't map to an actual Google Drive API method.)
26
+ #
27
+ # If the API method includes a mediaUpload key, we know that this method allows uploads, like to upload
28
+ # a new Google Drive file. If so, we call the #upload method instead of #request.
29
+ def method_missing(method, *args)
30
+ api_method = map[method.to_s]
31
+ args = args.last.is_a?(Hash) ? args.last : {} # basically #extract_options!
32
+ methods_or_resources = api_method['methods'] || api_method['resources']
33
+ if methods_or_resources
34
+ API.new(access_token, api, methods_or_resources)
35
+ else
36
+ url, options = build_url(api_method, args)
20
37
 
21
- url.prepend(self.class::ENDPOINT) unless URI(url).scheme
38
+ raise ArgumentError, ":body parameter was not passed" if !options[:body] && %w(POST PUT PATCH).include?(api_method['httpMethod'])
22
39
 
23
- # Adopt Google's API erorr handling recommendation here:
24
- # https://developers.google.com/drive/manage-uploads#exp-backoff
25
- #
26
- # In essence, we try 5 times to perform the request. With each subsequent request,
27
- # we wait 2^n seconds plus a random number of milliseconds (no greater than 1 second)
28
- # until either we receive a successful response, or we run out of attempts.
29
- # If the Retry-After header is in the error response, we use whichever happens to be
30
- # greater, our calculated wait time, or the value in the Retry-After header.
31
- attempt = 0
32
- while attempt < 5
33
- response = access_token.send(method, url, args)
34
- break unless response.status >= 500
35
- seconds_to_wait = [((2 ** attempt) + rand), response.headers['Retry-After'].to_i].max
36
- attempt += 1
37
- puts "#{attempt.ordinalize} request attempt failed. Trying again in #{seconds_to_wait} seconds..."
38
- sleep seconds_to_wait
40
+ send(api_method['mediaUpload'] && args[:media] ? :upload : :request, api_method['httpMethod'].downcase, url, options)
39
41
  end
42
+ end
43
+
44
+ private
45
+ # A really important part of this class. The headers are injected here,
46
+ # and the body is transformed into a JSON'd string when necessary.
47
+ # We do exponential back-off for error responses, and return a parsed
48
+ # response body if present, the full Response object if not.
49
+ def request(method, url = nil, options = {})
50
+ options[:headers] = {'Content-Type' => 'application/json'}.merge(options[:headers] || {})
51
+ options[:body] = options[:body].to_json if options[:body].is_a?(Hash)
40
52
 
41
- if response.body.present?
42
- case format
43
- when :json
44
- JSON.parse(response.body)
45
- when :xml
46
- Nokogiri::XML(response.body)
53
+ # Adopt Google's API erorr handling recommendation here:
54
+ # https://developers.google.com/drive/handle-errors#implementing_exponential_backoff
55
+ #
56
+ # In essence, we try 5 times to perform the request. With each subsequent request,
57
+ # we wait 2^n seconds plus a random number of milliseconds (no greater than 1 second)
58
+ # until either we receive a successful response, or we run out of attempts.
59
+ # If the Retry-After header is in the error response, we use whichever happens to be
60
+ # greater, our calculated wait time, or the value in the Retry-After header.
61
+ #
62
+ # If development_mode is set to true, we only run the request once. This speeds up
63
+ # development for those using this gem.
64
+ attempt = 0
65
+ max_attempts = GoogleAPI.development_mode ? 1 : 5
66
+ while attempt < max_attempts
67
+ response = access_token.send(method.to_sym, url, options)
68
+ seconds_to_wait = [((2 ** attempt) + rand), response.headers['Retry-After'].to_i].max
69
+ attempt += 1
70
+ break if response.status < 400 || attempt == max_attempts
71
+ GoogleAPI.logger.error "Request attempt ##{attempt} to #{url} failed for. Trying again in #{seconds_to_wait} seconds..."
72
+ sleep seconds_to_wait
47
73
  end
48
- else
49
- response
50
- end
51
- end
52
74
 
53
- # Shortcut methods to easily execute a HTTP request with any of the below HTTP verbs.
54
- [:get, :post, :put, :patch, :delete].each do |method|
55
- define_method method do |url = nil, args = {}|
56
- request(method, url, args)
75
+ response.parsed || response
57
76
  end
58
- end
59
77
 
60
- # Build a resumable upload request that then delegates to #post and #put with the correct
61
- # headers for each request.
62
- #
63
- # The initial POST request initiates the upload process, passing the metadata for the file.
64
- # The response from the API includes a Location header telling us where to actually send the
65
- # file we want uploaded. The subsequent PUT request sends the file itself to the API.
66
- def upload(url, object, file_path)
67
- object[:mimeType] = MIME::Types.type_for(file_path).first.to_s
68
- file = File.read(file_path)
69
-
70
- response = post(build_upload_url(url), body: object, headers: {'X-Upload-Content-Type' => object[:mimeType]})
71
- put(response.headers['Location'], body: file, headers: {'Content-Type' => object[:mimeType], 'Content-Length' => file.bytesize.to_s})
72
- end
78
+ # Build a resumable upload request that then makes POST and PUT requests with the correct
79
+ # headers for each request.
80
+ #
81
+ # The initial POST request initiates the upload process, passing the metadata for the file.
82
+ # The response from the API includes a Location header telling us where to actually send the
83
+ # media we want uploaded. The subsequent PUT request sends the media itself to the API.
84
+ def upload(api_method, url, options = {})
85
+ mime_type = ::MIME::Types.type_for(options[:media]).first.to_s
86
+ file = File.read(options.delete(:media))
73
87
 
74
- private
75
- # Each class that inherits from this API class can have GDATA_VERSION set as a constant.
76
- # This is then passed on to each request if present to dictate which version of Google's
77
- # API we intend to use.
78
- def version
79
- self.class::GDATA_VERSION rescue nil
80
- end
88
+ options[:body][:mimeType] = mime_type
89
+ options[:headers] = (options[:headers] || {}).merge({'X-Upload-Content-Type' => mime_type})
81
90
 
82
- # By default we use JSON as the format we pass to Google and get back from Google. To override
83
- # this default setting, each class that inherits from this API class can have FORMAT set as
84
- # a constant. The only other possible value can be XML.
85
- def format
86
- raise ArgumentError, "#{self.class} has FORMAT set to #{self.class::FORMAT}, which is not :json or :xml" unless [:json, :xml].include?(self.class::FORMAT)
87
- self.class::FORMAT
91
+ response = request(api_method, url, options)
92
+
93
+ options[:body] = file
94
+ options[:headers].delete('X-Upload-Content-Type')
95
+ options[:headers].merge!({'Content-Type' => mime_type, 'Content-Length' => file.bytesize.to_s})
96
+
97
+ request(:put, response.headers['Location'], options)
88
98
  end
89
99
 
90
- # Build the header hash for the request. If #version is set, we pass that as GData-Version,
91
- # and if #format is set, we pass that as Content-Type.
92
- def headers
93
- headers = {}
94
- headers['GData-Version'] = version if version
95
- headers['Content-Type'] = case format
96
- when :json
97
- 'application/json'
98
- when :xml
99
- 'application/atom+xml'
100
+ # Put together the full URL we will send a request to.
101
+ # First we join the API's base URL with the current method's path, forming the main URL.
102
+ #
103
+ # If the method is mediaUpload-enabled (like uploading a file to Google Drive), then we want
104
+ # to take the path from the resumable upload protocol.
105
+ #
106
+ # If not, then, we are going to iterate through each of the parameters for the current method.
107
+ # When the parameter's location is within the path, we first check that we have had that
108
+ # option passed, and if so, substitute it in the correct place.
109
+ # When the parameter's location is a query, we add it to our query parameters hash, provided it is present.
110
+ # Before returning the URL and remaining options, we have to build the query parameters hash
111
+ # into a string and append it to the end of the URL.
112
+ def build_url(api_method, options = {})
113
+ if api_method['mediaUpload'] && options[:media]
114
+ # we need to do [1..-1] to remove the prepended slash
115
+ url = GoogleAPI.discovered_apis[api]['rootUrl'] + api_method['mediaUpload']['protocols']['resumable']['path'][1..-1]
116
+ else
117
+ url = GoogleAPI.discovered_apis[api]['baseUrl'] + api_method['path']
118
+ query_params = []
119
+ api_method['parameters'].each_with_index do |(param, settings), index|
120
+ param = param.to_sym
121
+ case settings['location']
122
+ when 'path'
123
+ raise ArgumentError, ":#{param} was not passed" if settings['required'] && !options[param]
124
+ url.sub!("{#{param}}", options.delete(param).to_s)
125
+ when 'query'
126
+ query_params << "#{param}=#{options.delete(param)}" if options[param]
127
+ end
128
+ end
129
+ url += "?#{query_params.join('&')}" if query_params.length > 0
100
130
  end
101
- return headers
102
- end
103
131
 
104
- def build_upload_url(url)
105
- path = URI(self.class::ENDPOINT).path
106
- "https://www.googleapis.com/upload#{path}#{url}?uploadType=resumable"
132
+ [url, options]
107
133
  end
108
134
 
109
135
  end
@@ -9,15 +9,19 @@ module GoogleAPI
9
9
  def initialize(object)
10
10
  @object = object
11
11
 
12
- raise NoMethodError, "GoogleAPI::Client must be passed an object that is to oauthable. #{object.class.name} is not oauthable." unless object.class.respond_to?(:oauthable)
12
+ raise NoMethodError, "#{self.class} must be passed an object that is to oauthable. #{object.class.name} is not oauthable." unless object.class.respond_to?(:oauthable)
13
+ raise ArgumentError, "#{object.class.name} does not appeared to be OAuth2 authenticated. GoogleAPI requires :oauth_access_token, :oauth_request_token, and :oauth_access_token_expires_at to be present." unless object.oauth_hash.values.all?
13
14
  end
14
15
 
15
16
  # Build an AccessToken object from OAuth2. Check if the access token is expired, and if so,
16
17
  # refresh it and save the new access token returned from Google.
17
18
  def access_token
18
- @access_token ||= ::OAuth2::AccessToken.new(client, oauth_hash[:access_token], oauth_hash.except(:access_token))
19
+ @access_token = ::OAuth2::AccessToken.new(client, object.oauth_hash[:access_token],
20
+ refresh_token: object.oauth_hash[:refresh_token],
21
+ expires_at: object.oauth_hash[:expires_at].to_i
22
+ )
19
23
  if @access_token.expired?
20
- puts "Access Token expired, refreshing..."
24
+ GoogleAPI.logger.info "Access Token expired for #{object.class.name}(#{object.id}), refreshing..."
21
25
  @access_token = @access_token.refresh!
22
26
  object.update_access_token!(@access_token.token)
23
27
  end
@@ -25,21 +29,8 @@ module GoogleAPI
25
29
  @access_token
26
30
  end
27
31
 
28
- # Build the oauth_hash used to build the AccessToken object above. If any of the values
29
- # are nil?, we raise an error and tell the user.
30
- def oauth_hash
31
- unless @oauth_hash
32
- hash = object.oauth_hash.dup
33
- hash[:expires_at] = hash[:expires_at].to_i if hash[:expires_at].present?
34
- raise ArgumentError, "#{object.class.name} does not appeared to be OAuth2 authenticated. GoogleAPI requires :oauth_access_token, :oauth_request_token, and :oauth_access_token_expires_at to be present." unless hash.values.all?
35
- end
36
-
37
- @oauth_hash ||= hash
38
- end
39
-
40
32
  # Build the OAuth2::Client object to be used when building an AccessToken.
41
33
  def client
42
- puts "Creating the OAuth2::Client object..." unless @client
43
34
  @client ||= ::OAuth2::Client.new(GoogleAPI.client_id, GoogleAPI.client_secret,
44
35
  site: 'https://accounts.google.com',
45
36
  token_url: '/o/oauth2/token',
@@ -47,16 +38,25 @@ module GoogleAPI
47
38
  )
48
39
  end
49
40
 
50
- # Each API that Google offers us is instantiated within its own method below.
51
- # If you want to find all calendars for a user, you would do this:
41
+ # We build the appropriate API here based on the method name passed to the Client.
42
+ # For example:
52
43
  #
53
- # GoogleAPI::Client.new(User.first).calendar.all
44
+ # User.find(1).google.drive
54
45
  #
55
- # See GoogleAPI::Calendar for more information about the Calendar API.
56
- %w(calendar drive).each do |api|
57
- define_method api do
58
- "GoogleAPI::#{api.capitalize}".constantize.new(access_token)
46
+ # We will then discover and cache the Google Drive API for future use.
47
+ # Any methods chained to the resultant API will then be passed along to the
48
+ # instantiaed class. Read the documentation for GoogleAPI::API#method_missing for
49
+ # more information.
50
+ def method_missing(api, *args)
51
+ unless GoogleAPI.discovered_apis.has_key?(api)
52
+ GoogleAPI.logger.info "Discovering the #{api} Google API..."
53
+ response = access_token.get("https://www.googleapis.com/discovery/v1/apis?preferred=true&name=#{api}").parsed['items']
54
+ super unless response # Raise a NoMethodError if Google's Discovery API does not return a good response
55
+ discovery_url = response.first['discoveryRestUrl']
56
+ GoogleAPI.discovered_apis[api] = access_token.get(discovery_url).parsed
59
57
  end
58
+
59
+ API.new(access_token, api, GoogleAPI.discovered_apis[api]['resources'])
60
60
  end
61
61
 
62
62
  end
@@ -0,0 +1,16 @@
1
+ # This can go away once the below commit is released with OAuth2.
2
+ # https://github.com/intridea/oauth2/commit/8dc6feab9927c3fc03b8e0975909a96049a1cbd3
3
+ # Should be in 0.8.1 or 0.9.0
4
+
5
+ module OAuth2
6
+ class AccessToken
7
+
8
+ # Make a PATCH request with the Access Token
9
+ #
10
+ # @see AccessToken#request
11
+ def patch(path, opts={}, &block)
12
+ request(:patch, path, opts, &block)
13
+ end
14
+
15
+ end
16
+ end
@@ -4,7 +4,7 @@
4
4
  require 'google-api/active_record_inclusions'
5
5
 
6
6
  module GoogleAPI
7
- class Railtie < Rails::Railtie
7
+ class Railtie < ::Rails::Railtie
8
8
 
9
9
  initializer "google_api.configure_rails_initialization" do |app|
10
10
  ActiveRecord::Base.send(:include, ActiveRecordInclusions)
data/lib/google-api.rb CHANGED
@@ -1,24 +1,61 @@
1
+ require 'mime/types'
2
+ require 'oauth2'
3
+ require 'google-api/oauth2'
1
4
  require 'google-api/api'
2
- require 'google-api/api/calendar'
3
- require 'google-api/api/drive'
4
5
  require 'google-api/client'
5
- require 'google-api/railtie'
6
+ if defined?(::Rails)
7
+ require 'google-api/railtie'
8
+ else
9
+ require 'logger'
10
+ end
6
11
 
7
12
  module GoogleAPI
8
13
 
9
- # This enables us to easily pass the client_id and client_secret from the Rails
10
- # app with a GoogleAPI.configure block. See the README for more information.
14
+ # This enables us to easily pass the client_id and client_secret with a
15
+ # GoogleAPI.configure block. See the README for more information.
11
16
  # Loosely adapted from Twitter's configuration capabilities.
12
17
  # https://github.com/sferik/twitter/blob/v3.6.0/lib/twitter/configurable.rb
13
18
  class << self
14
19
 
15
- attr_accessor :client_id, :client_secret
20
+ attr_accessor :client_id, :client_secret, :development_mode, :logger
16
21
 
22
+ # Configuration options:
23
+ #
24
+ # development_mode: make it easier to build your application with this API. Default is false
25
+ # logger: if this gem is included in a Rails app, we will use the Rails.logger, otherwise, we log to STDOUT
17
26
  def configure
18
27
  yield self
28
+
29
+ raise ArgumentError, "GoogleAPI requires both a :client_id and :client_secret configuration option to be set." unless [client_id, client_secret].all?
30
+
31
+ @development_mode ||= false
32
+ @logger ||= defined?(::Rails) ? Rails.logger : stdout_logger
33
+
19
34
  self
20
35
  end
21
36
 
37
+ # An internally used hash to cache the discovered API responses.
38
+ # Keys could be 'drive', 'calendar', 'contacts', etc.
39
+ # Values will be a parsed JSON hash.
40
+ def discovered_apis
41
+ @discovered_apis ||= {}
42
+ end
43
+
44
+ # The default logger for this API. When we aren't within a Rails app,
45
+ # we will output log messages to STDOUT.
46
+ def stdout_logger
47
+ logger = Logger.new(STDOUT)
48
+ logger.progname = "google-api"
49
+ logger
50
+ end
51
+
52
+ # Used primarily within the test suite to reset our GoogleAPI environment for each test.
53
+ def reset_environment!
54
+ @development_mode = false
55
+ @logger = nil
56
+ @discovered_apis = {}
57
+ end
58
+
22
59
  end
23
60
 
24
61
  end
@@ -0,0 +1,22 @@
1
+ {
2
+ "kind": "discovery#directoryList",
3
+ "discoveryVersion": "v1",
4
+ "items": [
5
+ {
6
+ "kind": "discovery#directoryItem",
7
+ "id": "drive:v2",
8
+ "name": "drive",
9
+ "version": "v2",
10
+ "title": "Drive API",
11
+ "description": "The API to interact with Drive.",
12
+ "discoveryRestUrl": "https://www.googleapis.com/discovery/v1/apis/drive/v2/rest",
13
+ "discoveryLink": "./apis/drive/v2/rest",
14
+ "icons": {
15
+ "x16": "https://ssl.gstatic.com/docs/doclist/images/drive_icon_16.png",
16
+ "x32": "https://ssl.gstatic.com/docs/doclist/images/drive_icon_32.png"
17
+ },
18
+ "documentationLink": "https://developers.google.com/drive/",
19
+ "preferred": true
20
+ }
21
+ ]
22
+ }