yt 0.12.2 → 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  # encoding: UTF-8
2
2
  require 'yt/actions/list'
3
- require 'yt/models/request'
3
+ require 'yt/request'
4
4
 
5
5
  module Yt
6
6
  module Actions
@@ -1,5 +1,6 @@
1
- require 'yt/models/request'
1
+ require 'yt/request'
2
2
  require 'yt/actions/base'
3
+ require 'yt/config'
3
4
 
4
5
  module Yt
5
6
  module Actions
@@ -9,20 +10,27 @@ module Yt
9
10
  private
10
11
 
11
12
  def do_insert(extra_insert_params = {})
12
- request = Yt::Request.new insert_params.deep_merge(extra_insert_params)
13
- response = request.run
13
+ response = insert_request(extra_insert_params).run
14
14
  @items = []
15
15
  new_item extract_data_from(response)
16
16
  end
17
17
 
18
+ def insert_request(params = {})
19
+ Yt::Request.new(insert_params.deep_merge params).tap do |request|
20
+ print "#{request.as_curl}\n" if Yt.configuration.developing?
21
+ end
22
+ end
23
+
18
24
  def insert_params
19
25
  path = "/youtube/v3/#{self.class.to_s.demodulize.camelize :lower}"
20
26
 
21
27
  {}.tap do |params|
22
28
  params[:path] = path
29
+ params[:host] = 'www.googleapis.com'
23
30
  params[:method] = :post
24
31
  params[:auth] = @auth
25
32
  params[:expected_response] = Net::HTTPOK
33
+ params[:api_key] = Yt.configuration.api_key if Yt.configuration.api_key
26
34
  end
27
35
  end
28
36
 
@@ -1,6 +1,7 @@
1
- require 'yt/models/request'
1
+ require 'yt/request'
2
2
  require 'yt/models/iterator'
3
3
  require 'yt/errors/no_items'
4
+ require 'yt/config'
4
5
 
5
6
  module Yt
6
7
  module Actions
@@ -9,7 +10,7 @@ module Yt
9
10
  :size, to: :list
10
11
 
11
12
  def first!
12
- first.tap{|item| raise Errors::NoItems, last_request unless item}
13
+ first.tap{|item| raise Errors::NoItems, error_message unless item}
13
14
  end
14
15
 
15
16
  private
@@ -38,7 +39,7 @@ module Yt
38
39
  # iterate through all the pages, which can results in many requests.
39
40
  # To avoid this, +total_results+ is provided as a good size estimation.
40
41
  def total_results
41
- response = request(list_params).run
42
+ response = list_request(list_params).run
42
43
  total_results = response.body.fetch('pageInfo', {})['totalResults']
43
44
  total_results ||= response.body.fetch(items_key, []).size
44
45
  end
@@ -87,18 +88,23 @@ module Yt
87
88
  end
88
89
 
89
90
  def fetch_page(params = {})
90
- response = request(params).run
91
- token = response.body['nextPageToken']
92
- items = response.body.fetch items_key, []
91
+ @last_response = list_request(params).run
92
+ token = @last_response.body['nextPageToken']
93
+ items = @last_response.body.fetch items_key, []
93
94
  {items: items, token: token}
94
95
  end
95
96
 
96
- def request(params = {})
97
- @last_request = Yt::Request.new params
97
+ def list_request(params = {})
98
+ @list_request = Yt::Request.new(params).tap do |request|
99
+ print "#{request.as_curl}\n" if Yt.configuration.developing?
100
+ end
98
101
  end
99
102
 
100
- def last_request
101
- @last_request.request_error_message if @last_request
103
+ def error_message
104
+ {}.tap do |message|
105
+ message[:request_curl] = @list_request.as_curl
106
+ message[:response_body] = JSON(@last_response.body)
107
+ end.to_json if @list_request && @last_response
102
108
  end
103
109
 
104
110
  def list_params
@@ -106,9 +112,11 @@ module Yt
106
112
 
107
113
  {}.tap do |params|
108
114
  params[:method] = :get
115
+ params[:host] = 'www.googleapis.com'
109
116
  params[:auth] = @auth
110
117
  params[:path] = path
