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