# Defines the Dropbox::API module. require 'json' require 'net/http/post/multipart' module Dropbox # Extensions to the Dropbox::Session class that add core Dropbox API # functionality to this class. You must have authenticated your # Dropbox::Session instance before you can call any of these methods. (See the # Dropbox::Session class documentation for instructions.) # # API methods generally return +Struct+ objects containing their results, # unless otherwise noted. See the Dropbox API documentation at # http://developers.dropbox.com for specific information on the schema of each # result. # # You can opt-in to memoization of API method results. See the # Dropbox::Memoization class documentation to learn more. # # == Modes # # The Dropbox API works in three modes: sandbox, Dropbox (root), and # metadata-only. # # * In sandbox mode (the default), all operations are rooted from your # application's sandbox folder; other files elsewhere on the user's Dropbox # are inaccessible. # * In Dropbox mode, the root is the user's Dropbox folder, and all files are # accessible. This mode is typically only available to certain API users. # * In metadata-only mode, the root is the Dropbox folder, but write access # is not available. Operations that modify the user's files will # fail. # # You should configure the Dropbox::Session instance to use whichever mode # you chose when you set up your application: # # session.mode = :metadata_only # # Valid values are listed in Dropbox::API::MODES, and this step is not # necessary for sandboxed applications, as the sandbox mode is the default. # # You can also temporarily change the mode for many method calls using their # options hash: # # session.move 'my_file', 'new/path', :mode => :dropbox module API include Dropbox::Memoization # Valid API modes for the #mode= method. MODES = [ :sandbox, :dropbox, :metadata_only ] # Returns a Dropbox::Entry instance that can be used to work with files or # directories in an object-oriented manner. def entry(path) Dropbox::Entry.new(self, path) end alias :file :entry alias :directory :entry alias :dir :entry # Returns a +Struct+ with information about the user's account. See # https://www.dropbox.com/developers/docs#account-info for more information # on the data returned. def account get('account', 'info', :ssl => @ssl).to_struct_recursively end memoize :account # Downloads the file at the given path relative to the configured mode's # root. # # Returns the contents of the downloaded file as a +String+. Support for # streaming downloads and range queries is available server-side, but not # available in this API client due to limitations of the OAuth gem. # # Options: # # +mode+:: Temporarily changes the API mode. See the MODES array. def download(path, options={}) path = path.sub(/^\//, '') rest = Dropbox.check_path(path).split('/') rest << { :ssl => @ssl } api_body :get, 'files', root(options), *rest #TODO streaming, range queries end # Downloads a minimized thumbnail for a file. Pass the path to the file, # optionally the size of the thumbnail you want, and any additional options. # See https://www.dropbox.com/developers/docs#thumbnails for a list of valid # size specifiers. # # Returns the content of the thumbnail image as a +String+. The thumbnail # data is in JPEG format. Returns +nil+ if the file does not have a # thumbnail. You can check if a file has a thumbnail using the metadata # method. # # Because of the way this API method works, if you pass in the name of a # file that does not exist, you will not receive a 404, but instead just get # +nil+. # # Options: # # +mode+:: Temporarily changes the API mode. See the MODES array. # # Examples: # # Get the thumbnail for an image (default thunmbnail size): # # session.thumbnail('my/image.jpg') # # Get the thumbnail for an image in the +medium+ size: # # session.thumbnail('my/image.jpg', 'medium') def thumbnail(*args) options = args.extract_options! path = args.shift size = args.shift raise ArgumentError, "thumbnail takes a path, an optional size, and optional options" unless path.kind_of?(String) and (size.kind_of?(String) or size.nil?) and args.empty? path = path.sub(/^\//, '') rest = Dropbox.check_path(path).split('/') rest << { :ssl => @ssl } rest.last[:size] = size if size begin api_body :get, 'thumbnails', root(options), *rest rescue Dropbox::UnsuccessfulResponseError => e raise unless e.response.code.to_i == 404 return nil end end # Uploads a file to a path relative to the configured mode's root. The # +remote_path+ parameter is taken to be the path portion _only_; the name # of the remote file will be identical to that of the local file. You can # provide any of the following for the first parameter: # # * a +File+ object, in which case the name of the local file is used, or # * a path to a file, in which case that file's name is used. # # Options: # # +mode+:: Temporarily changes the API mode. See the MODES array. # # Examples: # # session.upload 'music.pdf', '/' # upload a file by path to the root directory # session.upload 'music.pdf, 'music/' # upload a file by path to the music folder # session.upload File.new('music.pdf'), '/' # same as the first example def upload(local_file, remote_path, options={}) if local_file.kind_of?(File) or local_file.kind_of?(Tempfile) then file = local_file name = local_file.respond_to?(:original_filename) ? local_file.original_filename : File.basename(local_file.path) local_path = local_file.path elsif local_file.kind_of?(String) then file = File.new(local_file) name = File.basename(local_file) local_path = local_file else raise ArgumentError, "local_file must be a File or file path" end remote_path = remote_path.sub(/^\//, '') remote_path = Dropbox.check_path(remote_path).split('/') remote_path << { :ssl => @ssl } url = Dropbox.api_url('files', root(options), *remote_path) uri = URI.parse(url) oauth_request = Net::HTTP::Post.new(uri.path) oauth_request.set_form_data 'file' => name alternate_host_session = clone_with_host(@ssl ? Dropbox::ALTERNATE_SSL_HOSTS['files'] : Dropbox::ALTERNATE_HOSTS['files']) alternate_host_session.instance_variable_get(:@consumer).sign!(oauth_request, @access_token) oauth_signature = oauth_request.to_hash['authorization'] request = Net::HTTP::Post::Multipart.new(uri.path, 'file' => UploadIO.convert!( file, 'application/octet-stream', name, local_path)) request['authorization'] = oauth_signature.join(', ') response = Net::HTTP.start(uri.host, uri.port) { |http| http.request(request) } if response.kind_of?(Net::HTTPSuccess) then begin return JSON.parse(response.body).symbolize_keys_recursively.to_struct_recursively rescue JSON::ParserError raise ParseError.new(uri.to_s, response) end else raise UnsuccessfulResponseError.new(uri.to_s, response) end end # Copies the +source+ file to the path at +target+. If +target+ ends with a # slash, the new file will share the same name as the old file. Returns a # +Struct+ with metadata for the new file. (See the metadata method.) # # Both paths are assumed to be relative to the configured mode's root. # # Raises FileNotFoundError if +source+ does not exist. Raises # FileExistsError if +target+ already exists. # # Options: # # +mode+:: Temporarily changes the API mode. See the MODES array. # # TODO The API documentation says this method returns 404/403 if the source or target is invalid, but it actually returns 5xx. def copy(source, target, options={}) source = source.sub(/^\//, '') target = target.sub(/^\//, '') target << File.basename(source) if target.ends_with?('/') begin 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 rescue UnsuccessfulResponseError => error raise FileNotFoundError.new(source) if error.response.kind_of?(Net::HTTPNotFound) raise FileExistsError.new(target) if error.response.kind_of?(Net::HTTPForbidden) raise error end end alias :cp :copy # Creates a folder at the given path. The path is assumed to be relative to # the configured mode's root. Returns a +Struct+ with metadata about the new # folder. (See the metadata method.) # # Raises FileExistsError if there is already a file or folder at +path+. # # Options: # # +mode+:: Temporarily changes the API mode. See the MODES array. # # 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. def create_folder(path, options={}) path = path.sub(/^\//, '') path.sub! /\/$/, '' begin parse_metadata(post('fileops', 'create_folder', :path => Dropbox.check_path(path), :root => root(options), :ssl => @ssl)).to_struct_recursively rescue UnsuccessfulResponseError => error raise FileExistsError.new(path) if error.response.kind_of?(Net::HTTPForbidden) raise error end end alias :mkdir :create_folder # Deletes a file or folder at the given path. The path is assumed to be # relative to the configured mode's root. # # Raises FileNotFoundError if the file or folder does not exist at +path+. # # Options: # # +mode+:: Temporarily changes the API mode. See the MODES array. # # TODO The API documentation says this method returns 404 if the path does not exist, but it actually returns 5xx. def delete(path, options={}) path = path.sub(/^\//, '') path.sub! /\/$/, '' begin api_response(:post, 'fileops', 'delete', :path => Dropbox.check_path(path), :root => root(options), :ssl => @ssl) rescue UnsuccessfulResponseError => error raise FileNotFoundError.new(path) if error.response.kind_of?(Net::HTTPNotFound) raise error end return true end alias :rm :delete # Moves the +source+ file to the path at +target+. If +target+ ends with a # slash, the file name will remain unchanged. If +source+ and +target+ share # the same path but have differing file names, the file will be renamed (see # also the rename method). Returns a +Struct+ with metadata for the new # file. (See the metadata method.) # # Both paths are assumed to be relative to the configured mode's root. # # Raises FileNotFoundError if +source+ does not exist. Raises # FileExistsError if +target+ already exists. # # Options: # # +mode+:: Temporarily changes the API mode. See the MODES array. # # TODO The API documentation says this method returns 404/403 if the source or target is invalid, but it actually returns 5xx. def move(source, target, options={}) source = source.sub(/^\//, '') target = target.sub(/^\//, '') target << File.basename(source) if target.ends_with?('/') begin 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 rescue UnsuccessfulResponseError => error raise FileNotFoundError.new(source) if error.response.kind_of?(Net::HTTPNotFound) raise FileExistsError.new(target) if error.response.kind_of?(Net::HTTPForbidden) raise error end end alias :mv :move # Renames a file. Takes the same options and raises the same exceptions as # the move method. # # Calling # # session.rename 'path/to/file', 'new_name' # # is equivalent to calling # # session.move 'path/to/file', 'path/to/new_name' def rename(path, new_name, options={}) raise ArgumentError, "Names cannot have slashes in them" if new_name.include?('/') path = path.sub(/\/$/, '') destination = path.split('/') destination[destination.size - 1] = new_name destination = destination.join('/') move path, destination, options end # Returns a cookie-protected URL that the authorized user can use to view # the file at the given path. This URL requires an authorized user. # # The path is assumed to be relative to the configured mode's root. # # Options: # # +mode+:: Temporarily changes the API mode. See the MODES array. def link(path, options={}) path = path.sub(/^\//, '') begin rest = Dropbox.check_path(path).split('/') rest << { :ssl => @ssl } api_response(:get, 'links', root(options), *rest) rescue UnsuccessfulResponseError => error return error.response['Location'] if error.response.kind_of?(Net::HTTPFound) #TODO shouldn't be using rescue blocks for normal program flow raise error end end memoize :link # Returns a +Struct+ containing metadata on a given file or folder. The path # is assumed to be relative to the configured mode's root. # # If you pass a directory for +path+, the metadata will also contain a # listing of the directory contents (unless the +suppress_list+ option is # true). # # For information on the schema of the return struct, see the Dropbox API # at https://www.dropbox.com/developers/docs#metadata # # The +modified+ key will be converted into a +Time+ instance. The +is_dir+ # key will also be available as directory?. # # Options: # # +suppress_list+:: Set this to true to remove the directory list from # the result (only applicable if +path+ is a directory). # +limit+:: Set this value to limit the number of entries returned when # listing a directory. If the result has more than this number of # entries, a TooManyEntriesError will be raised. # +mode+:: Temporarily changes the API mode. See the MODES array. # # TODO hash option seems to return HTTPBadRequest for now def metadata(path, options={}) path = path.sub(/^\//, '') args = [ 'metadata', root(options) ] args += Dropbox.check_path(path).split('/') args << Hash.new args.last[:file_limit] = options[:limit] if options[:limit] #args.last[:hash] = options[:hash] if options[:hash] args.last[:list] = !(options[:suppress_list].to_bool) args.last[:ssl] = @ssl begin parse_metadata(get(*args)).to_struct_recursively rescue UnsuccessfulResponseError => error raise TooManyEntriesError.new(path) if error.response.kind_of?(Net::HTTPNotAcceptable) raise FileNotFoundError.new(path) if error.response.kind_of?(Net::HTTPNotFound) #return :not_modified if error.kind_of?(Net::HTTPNotModified) raise error end end memoize :metadata alias :info :metadata # Returns an array of Structs with information on each file within # the given directory. Calling # # session.list 'my/folder' # # is equivalent to calling # # session.metadata('my/folder').contents # # Returns nil if the path is not a directory. Raises the same exceptions as # the metadata method. Takes the same options as the metadata method, except # the +suppress_list+ option is implied to be false. def list(path, options={}) metadata(path, options.merge(:suppress_list => false)).contents end alias :ls :list def event_metadata(target_events, options={}) # :nodoc: get 'event_metadata', :ssl => @ssl, :root => root(options), :target_events => target_events end def event_content(entry, options={}) # :nodoc: request = Dropbox.api_url('event_content', :target_event => entry, :ssl => @ssl, :root => root(options)) response = api_internal(:get, request) begin return response.body, JSON.parse(response.header['X-Dropbox-Metadata']) rescue JSON::ParserError raise ParseError.new(request, response) end end # Returns the configured API mode. def mode @api_mode ||= :sandbox end # Sets the API mode. See the MODES array. def mode=(newmode) raise ArgumentError, "Unknown API mode #{newmode.inspect}" unless MODES.include?(newmode) @api_mode = newmode end private def parse_metadata(hsh) hsh[:modified] = Time.parse(hsh[:modified]) if hsh[:modified] hsh[:directory?] = hsh[:is_dir] hsh.each { |_,v| parse_metadata(v) if v.kind_of?(Hash) } hsh.each { |_,v| v.each { |h| parse_metadata(h) if h.kind_of?(Hash) } if v.kind_of?(Array) } hsh end def root(options={}) api_mode = options[:mode] || mode raise ArgumentError, "Unknown API mode #{api_mode.inspect}" unless MODES.include?(api_mode) return api_mode == :sandbox ? 'sandbox' : 'dropbox' end def get(*params) api_json :get, *params end def post(*params) api_json :post, *params end def api_internal(method, request) raise UnauthorizedError, "Must authorize before you can use API method" unless @access_token response = @access_token.send(method, request) raise UnsuccessfulResponseError.new(request, response) unless response.kind_of?(Net::HTTPSuccess) return response end def api_json(method, *params) request = Dropbox.api_url(*params) response = api_internal(method, request) begin return JSON.parse(response.body).symbolize_keys_recursively rescue JSON::ParserError raise ParseError.new(request, response) end end def api_body(method, *params) api_response(method, *params).body end def api_response(method, *params) api_internal(method, Dropbox.api_url(*params)) end end # Superclass for exceptions raised when the server reports an error. class APIError < StandardError # The request URL. attr_reader :request # The Net::HTTPResponse returned by the server. attr_reader :response def initialize(request, response) # :nodoc: @request = request @response = response end def to_s # :nodoc: "API error: #{request}" end end # Raised when the Dropbox API returns a response that was not understood. class ParseError < APIError def to_s # :nodoc: "Invalid response received: #{request}" end end # Raised when something other than 200 OK is returned by an API method. class UnsuccessfulResponseError < APIError def to_s # :nodoc: "HTTP status #{@response.class.to_s} received: #{request}" end end # Superclass of errors relating to Dropbox files. class FileError < StandardError # The path of the offending file. attr_reader :path def initialize(path) # :nodoc: @path = path end def to_s # :nodoc: "#{self.class.to_s}: #{@path}" end end # Raised when a Dropbox file doesn't exist. class FileNotFoundError < FileError; end # Raised when a Dropbox file is in the way. class FileExistsError < FileError; end # Raised when the number of files within a directory exceeds a specified # limit. class TooManyEntriesError < FileError; end # Raised when the event_metadata method returns an error. class PingbackError < StandardError # The HTTP error code returned by the event_metadata method. attr_reader :code def initialize(code) # :nodoc @code = code end def to_s # :nodoc: "#{self.class.to_s} code #{@code}" end end end