111
118
  params[:exptected_response] = Net::HTTPOK
119
+ params[:api_key] = Yt.configuration.api_key if Yt.configuration.api_key
112
120
  end
113
121
  end
114
122
 
@@ -1,5 +1,6 @@
1
- require 'yt/models/request'
1
+ require 'yt/request'
2
2
  require 'yt/actions/base'
3
+ require 'yt/config'
3
4
 
4
5
  module Yt
5
6
  module Actions
@@ -10,18 +11,25 @@ module Yt
10
11
  private
11
12
 
12
13
  def do_modify(params = {})
13
- request = Yt::Request.new params
14
- response = request.run
14
+ response = modify_request(params).run
15
15
  yield response.body if block_given?
16
16
  end
17
17
 
18
+ def modify_request(params = {})
19
+ Yt::Request.new(params).tap do |request|
20
+ print "#{request.as_curl}\n" if Yt.configuration.developing?
21
+ end
22
+ end
23
+
18
24
  def modify_params
19
25
  path = "/youtube/v3/#{self.class.to_s.demodulize.pluralize.camelize :lower}"
20
26
 
21
27
  {}.tap do |params|
22
28
  params[:path] = path
23
29
  params[:auth] = @auth
30
+ params[:host] = 'www.googleapis.com'
24
31
  params[:expected_response] = Net::HTTPNoContent
32
+ params[:api_key] = Yt.configuration.api_key if Yt.configuration.api_key
25
33
  end
26
34
  end
27
35
  end
@@ -51,7 +51,7 @@ module Yt
51
51
 
52
52
  # Obtains a new access token.
53
53
  # Returns true if the new access token is different from the previous one
54
- def refresh
54
+ def refreshed_access_token?
55
55
  old_access_token = authentication.access_token
56
56
  @authentication = @access_token = @refreshed_authentications = nil
57
57
  old_access_token != authentication.access_token
@@ -17,7 +17,7 @@ module Yt
17
17
  # a video, so we use an "old-style" URL that YouTube still maintains.
18
18
  def list_params
19
19
  super.tap do |params|
20
- params[:format] = :xml
20
+ params[:response_format] = :xml
21
21
  params[:host] = 'www.youtube.com'
22
22
  params[:path] = '/annotations_invideo'
23
23
  params[:params] = {video_id: @parent.id}
@@ -30,7 +30,9 @@ module Yt
30
30
  # @note Annotations overwrites +next_page+ since the list of annotations
31
31
  # is not paginated API-style, but in its own custom way.
32
32
  def next_page
33
- request = Yt::Request.new list_params
33
+ request = Yt::Request.new(list_params).tap do |request|
34
+ print "#{request.as_curl}\n" if Yt.configuration.developing?
35
+ end
34
36
  response = request.run
35
37
  @page_token = nil
36
38
 
@@ -17,7 +17,7 @@ module Yt
17
17
  super.tap do |params|
18
18
  params[:host] = 'accounts.google.com'
19
19
  params[:path] = '/o/oauth2/token'
20
- params[:body_type] = :form
20
+ params[:request_format] = :form
21
21
  params[:method] = :post
22
22
  params[:auth] = nil
23
23
  params[:body] = auth_params
@@ -30,7 +30,9 @@ module Yt
30
30
  end
31
31
 
32
32
  def next_page
33
- request = Yt::Request.new list_params
33
+ request = Yt::Request.new(list_params).tap do |request|
34
+ print "#{request.as_curl}\n" if Yt.configuration.developing?
35
+ end
34
36
  Array.wrap request.run.body
35
37
  rescue Yt::Error => error
36
38
  expected?(error) ? [] : raise(error)
@@ -26,7 +26,7 @@ module Yt
26
26
  # @private
27
27
  # @note Ownerships overwrites +fetch_page+ since it’s a get.
28
28
  def fetch_page(params = {})
29
- response = request(params).run
29
+ response = list_request(params).run
30
30
  {items: [response.body], token: nil}
31
31
  end
32
32
  end
@@ -5,9 +5,17 @@ module Yt
5
5
  class Reports < Base
6
6
  attr_writer :metric
7
7
 
8
- def within(days_range)
8
+ def within(days_range, try_again = true)
9
9
  @days_range = days_range
