rdropbox 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,530 @@
1
+ # Defines the Dropbox::API module.
2
+
3
+ require "#{File.expand_path File.dirname(__FILE__)}/memoization"
4
+ require 'json'
5
+ require 'net/http/post/multipart'
6
+
7
+ module Dropbox
8
+
9
+ # Extensions to the Dropbox::Session class that add core Dropbox API
10
+ # functionality to this class. You must have authenticated your
11
+ # Dropbox::Session instance before you can call any of these methods. (See the
12
+ # Dropbox::Session class documentation for instructions.)
13
+ #
14
+ # API methods generally return +Struct+ objects containing their results,
15
+ # unless otherwise noted. See the Dropbox API documentation at
16
+ # http://developers.dropbox.com for specific information on the schema of each
17
+ # result.
18
+ #
19
+ # You can opt-in to memoization of API method results. See the
20
+ # Dropbox::Memoization class documentation to learn more.
21
+ #
22
+ # == Modes
23
+ #
24
+ # The Dropbox API works in three modes: sandbox, Dropbox (root), and
25
+ # metadata-only.
26
+ #
27
+ # * In sandbox mode (the default), all operations are rooted from your
28
+ # application's sandbox folder; other files elsewhere on the user's Dropbox
29
+ # are inaccessible.
30
+ # * In Dropbox mode, the root is the user's Dropbox folder, and all files are
31
+ # accessible. This mode is typically only available to certain API users.
32
+ # * In metadata-only mode, the root is the Dropbox folder, but read-only
33
+ # access is not available. Operations that modify the user's files will
34
+ # fail.
35
+ #
36
+ # You should configure the Dropbox::Session instance to use whichever mode
37
+ # you chose when you set up your application:
38
+ #
39
+ # session.mode = :metadata_only
40
+ #
41
+ # Valid values are listed in Dropbox::API::MODES, and this step is not
42
+ # necessary for sandboxed applications, as the sandbox mode is the default.
43
+ #
44
+ # You can also temporarily change the mode for many method calls using their
45
+ # options hash:
46
+ #
47
+ # session.move 'my_file', 'new/path', :mode => :dropbox
48
+
49
+ module API
50
+ include Dropbox::Memoization
51
+
52
+ # Valid API modes for the #mode= method.
53
+ MODES = [ :sandbox, :dropbox, :metadata_only ]
54
+
55
+ # Returns a Dropbox::Entry instance that can be used to work with files or
56
+ # directories in an object-oriented manner.
57
+
58
+ def entry(path)
59
+ Dropbox::Entry.new(self, path)
60
+ end
61
+ alias :file :entry
62
+ alias :directory :entry
63
+ alias :dir :entry
64
+
65
+ # Returns a +Struct+ with information about the user's account. See
66
+ # http://developers.dropbox.com/python/base.html#account-info for more
67
+ # information on the data returned.
68
+
69
+ def account
70
+ get('account', 'info', :ssl => @ssl).to_struct_recursively
71
+ end
72
+ memoize :account
73
+
74
+ # Downloads the file at the given path relative to the configured mode's
75
+ # root.
76
+ #
77
+ # Returns the contents of the downloaded file as a +String+. Support for
78
+ # streaming downloads and range queries is available server-side, but not
79
+ # available in this API client due to limitations of the OAuth gem.
80
+ #
81
+ # Options:
82
+ #
83
+ # +mode+:: Temporarily changes the API mode. See the MODES array.
84
+
85
+ def download(path, options={})
86
+ path.sub! /^\//, ''
87
+ rest = Dropbox.check_path(path).split('/')
88
+ rest << { :ssl => @ssl }
89
+ api_body :get, 'files', root(options), *rest
90
+ #TODO streaming, range queries
91
+ end
92
+
93
+ # Uploads a file to a path relative to the configured mode's root. The
94
+ # +remote_path+ parameter is taken to be the path portion _only_; the name
95
+ # of the remote file will be identical to that of the local file. You can
96
+ # provide any of the following for the first parameter:
97
+ #
98
+ # * a +File+ object, in which case the name of the local file is used, or
99
+ # * a path to a file, in which case that file's name is used.
100
+ #
101
+ # Options:
102
+ #
103
+ # +mode+:: Temporarily changes the API mode. See the MODES array.
104
+ #
105
+ # Examples:
106
+ #
107
+ # session.upload 'music.pdf', '/' # upload a file by path to the root directory
108
+ # session.upload 'music.pdf, 'music/' # upload a file by path to the music folder
109
+ # session.upload File.new('music.pdf'), '/' # same as the first example
110
+
111
+ def upload(local_file, remote_path, options={})
112
+ if local_file.kind_of?(File) or local_file.kind_of?(Tempfile) then
113
+ file = local_file
114
+ name = local_file.respond_to?(:original_filename) ? local_file.original_filename : File.basename(local_file.path)
115
+ local_path = local_file.path
116
+ elsif local_file.kind_of?(String) then
117
+ file = File.new(local_file)
118
+ name = File.basename(local_file)
119
+ local_path = local_file
120
+ else
121
+ raise ArgumentError, "local_file must be a File or file path"
122
+ end
123
+
124
+ remote_path.sub! /^\//, ''
125
+ remote_path = Dropbox.check_path(remote_path).split('/')
126
+
127
+ remote_path << { :ssl => @ssl }
128
+ url = Dropbox.api_url('files', root(options), *remote_path)
129
+ uri = URI.parse(url)
130
+
131
+ oauth_request = Net::HTTP::Post.new(uri.path)
132
+ oauth_request.set_form_data 'file' => name
133
+
134
+ alternate_host_session = clone_with_host(@ssl ? Dropbox::ALTERNATE_SSL_HOSTS['files'] : Dropbox::ALTERNATE_HOSTS['files'])
135
+ alternate_host_session.instance_variable_get(:@consumer).sign!(oauth_request, @access_token)
136
+ oauth_signature = oauth_request.to_hash['authorization']
137
+
138
+ request = Net::HTTP::Post::Multipart.new(uri.path,
139
+ 'file' => UploadIO.convert!(
140
+ file,
141
+ 'application/octet-stream',
142
+ name,
143
+ local_path))
144
+ request['authorization'] = oauth_signature.join(', ')
145
+
146
+ response = Net::HTTP.start(uri.host, uri.port) { |http| http.request(request) }
147
+ if response.kind_of?(Net::HTTPSuccess) then
148
+ begin
149
+ return JSON.parse(response.body).symbolize_keys_recursively.to_struct_recursively
150
+ rescue JSON::ParserError
151
+ raise ParseError.new(uri.to_s, response)
152
+ end
153
+ else
154
+ raise UnsuccessfulResponseError.new(uri.to_s, response)
155
+ end
156
+ end
157
+
158
+ # Copies the +source+ file to the path at +target+. If +target+ ends with a
159
+ # slash, the new file will share the same name as the old file. Returns a
160
+ # +Struct+ with metadata for the new file. (See the metadata method.)
161
+ #
162
+ # Both paths are assumed to be relative to the configured mode's root.
163
+ #
164
+ # Raises FileNotFoundError if +source+ does not exist. Raises
165
+ # FileExistsError if +target+ already exists.
166
+ #
167
+ # Options:
168
+ #
169
+ # +mode+:: Temporarily changes the API mode. See the MODES array.
170
+ #
171
+ # TODO The API documentation says this method returns 404/403 if the source or target is invalid, but it actually returns 5xx.
172
+
173
+ def copy(source, target, options={})
174
+ source.sub! /^\//, ''
175
+ target.sub! /^\//, ''
176
+ target << File.basename(source) if target.ends_with?('/')
177
+ begin
178
+ parse_metadata(post('fileops', 'copy', :from_path => Dropbox.check_path(source), :to_path => Dropbox.check_path(target), :root => root(options), :ssl => @ssl)).to_struct_recursively
179
+ rescue UnsuccessfulResponseError => error
180
+ raise FileNotFoundError.new(source) if error.response.kind_of?(Net::HTTPNotFound)
181
+ raise FileExistsError.new(target) if error.response.kind_of?(Net::HTTPForbidden)
182
+ raise error
183
+ end
184
+ end
185
+ alias :cp :copy
186
+
187
+ # Creates a folder at the given path. The path is assumed to be relative to
188
+ # the configured mode's root. Returns a +Struct+ with metadata about the new
189
+ # folder. (See the metadata method.)
190
+ #
191
+ # Raises FileExistsError if there is already a file or folder at +path+.
192
+ #
193
+ # Options:
194
+ #
195
+ # +mode+:: Temporarily changes the API mode. See the MODES array.
196
+ #
197
+ # TODO The API documentation says this method returns 403 if the path already exists, but it actually appends " (1)" to the end of the name and returns 200.
198
+
199
+ def create_folder(path, options={})
200
+ path.sub! /^\//, ''
201
+ path.sub! /\/$/, ''
202
+ begin
203
+ parse_metadata(post('fileops', 'create_folder', :path => Dropbox.check_path(path), :root => root(options), :ssl => @ssl)).to_struct_recursively
204
+ rescue UnsuccessfulResponseError => error
205
+ raise FileExistsError.new(path) if error.response.kind_of?(Net::HTTPForbidden)
206
+ raise error
207
+ end
208
+ end
209
+ alias :mkdir :create_folder
210
+
211
+ # Deletes a file or folder at the given path. The path is assumed to be
212
+ # relative to the configured mode's root.
213
+ #
214
+ # Raises FileNotFoundError if the file or folder does not exist at +path+.
215
+ #
216
+ # Options:
217
+ #
218
+ # +mode+:: Temporarily changes the API mode. See the MODES array.
219
+ #
220
+ # TODO The API documentation says this method returns 404 if the path does not exist, but it actually returns 5xx.
221
+
222
+ def delete(path, options={})
223
+ path.sub! /^\//, ''
224
+ path.sub! /\/$/, ''
225
+ begin
226
+ api_response(:post, 'fileops', 'delete', :path => Dropbox.check_path(path), :root => root(options), :ssl => @ssl)
227
+ rescue UnsuccessfulResponseError => error
228
+ raise FileNotFoundError.new(path) if error.response.kind_of?(Net::HTTPNotFound)
229
+ raise error
230
+ end
231
+ return true
232
+ end
233
+ alias :rm :delete
234
+
235
+ # Moves the +source+ file to the path at +target+. If +target+ ends with a
236
+ # slash, the file name will remain unchanged. If +source+ and +target+ share
237
+ # the same path but have differing file names, the file will be renamed (see
238
+ # also the rename method). Returns a +Struct+ with metadata for the new
239
+ # file. (See the metadata method.)
240
+ #
241
+ # Both paths are assumed to be relative to the configured mode's root.
242
+ #
243
+ # Raises FileNotFoundError if +source+ does not exist. Raises
244
+ # FileExistsError if +target+ already exists.
245
+ #
246
+ # Options:
247
+ #
248
+ # +mode+:: Temporarily changes the API mode. See the MODES array.
249
+ #
250
+ # TODO The API documentation says this method returns 404/403 if the source or target is invalid, but it actually returns 5xx.
251
+
252
+ def move(source, target, options={})
253
+ source.sub! /^\//, ''
254
+ target.sub! /^\//, ''
255
+ target << File.basename(source) if target.ends_with?('/')
256
+ begin
257
+ parse_metadata(post('fileops', 'move', :from_path => Dropbox.check_path(source), :to_path => Dropbox.check_path(target), :root => root(options), :ssl => @ssl)).to_struct_recursively
258
+ rescue UnsuccessfulResponseError => error
259
+ raise FileNotFoundError.new(source) if error.response.kind_of?(Net::HTTPNotFound)
260
+ raise FileExistsError.new(target) if error.response.kind_of?(Net::HTTPForbidden)
261
+ raise error
262
+ end
263
+ end
264
+ alias :mv :move
265
+
266
+ # Renames a file. Takes the same options and raises the same exceptions as
267
+ # the move method.
268
+ #
269
+ # Calling
270
+ #
271
+ # session.rename 'path/to/file', 'new_name'
272
+ #
273
+ # is equivalent to calling
274
+ #
275
+ # session.move 'path/to/file', 'path/to/new_name'
276
+
277
+ def rename(path, new_name, options={})
278
+ raise ArgumentError, "Names cannot have slashes in them" if new_name.include?('/')
279
+ path.sub! /\/$/, ''
280
+ destination = path.split('/')
281
+ destination[destination.size - 1] = new_name
282
+ destination = destination.join('/')
283
+ move path, destination, options
284
+ end
285
+
286
+ # Returns a cookie-protected URL that the authorized user can use to view
287
+ # the file at the given path. This URL requires an authorized user.
288
+ #
289
+ # The path is assumed to be relative to the configured mode's root.
290
+ #
291
+ # Options:
292
+ #
293
+ # +mode+:: Temporarily changes the API mode. See the MODES array.
294
+
295
+ def link(path, options={})
296
+ path.sub! /^\//, ''
297
+ begin
298
+ rest = Dropbox.check_path(path).split('/')
299
+ rest << { :ssl => @ssl }
300
+ api_response(:get, 'links', root(options), *rest)
301
+ rescue UnsuccessfulResponseError => error
302
+ return error.response['Location'] if error.response.kind_of?(Net::HTTPFound)
303
+ #TODO shouldn't be using rescue blocks for normal program flow
304
+ raise error
305
+ end
306
+ end
307
+ memoize :link
308
+
309
+ # Returns a +Struct+ containing metadata on a given file or folder. The path
310
+ # is assumed to be relative to the configured mode's root.
311
+ #
312
+ # If you pass a directory for +path+, the metadata will also contain a
313
+ # listing of the directory contents (unless the +suppress_list+ option is
314
+ # true).
315
+ #
316
+ # For information on the schema of the return struct, see the Dropbox API
317
+ # at http://developers.dropbox.com/python/base.html#metadata
318
+ #
319
+ # The +modified+ key will be converted into a +Time+ instance. The +is_dir+
320
+ # key will also be available as <tt>directory?</tt>.
321
+ #
322
+ # Options:
323
+ #
324
+ # +suppress_list+:: Set this to true to remove the directory list from
325
+ # the result (only applicable if +path+ is a directory).
326
+ # +limit+:: Set this value to limit the number of entries returned when
327
+ # listing a directory. If the result has more than this number of
328
+ # entries, a TooManyEntriesError will be raised.
329
+ # +mode+:: Temporarily changes the API mode. See the MODES array.
330
+ #
331
+ # TODO hash option seems to return HTTPBadRequest for now
332
+
333
+ def metadata(path, options={})
334
+ path.sub! /^\//, ''
335
+ args = [
336
+ 'metadata',
337
+ root(options)
338
+ ]
339
+ args += Dropbox.check_path(path).split('/')
340
+ args << Hash.new
341
+ args.last[:file_limit] = options[:limit] if options[:limit]
342
+ #args.last[:hash] = options[:hash] if options[:hash]
343
+ args.last[:list] = !(options[:suppress_list].to_bool)
344
+ args.last[:ssl] = @ssl
345
+
346
+ begin
347
+ parse_metadata(get(*args)).to_struct_recursively
348
+ rescue UnsuccessfulResponseError => error
349
+ raise TooManyEntriesError.new(path) if error.response.kind_of?(Net::HTTPNotAcceptable)
350
+ raise FileNotFoundError.new(path) if error.response.kind_of?(Net::HTTPNotFound)
351
+ #return :not_modified if error.kind_of?(Net::HTTPNotModified)
352
+ raise error
353
+ end
354
+ end
355
+ memoize :metadata
356
+ alias :info :metadata
357
+
358
+ # Returns an array of <tt>Struct</tt>s with information on each file within
359
+ # the given directory. Calling
360
+ #
361
+ # session.list 'my/folder'
362
+ #
363
+ # is equivalent to calling
364
+ #
365
+ # session.metadata('my/folder').contents
366
+ #
367
+ # Returns nil if the path is not a directory. Raises the same exceptions as
368
+ # the metadata method. Takes the same options as the metadata method, except
369
+ # the +suppress_list+ option is implied to be false.
370
+
371
+
372
+ def list(path, options={})
373
+ metadata(path, options.merge(:suppress_list => false)).contents
374
+ end
375
+ alias :ls :list
376
+
377
+ def event_metadata(target_events, options={}) # :nodoc:
378
+ get 'event_metadata', :ssl => @ssl, :root => root(options), :target_events => target_events
379
+ end
380
+
381
+ def event_content(entry, options={}) # :nodoc:
382
+ request = Dropbox.api_url('event_content', :target_event => entry, :ssl => @ssl, :root => root(options))
383
+ response = api_internal(:get, request)
384
+ begin
385
+ return response.body, JSON.parse(response.header['X-Dropbox-Metadata'])
386
+ rescue JSON::ParserError
387
+ raise ParseError.new(request, response)
388
+ end
389
+ end
390
+
391
+ # Returns the configured API mode.
392
+
393
+ def mode
394
+ @api_mode ||= :sandbox
395
+ end
396
+
397
+ # Sets the API mode. See the MODES array.
398
+
399
+ def mode=(newmode)
400
+ raise ArgumentError, "Unknown API mode #{newmode.inspect}" unless MODES.include?(newmode)
401
+ @api_mode = newmode
402
+ end
403
+
404
+ private
405
+
406
+ def parse_metadata(hsh)
407
+ hsh[:modified] = Time.parse(hsh[:modified]) if hsh[:modified]
408
+ hsh[:directory?] = hsh[:is_dir]
409
+ hsh.each { |_,v| parse_metadata(v) if v.kind_of?(Hash) }
410
+ hsh.each { |_,v| v.each { |h| parse_metadata(h) if h.kind_of?(Hash) } if v.kind_of?(Array) }
411
+ hsh
412
+ end
413
+
414
+ def root(options={})
415
+ api_mode = options[:mode] || mode
416
+ raise ArgumentError, "Unknown API mode #{api_mode.inspect}" unless MODES.include?(api_mode)
417
+ return api_mode == :sandbox ? 'sandbox' : 'dropbox'
418
+ end
419
+
420
+ def get(*params)
421
+ api_json :get, *params
422
+ end
423
+
424
+ def post(*params)
425
+ api_json :post, *params
426
+ end
427
+
428
+ def api_internal(method, request)
429
+ raise UnauthorizedError, "Must authorize before you can use API method" unless @access_token
430
+ response = @access_token.send(method, request)
431
+ raise UnsuccessfulResponseError.new(request, response) unless response.kind_of?(Net::HTTPSuccess)
432
+ return response
433
+ end
434
+
435
+ def api_json(method, *params)
436
+ request = Dropbox.api_url(*params)
437
+ response = api_internal(method, request)
438
+ begin
439
+ return JSON.parse(response.body).symbolize_keys_recursively
440
+ rescue JSON::ParserError
441
+ raise ParseError.new(request, response)
442
+ end
443
+ end
444
+
445
+ def api_body(method, *params)
446
+ api_response(method, *params).body
447
+ end
448
+
449
+ def api_response(method, *params)
450
+ api_internal(method, Dropbox.api_url(*params))
451
+ end
452
+ end
453
+
454
+ # Superclass for exceptions raised when the server reports an error.
455
+
456
+ class APIError < StandardError
457
+ # The request URL.
458
+ attr_reader :request
459
+ # The Net::HTTPResponse returned by the server.
460
+ attr_reader :response
461
+
462
+ def initialize(request, response) # :nodoc:
463
+ @request = request
464
+ @response = response
465
+ end
466
+
467
+ def to_s # :nodoc:
468
+ "API error: #{request}"
469
+ end
470
+ end
471
+
472
+ # Raised when the Dropbox API returns a response that was not understood.
473
+
474
+ class ParseError < APIError
475
+ def to_s # :nodoc:
476
+ "Invalid response received: #{request}"
477
+ end
478
+ end
479
+
480
+ # Raised when something other than 200 OK is returned by an API method.
481
+
482
+ class UnsuccessfulResponseError < APIError
483
+ def to_s # :nodoc:
484
+ "HTTP status #{@response.class.to_s} received: #{request}"
485
+ end
486
+ end
487
+
488
+ # Superclass of errors relating to Dropbox files.
489
+
490
+ class FileError < StandardError
491
+ # The path of the offending file.
492
+ attr_reader :path
493
+
494
+ def initialize(path) # :nodoc:
495
+ @path = path
496
+ end
497
+
498
+ def to_s # :nodoc:
499
+ "#{self.class.to_s}: #{@path}"
500
+ end
501
+ end
502
+
503
+ # Raised when a Dropbox file doesn't exist.
504
+
505
+ class FileNotFoundError < FileError; end
506
+
507
+ # Raised when a Dropbox file is in the way.
508
+
509
+ class FileExistsError < FileError; end
510
+
511
+ # Raised when the number of files within a directory exceeds a specified
512
+ # limit.
513
+
514
+ class TooManyEntriesError < FileError; end
515
+
516
+ # Raised when the event_metadata method returns an error.
517
+
518
+ class PingbackError < StandardError
519
+ # The HTTP error code returned by the event_metadata method.
520
+ attr_reader :code
521
+
522
+ def initialize(code) # :nodoc
523
+ @code = code
524
+ end
525
+
526
+ def to_s # :nodoc:
527
+ "#{self.class.to_s} code #{@code}"
528
+ end
529
+ end
530
+ end
@@ -0,0 +1,96 @@
1
+ # Defines the Dropbox::Entry class.
2
+
3
+ nil # doc fix
4
+
5
+ module Dropbox
6
+
7
+ # A façade over a Dropbox::Session that allows the programmer to interact with
8
+ # Dropbox files in an object-oriented manner. The Dropbox::Entry instance is
9
+ # created by calling the Dropbox::API#entry method:
10
+ #
11
+ # file = session.file('remote/file.pdf')
12
+ # dir = session.directory('remote/dir') # these calls are actually identical
13
+ #
14
+ # Note that no network calls are made; this merely creates a façade that will
15
+ # delegate future calls to the session:
16
+ #
17
+ # file.move('new/path') # identical to calling session.move('remote/file.pdf', 'new/path')
18
+ #
19
+ # The internal path is updated as the file is moved and renamed:
20
+ #
21
+ # file = session.file('first_name.txt')
22
+ # file.rename('second_name.txt')
23
+ # file.rename('third_name.txt') # works as the internal path is updated with the first rename
24
+
25
+ class Entry
26
+ # The remote path of the file.
27
+ attr_reader :path
28
+
29
+ def initialize(session, path) # :nodoc:
30
+ @session = session
31
+ @path = path
32
+ end
33
+
34
+ # Delegates to Dropbox::API#metadata.
35
+
36
+ def metadata(options={})
37
+ @session.metadata path, options
38
+ end
39
+ alias :info :metadata
40
+
41
+ # Delegates to Dropbox::API#list
42
+
43
+ def list(options={})
44
+ @session.list path, options
45
+ end
46
+ alias :ls :list
47
+
48
+ # Delegates to Dropbox::API#move.
49
+
50
+ def move(dest, options={})
51
+ result = @session.move(path, dest, options)
52
+ @path = result.path.gsub(/^\//, '')
53
+ return result
54
+ end
55
+ alias :mv :move
56
+
57
+ # Delegates to Dropbox::API#rename.
58
+
59
+ def rename(name, options={})
60
+ result = @session.rename(path, name, options)
61
+ @path = result.path.gsub(/^\//, '')
62
+ return result
63
+ end
64
+
65
+ # Delegates to Dropbox::API#copy.
66
+
67
+ def copy(dest, options={})
68
+ @session.copy path, dest, options
69
+ end
70
+ alias :cp :copy
71
+
72
+ # Delegates to Dropbox::API#delete.
73
+
74
+ def delete(options={})
75
+ @session.delete path, options
76
+ end
77
+ alias :rm :delete
78
+
79
+ # Delegates to Dropbox::API#download.
80
+
81
+ def download(options={})
82
+ @session.download path, options
83
+ end
84
+ alias :body :download
85
+
86
+ # Delegates to Dropbox::API#link.
87
+
88
+ def link(options={})
89
+ @session.link path, options
90
+ end
91
+
92
+ def inspect # :nodoc:
93
+ "#<#{self.class.to_s} #{path}>"
94
+ end
95
+ end
96
+ end