yt 0.12.2 → 0.13.0

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.
@@ -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