10
10
  Hash[*flat_map{|daily_value| daily_value}]
11
+ # NOTE: Once in a while, YouTube responds with 400 Error and the message
12
+ # "Invalid query. Query did not conform to the expectations."; in this
13
+ # case running the same query after one second fixes the issue. This is
14
+ # not documented by YouTube and hardly testable, but trying again the
15
+ # same query is a workaround that works and can hardly cause any damage.
16
+ # Similarly, once in while YouTube responds with a random 503 error.
17
+ rescue Yt::Error => e
18
+ try_again && rescue?(e) ? sleep(3) && within(days_range, false) : raise
11
19
  end
12
20
 
13
21
  private
@@ -47,6 +55,10 @@ module Yt
47
55
  def items_key
48
56
  'rows'
49
57
  end
58
+
59
+ def rescue?(error)
60
+ 'badRequest'.in?(error.reasons) && error.to_s =~ /did not conform/
61
+ end
50
62
  end
51
63
  end
52
64
  end
@@ -18,12 +18,14 @@ module Yt
18
18
  # @option options [String] :title The video’s title.
19
19
  # @option options [String] :description The video’s description.
20
20
  # @option options [Array<String>] :title The video’s tags.
21
+ # @option options [Integer] :category_id The video’s category ID.
21
22
  # @option options [String] :privacy_status The video’s privacy status.
22
23
  def insert(content_length, options = {})
23
24
  @headers = headers_for content_length
24
25
  body = {}
25
26
 
26
- snippet = options.slice :title, :description, :tags
27
+ snippet = options.slice :title, :description, :tags, :category_id
28
+ snippet[:categoryId] = snippet.delete(:category_id) if snippet[:category_id]
27
29
  body[:snippet] = snippet if snippet.any?
28
30
 
29
31
  status = options[:privacy_status]
@@ -40,7 +42,7 @@ module Yt
40
42
 
41
43
  def insert_params
42
44
  super.tap do |params|
43
- params[:format] = nil
45
+ params[:response_format] = nil
44
46
  params[:path] = '/upload/youtube/v3/videos'
45
47
  params[:params] = {part: 'snippet,status', uploadType: 'resumable'}
46
48
  end
@@ -22,7 +22,9 @@ module Yt
22
22
  # so @page_token has to be explcitly set to nil, and the result wrapped
23
23
  # in an Array.
24
24
  def next_page
25
- request = Yt::Request.new list_params
25
+ request = Yt::Request.new(list_params).tap do |request|
26
+ print "#{request.as_curl}\n" if Yt.configuration.developing?
27
+ end
26
28
  response = request.run
27
29
  @page_token = nil
28
30
 
@@ -9,7 +9,7 @@ module Yt
9
9
  # @!attribute [r] channel
10
10
  # @return [Yt::Models::Channel] the account’s channel.
11
11
  has_one :channel
12
- delegate :playlists, :create_playlist, :delete_playlists,
12
+ delegate :playlists, :delete_playlists,
13
13
  :subscribed_channels, to: :channel
14
14
 
15
15
  # @!attribute [r] user_info
@@ -42,6 +42,10 @@ module Yt
42
42
  # by the account.
43
43
  has_many :content_owners
44
44
 
45
+ # @!attribute [r] playlists
46
+ # @return [Yt::Collections::Playlists] the account’s playlists.
47
+ has_many :playlists
48
+
45
49
  # Uploads a video
46
50
  # @param [String] path_or_url the video to upload. Can either be the
47
51
  # path of a local file or the URL of a remote file.
@@ -57,6 +61,10 @@ module Yt
57
61
  session.upload_video file
58
62
  end
59
63
 
64
+ def create_playlist(params = {})
65
+ playlists.insert params
66
+ end
67
+
60
68
  # @private
61
69
  # Tells `has_many :videos` that account.videos should return all the
62
70
  # videos *owned by* the account (public, private, unlisted).
@@ -131,10 +131,6 @@ module Yt
131
131
  end
132
132
  end
133
133
 
134
- def create_playlist(params = {})
135
- playlists.insert params
136
- end
137
-
138
134
  def delete_playlists(attrs = {})
139
135
  playlists.delete_all attrs
140
136
  end
@@ -52,7 +52,7 @@ module Yt
52
52
  # @see https://developers.google.com/youtube/v3/docs/videos/update
53
53
  def update_params
54
54
  super.tap do |params|
55
- params[:body_type] = :json
55
+ params[:request_format] = :json
56
56
  params[:expected_response] = Net::HTTPOK
57
57
  end
58
58
  end
@@ -34,7 +34,7 @@ module Yt
34
34
  # To be sure to include both cases, HTTPSuccess is used
35
35
  def update_params
36
36
  super.tap do |params|
37
- params[:body_type] = :file
37
+ params[:request_format] = :file
38
38
  params[:host] = @uri.host
39
39
  params[:path] = @uri.path
40
40
  params[:expected_response] = Net::HTTPSuccess
@@ -0,0 +1,267 @@
1
+ require 'net/http' # for Net::HTTP.start
2
+ require 'uri' # for URI.json
3
+ require 'json' # for JSON.parse
4
+ require 'active_support' # does not load anything by default but is required
5
+ require 'active_support/core_ext' # for Hash.from_xml, Hash.to_param
6
+
7
+ require 'yt/errors/forbidden'
8
+ require 'yt/errors/missing_auth'
9
+ require 'yt/errors/request_error'
10
+ require 'yt/errors/server_error'
11
+ require 'yt/errors/unauthorized'
12
+
13
+ module Yt
14
+ # A wrapper around Net::HTTP to send HTTP requests to any web API and
15
+ # return their result or raise an error if the result is unexpected.
16
+ # The basic way to use Request is by calling +run+ on an instance.
17
+ # @example List the most popular videos on YouTube.
18
+ # host = ''www.googleapis.com'
19
+ # path = '/youtube/v3/videos'
20
+ # params = {chart: 'mostPopular', key: ENV['API_KEY'], part: 'snippet'}
21
+ # response = Yt::Request.new(path: path, params: params).run
22
+ # response.body['items'].map{|video| video['snippet']['title']}
23
+ #
24
+ class Request
25
+ # Initializes a Request object.
26
+ # @param [Hash] options the options for the request.
27
+ # @option options [String, Symbol] :method (:get) The HTTP method to use.
28
+ # @option options [Class] :expected_response (Net::HTTPSuccess) The class
29
+ # of response that the request should obtain when run.
30
+ # @option options [String, Symbol] :response_format (:json) The expected
31
+ # format of the response body. If passed, the response body will be
32
+ # parsed according to the format before being returned.
33
+ # @option options [String] :host The host component of the request URI.
34
+ # @option options [String] :path The path component of the request URI.
35
+ # @option options [Hash] :params ({}) The params to use as the query
36
+ # component of the request URI, for instance the Hash {a: 1, b: 2}
37
+ # corresponds to the query parameters "a=1&b=2".
38
+ # @option options [Hash] :camelize_params (true) whether to transform
39
+ # each key of params into a camel-case symbol before sending the
40
+ # request. For instance, if set to true, the params {aBc: 1, d_e: 2,
41
+ # 'f' => 3} would be sent as {aBc: 1, dE: 2, f: 3}.
42
+ # @option options [Hash] :request_format (:json) The format of the
43
+ # requesty body. If a request body is passed, it will be parsed
44
+ # according to this format before sending it in the request.
45
+ # @option options [#size] :body The body component of the request.
46
+ # @option options [Hash] :headers ({}) The headers component of the
47
+ # request.
48
+ # @option options [#access_token, #refreshed_access_token?] :auth The
49
+ # authentication object. If set, must respond to +access_token+ and
50
+ # return the OAuth token to make an authenticated request, and must
51
+ # respond to +refreshed_access_token?+ and return whether the access
52
+ # token can be refreshed if expired.
53
+ def initialize(options = {})
54
+ @method = options.fetch :method, :get
55
+ @expected_response = options.fetch :expected_response, Net::HTTPSuccess
56
+ @response_format = options.fetch :response_format, :json
57
+ @host = options[:host]
58
+ @path = options[:path]
59
+ @params = options.fetch :params, {}
60
+ # Note: This is to be invoked by auth-only YouTube APIs.
61
+ @params[:key] = options[:api_key] if options[:api_key]
62
+ # Note: This is to be invoked by all YouTube API except Annotations,
63
+ # Analyitics and Uploads
64
+ camelize_keys! @params if options.fetch(:camelize_params, true)
65
+ @request_format = options.fetch :request_format, :json
66
+ @body = options[:body]
67
+ @headers = options.fetch :headers, {}
68
+ @auth = options[:auth]
69
+ end
70
+
71
+ # Sends the request and returns the response.
72
+ # If the request fails once for a temporary server error or an expired
73
+ # token, tries the request again before eventually raising an error.
74
+ # @return [Net::HTTPResponse] if the request succeeds and matches the
75
+ # expectations, the response with the body appropriately parsed.
76
+ # @raise [Yt::RequestError] if the request fails or the response does
77
+ # not match the expectations.
78
+ def run
79
+ if matches_expectations?
80
+ response.tap{parse_response!}
81
+ elsif run_again?
82
+ run
83
+ else
84
+ raise response_error, error_message.to_json
85
+ end
86
+ end
87
+
88
+ # Returns the +cURL+ version of the request, useful to re-run the request
89
+ # in a shell terminal.
90
+ # @return [String] the +cURL+ version of the request.
91
+ def as_curl
92
+ 'curl'.tap do |curl|
93
+ curl << " -X #{http_request.method}"
94
+ http_request.each_header{|k, v| curl << %Q{ -H "#{k}: #{v}"}}
95
+ curl << %Q{ -d '#{http_request.body}'} if http_request.body
96
+ curl << %Q{ "#{uri.to_s}"}
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # @return [URI::HTTPS] the (memoized) URI of the request.
103
+ def uri
104
+ attributes = {host: @host, path: @path, query: @params.to_param}
105
+ @uri ||= URI::HTTPS.build attributes
106
+ end
107
+
108
+ # @return [Net::HTTPRequest] the full HTTP request object,
109
+ # inclusive of headers of request body.
110
+ def http_request
111
+ net_http_class = "Net::HTTP::#{@method.capitalize}".constantize
112
+ @http_request ||= net_http_class.new(uri.request_uri).tap do |request|
113
+ set_request_body! request
114
+ set_request_headers! request
115
+ end
116
+ end
117
+
118
+ # Adds the request body to the request in the appropriate format.
119
+ # if the request body is a JSON Object, transform its keys into camel-case,
120
+ # since this is the common format for JSON APIs.
121
+ def set_request_body!(request)
122
+ case @request_format
123
+ when :json then request.body = (camelize_keys! @body).to_json
124
+ when :form then request.set_form_data @body
125
+ when :file then request.body_stream = @body
126
+ end if @body
127
+ end
128
+
129
+ # Destructively converts all the keys of hash to camel-case symbols.
130
+ # Note: This is to be invoked by all YouTube API except Accounts
131
+ def camelize_keys!(hash)
132
+ hash.keys.each do |key|
133
+ hash[key.to_s.camelize(:lower).to_sym] = hash.delete key
134
+ end if hash.is_a? Hash
135
+ hash
136
+ end
137
+
138
+ # Adds the request headers to the request in the appropriate format.
139
+ # The User-Agent header is also set to recognize the request, and to
140
+ # tell the server that gzip compression can be used, since Net::HTTP
141
+ # supports it and automatically sets the Accept-Encoding header.
142
+ def set_request_headers!(request)
143
+ case @request_format
144
+ when :json
145
+ request.initialize_http_header 'Content-Type' => 'application/json'
146
+ request.initialize_http_header 'Content-length' => '0' unless @body
147
+ when :file
148
+ request.initialize_http_header 'Content-Length' => @body.size.to_s
149
+ request.initialize_http_header 'Transfer-Encoding' => 'chunked'
150
+ end
151
+ @headers['User-Agent'] = 'Yt::Request (gzip)'
152
+ @headers['Authorization'] = "Bearer #{@auth.access_token}" if @auth
153
+ @headers.each{|name, value| request.add_field name, value}
154
+ end
155
+
156
+ # @return [Boolean] whether the class of response returned by running
157
+ # the request matches the expected class of response.
158
+ def matches_expectations?
159
+ response.is_a? @expected_response
160
+ end
161
+
162
+ # Run the request and memoize the response or the server error received.
163
+ def response
164
+ @response ||= send_http_request
165
+ rescue *server_errors => e
166
+ @response ||= e
167
+ end
168
+
169
+ # Send the request to the server, allowing ActiveSupport::Notifications
170
+ # client to subscribe to the request.
171
+ def send_http_request
172
+ net_http_options = [uri.host, uri.port, use_ssl: true]
173
+ ActiveSupport::Notifications.instrument 'request.yt' do |payload|
174
+ payload[:method] = @method
175
+ payload[:request_uri] = uri
176
+ payload[:response] = Net::HTTP.start(*net_http_options) do |http|
177
+ http.request http_request
178
+ end
179
+ end
180
+ end
181
+
182
+ # Replaces the body of the response with the parsed version of the body,
183
+ # according to the format specified in the Request.
184
+ def parse_response!
185
+ response.body = case @response_format
186
+ when :xml then Hash.from_xml response.body
187
+ when :json then JSON response.body
188
+ end if response.body
189
+ end
190
+
191
+ # Returns whether it is worth to run a failed request again.
192
+ # There are two cases in which retrying a request might be worth:
193
+ # - when the server specifies that the request token has expired and
194
+ # the user has to refresh the token in order to tryi again
195
+ # - when the server is unreachable, and waiting for a couple of seconds
196
+ # might solve the connection issues.
197
+ def run_again?
198
+ refresh_token_and_retry? || server_error? && sleep_and_retry?
199
+ end
200
+
201
+ # Returns the list of server errors worth retrying the request once.
202
+ def server_errors
203
+ [
204
+ OpenSSL::SSL::SSLError,
205
+ Errno::ETIMEDOUT,
206
+ Errno::ENETUNREACH,
207
+ Errno::ECONNRESET,
208
+ Net::HTTPServerError
209
+ ]
210
+ end
211
+
212
+ # Sleeps for a while and returns true for the first +max_retries+ times,
213
+ # then returns false. Useful to try the same request again multiple
214
+ # times with a delay if a connection error occurs.
215
+ def sleep_and_retry?(max_retries = 1)
216
+ @retries_so_far ||= -1
217
+ @retries_so_far += 1
218
+ if (@retries_so_far < max_retries)
219
+ @response = @http_request = @uri = nil
220
+ sleep 3
221
+ end
222
+ end
223
+
224
+ # In case an authorized request responds with "Unauthorized", checks
225
+ # if the original access token can be refreshed. If that's the case,
226
+ # clears the memoized variables and returns true, so the request can
227
+ # be run again, otherwise raises an error.
228
+ def refresh_token_and_retry?
229
+ if unauthorized? && @auth && @auth.refreshed_access_token?
230
+ @response = @http_request = @uri = nil
231
+ true
232
+ end
233
+ rescue Errors::MissingAuth
234
+ false
235
+ end
236
+
237
+ # @return [Yt::RequestError] the error associated to the class of the
238
+ # response.
239
+ def response_error
240
+ case response
241
+ when *server_errors then Errors::ServerError
242
+ when Net::HTTPUnauthorized then Errors::Unauthorized
243
+ when Net::HTTPForbidden then Errors::Forbidden
244
+ else Errors::RequestError
245
+ end
246
+ end
247
+
248
+ # @return [Boolean] whether the response matches any server error.
249
+ def server_error?
250
+ response_error == Errors::ServerError
251
+ end
252
+
253
+ # @return [Boolean] whether the request lacks proper authorization.
254
+ def unauthorized?
255
+ response_error == Errors::Unauthorized
256
+ end
257
+
258
+ # Return the elements of the request/response that are worth displaying
259
+ # as an error message if the request fails.
260
+ # If the response format is JSON, showing the parsed body is sufficient,
261
+ # otherwise the whole (inspected) response is worth looking at.
262
+ def error_message
263
+ response_body = JSON(response.body) rescue response.inspect
264
+ {request_curl: as_curl, response_body: response_body}
265
+ end
266
+ end
267
+ end