lib/dropbox_sdk.rb in dropbox-sdk-1.0.beta vs lib/dropbox_sdk.rb in dropbox-sdk-1.1

- old
+ new

@@ -1,137 +1,220 @@ require 'rubygems' -require 'oauth' -require 'json' require 'uri' +require 'net/https' +require 'cgi' +require 'json' require 'yaml' -DROPBOX_API_SERVER = "api.dropbox.com" -DROPBOX_API_CONTENT_SERVER = "api-content.dropbox.com" +module Dropbox + API_SERVER = "api.dropbox.com" + API_CONTENT_SERVER = "api-content.dropbox.com" + WEB_SERVER = "www.dropbox.com" -API_VERSION = 1 + API_VERSION = 1 + SDK_VERSION = "1.1" +end # DropboxSession is responsible for holding OAuth information. It knows how to take your consumer key and secret # and request an access token, an authorize url, and get an access token. You just need to pass it to # DropboxClient after its been authorized. class DropboxSession - def initialize(key, secret) - @consumer_key = key - @consumer_secret = secret - @oauth_conf = { - :site => "https://" + DROPBOX_API_SERVER, - :scheme => :header, - :http_method => :post, - :request_token_url => "/#{API_VERSION}/oauth/request_token", - :access_token_url => "/#{API_VERSION}/oauth/access_token", - :authorize_url => "/#{API_VERSION}/oauth/authorize", - } - @consumer = OAuth::Consumer.new(@consumer_key, @consumer_secret, @oauth_conf) - @access_token = nil + def initialize(consumer_key, consumer_secret) + @consumer_key = consumer_key + @consumer_secret = consumer_secret @request_token = nil + @access_token = nil end - # This gets a request token. Callbacks are excluded, and any arguments provided are passed on - # to the oauth gem's get_request_token call. - def get_request_token(*args) - begin - @request_token ||= @consumer.get_request_token({:exclude_callback => true}, *args) - rescue OAuth::Unauthorized => e - raise DropboxAuthError.new("Could not get request token, unauthorized. Is your app key and secret correct? #{e}") + private + + def do_http(uri, auth_token, request) # :nodoc: + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + request.add_field('Authorization', build_auth_header(auth_token)) + + #We use this to better understand how developers are using our SDKs. + request['User-Agent'] = "OfficialDropboxRubySDK/#{Dropbox::SDK_VERSION}" + + http.request(request) + end + + def build_auth_header(token) # :nodoc: + header = "OAuth oauth_version=\"1.0\", oauth_signature_method=\"PLAINTEXT\", " + + "oauth_consumer_key=\"#{URI.escape(@consumer_key)}\", " + if token + key = URI.escape(token.key) + secret = URI.escape(token.secret) + header += "oauth_token=\"#{key}\", oauth_signature=\"#{URI.escape(@consumer_secret)}&#{secret}\"" + else + header += "oauth_signature=\"#{URI.escape(@consumer_secret)}&\"" end + header end + def do_get_with_token(url, token, headers=nil) # :nodoc: + uri = URI.parse(url) + do_http(uri, token, Net::HTTP::Get.new(uri.request_uri)) + end + + public + + def do_get(url, headers=nil) # :nodoc: + assert_authorized + do_get_with_token(url, @access_token) + end + + def do_http_with_body(uri, request, body) + if body != nil + if body.is_a?(Hash) + form_data = {} + body.each {|k,v| form_data[k.to_s] = v if !v.nil?} + request.set_form_data(form_data) + elsif body.respond_to?(:read) + if body.respond_to?(:length) + request["Content-Length"] = body.length.to_s + elsif body.respond_to?(:stat) && body.stat.respond_to?(:size) + request["Content-Length"] = body.stat.size.to_s + else + raise ArgumentError, "Don't know how to handle 'body' (responds to 'read' but not to 'length' or 'stat.size')." + end + request.body_stream = body + else + s = body.to_s + request["Content-Length"] = s.length + request.body = s + end + end + do_http(uri, @access_token, request) + end + + def do_post(url, headers=nil, body=nil) # :nodoc: + assert_authorized + uri = URI.parse(url) + do_http_with_body(uri, Net::HTTP::Post.new(uri.request_uri, headers), body) + end + + def do_put(url, headers=nil, body=nil) # :nodoc: + assert_authorized + uri = URI.parse(url) + do_http_with_body(uri, Net::HTTP::Put.new(uri.request_uri, headers), body) + end + + + def get_token(url_end, input_token, error_message_prefix) #: nodoc: + response = do_get_with_token("https://#{Dropbox::API_SERVER}:443/#{Dropbox::API_VERSION}/oauth#{url_end}", input_token) + if not response.kind_of?(Net::HTTPSuccess) # it must be a 200 + raise DropboxAuthError.new("#{error_message_prefix} Server returned #{response.code}: #{response.message}.", response) + end + parts = CGI.parse(response.body) + if !parts.has_key? "oauth_token" and parts["oauth_token"].length != 1 + raise DropboxAuthError.new("Invalid response from #{url_end}: missing \"oauth_token\" parameter: #{response.body}", response) + end + if !parts.has_key? "oauth_token_secret" and parts["oauth_token_secret"].length != 1 + raise DropboxAuthError.new("Invalid response from #{url_end}: missing \"oauth_token\" parameter: #{response.body}", response) + end + + OAuthToken.new(parts["oauth_token"][0], parts["oauth_token_secret"][0]) + end + + # This returns a request token. Requests one from the dropbox server using the provided application key and secret if nessecary. + def get_request_token() + @request_token ||= get_token("/request_token", nil, "Error getting request token. Is your app key and secret correctly set?") + end + # This returns a URL that your user must visit to grant # permissions to this application. - def get_authorize_url(callback=nil, *args) - get_request_token(*args) + def get_authorize_url(callback=nil) + get_request_token() - url = @request_token.authorize_url + url = "/#{Dropbox::API_VERSION}/oauth/authorize?oauth_token=#{URI.escape(@request_token.key)}" if callback - url += "&oauth_callback=" + URI.escape(callback) + url += "&oauth_callback=#{URI.escape(callback)}" end + if @locale + url += "&locale=#{URI.escape(@locale)}" + end - "https://www.dropbox.com" + url + "https://#{Dropbox::WEB_SERVER}#{url}" end # Clears the access_token def clear_access_token @access_token = nil end + # Returns the request token, or nil if one hasn't been acquired yet. + def request_token + @request_token + end + + # Returns the access token, or nil if one hasn't been acquired yet. + def access_token + @access_token + end + # Given a saved request token and secret, set this location's token and secret # * token - this is the request token # * secret - this is the request token secret def set_request_token(key, secret) - @request_token = OAuth::RequestToken.new(@consumer, key, secret) + @request_token = OAuthToken.new(key, secret) end # Given a saved access token and secret, you set this Session to use that token and secret # * token - this is the access token # * secret - this is the access token secret def set_access_token(key, secret) - @access_token = OAuth::AccessToken.from_hash(@consumer, {:oauth_token => key, :oauth_token_secret => secret}) + @access_token = OAuthToken.new(key, secret) end - def check_authorized - ##this check is applied before the token and secret methods - raise DropboxError.new('Session does not yet have a request token') unless authorized? - end - - # Returns the current oauth access or request token. - def token - (@access_token || @request_token).token - end - - # Returns the current oauth access or request token secret. - def secret - (@access_token || @request_token).secret - end - # Returns the access token. If this DropboxSession doesn't yet have an access_token, it requests one # using the request_token generate from your app's token and secret. This request will fail unless - # your user has got to the authorize_url and approved your request + # your user has gone to the authorize_url and approved your request def get_access_token - if @access_token.nil? - if @request_token.nil? - raise DropboxAuthError.new("No request token. You must set this or get an authorize url first.") - end + return @access_token if authorized? - begin - @access_token = @request_token.get_access_token - rescue OAuth::Unauthorized => e - raise DropboxAuthError.new("Could not get access token, unauthorized. Did you go to authorize_url? #{e}") - end + if @request_token.nil? + raise DropboxAuthError.new("No request token. You must set this or get an authorize url first.") end - @access_token + + @access_token = get_token("/access_token", @request_token, "Couldn't get access token.") end + # If we have an access token, then do nothing. If not, throw a RuntimeError. + def assert_authorized + unless authorized? + raise RuntimeError.new('Session does not yet have a request token') + end + end + # Returns true if this Session has been authorized and has an access_token. def authorized? !!@access_token end # serialize the DropboxSession. # At DropboxSession's state is capture in three key/secret pairs. Consumer, request, and access. - # This takes the form of an array that is then converted to yaml + # Serialize returns these in a YAML string, generated from a converted array of the form: # [consumer_key, consumer_secret, request_token.token, request_token.secret, access_token.token, access_token.secret] # access_token is only included if it already exists in the DropboxSesssion def serialize toreturn = [] if @access_token - toreturn.push @access_token.secret, @access_token.token + toreturn.push @access_token.secret, @access_token.key end - get_request_token unless @request_token + get_request_token - toreturn.push @request_token.secret, @request_token.token + toreturn.push @request_token.secret, @request_token.key toreturn.push @consumer_secret, @consumer_key toreturn.to_yaml end - # Takes a serialized DropboxSession and returns a new DropboxSession object + # Takes a serialized DropboxSession YAML String and returns a new DropboxSession object def self.deserialize(ser) ser = YAML::load(ser) session = DropboxSession.new(ser.pop, ser.pop) session.set_request_token(ser.pop, ser.pop) @@ -141,11 +224,27 @@ session end end +# A class that represents either an OAuth request token or an OAuth access token. +class OAuthToken + def initialize(key, secret) + @key = key + @secret = secret + end + def key + @key + end + + def secret + @secret + end +end + + # This is the usual error raised on any Dropbox related Errors class DropboxError < RuntimeError attr_accessor :http_response, :error, :user_error def initialize(error, http_response=nil, user_error=nil) @error = error @@ -177,36 +276,25 @@ class DropboxClient # Initialize a new DropboxClient. You need to get it a session which either has been authorized. See # documentation on DropboxSession for how to authorize it. def initialize(session, root="app_folder", locale=nil) - if not session.authorized? - begin - ## attempt to get an access token and authorize the session - session.get_access_token - rescue OAuth::Unauthorized => e - raise DropboxAuthError.new("Could not initialize. Failed to get access token from Session. Error was: #{ e.message }") - # If this was raised, the user probably didn't go to auth.get_authorize_url - end - end + session.get_access_token @root = root.to_s # If they passed in a symbol, make it a string if not ["dropbox","app_folder"].include?(@root) raise DropboxError.new("root must be :dropbox or :app_folder") end if @root == "app_folder" #App Folder is the name of the access type, but for historical reasons - #sandbox is the URL root compontent that indicates this + #sandbox is the URL root component that indicates this @root = "sandbox" end @locale = locale @session = session - @token = session.get_access_token - - #There's no gurantee that @token is still valid, so be sure to handle any DropboxAuthErrors that can be raised end # Parse response. You probably shouldn't be calling this directly. This takes responses from the server # and parses them. It also checks for errors and raises exceptions with the appropriate messages. def parse_response(response, raw=false) # :nodoc: @@ -234,29 +322,28 @@ begin return JSON.parse(response.body) rescue JSON::ParserError raise DropboxError.new("Unable to parse JSON response", response) end - end # Returns account info in a Hash object # # For a detailed description of what this call returns, visit: # https://www.dropbox.com/developers/docs#account-info def account_info() - response = @token.get build_url("/account/info") + response = @session.do_get build_url("/account/info") parse_response(response) end # Uploads a file to a server. This uses the HTTP PUT upload method for simplicity # # Arguments: # * to_path: The directory path to upload the file to. If the destination # directory does not yet exist, it will be created. - # * file_obj: A file-like object to upload. If you would like, you can + # * file_obj: A file-like object to upload. If you would like, you can # pass a string as file_obj. # * overwrite: Whether to overwrite an existing file at the given path. [default is False] # If overwrite is False and a file already exists there, Dropbox # will rename the upload to make sure it doesn't overwrite anything. # You must check the returned metadata to know what this new name is. @@ -273,29 +360,28 @@ # and it will never be overwritten you send a less-recent one. # Returns: # * a Hash containing the metadata of the newly uploaded file. The file may have a different name if it conflicted. # # Simple Example - # client = DropboxClient(session, "app_folder") + # client = DropboxClient(session, :app_folder) # #session is a DropboxSession I've already authorized # client.put_file('/test_file_on_dropbox', open('/tmp/test_file')) # This will upload the "/tmp/test_file" from my computer into the root of my App's app folder # and call it "test_file_on_dropbox". # The file will not overwrite any pre-existing file. def put_file(to_path, file_obj, overwrite=false, parent_rev=nil) - path = "/files_put/#{@root}#{format_path(to_path)}" params = { 'overwrite' => overwrite.to_s } params['parent_rev'] = parent_rev unless parent_rev.nil? - response = @token.put(build_url(path, params, content_server=true), - file_obj, - "Content-Type" => "application/octet-stream") + response = @session.do_put(build_url(path, params, content_server=true), + {"Content-Type" => "application/octet-stream"}, + file_obj) parse_response(response) end # Download a file @@ -309,11 +395,11 @@ def get_file(from_path, rev=nil) params = {} params['rev'] = rev.to_s if rev path = "/files/#{@root}#{format_path(from_path)}" - response = @token.get(build_url(path, params, content_server=true)) + response = @session.do_get build_url(path, params, content_server=true) parse_response(response, raw=true) end # Copy a file or folder to a new location. @@ -334,11 +420,11 @@ params = { "root" => @root, "from_path" => format_path(from_path, false), "to_path" => format_path(to_path, false), } - response = @token.get(build_url("/fileops/copy", params)) + response = @session.do_post build_url("/fileops/copy", params) parse_response(response) end # Create a folder. # @@ -350,13 +436,13 @@ # For a detailed description of what this call returns, visit: # https://www.dropbox.com/developers/docs#fileops-create-folder def file_create_folder(path) params = { "root" => @root, - "path" => format_path(from_path, false), + "path" => format_path(path, false), } - response = @token.get(build_url("/fileops/create_folder", params)) + response = @session.do_post build_url("/fileops/create_folder", params) parse_response(response) end # Deletes a file @@ -369,13 +455,13 @@ # For a detailed description of what this call returns, visit: # https://www.dropbox.com/developers/docs#fileops-delete def file_delete(path) params = { "root" => @root, - "path" => format_path(from_path, false), + "path" => format_path(path, false), } - response = @token.get(build_url("/fileops/delete", params)) + response = @session.do_post build_url("/fileops/delete", params) parse_response(response) end # Moves a file # @@ -392,11 +478,11 @@ params = { "root" => @root, "from_path" => format_path(from_path, false), "to_path" => format_path(to_path, false), } - response = @token.post(build_url("/fileops/move", params)) + response = @session.do_post build_url("/fileops/move", params) parse_response(response) end # Retrives metadata for a file or folder # @@ -423,11 +509,11 @@ "list" => list.to_s } params["hash"] = hash if hash - response = @token.get build_url("/metadata/#{@root}#{format_path(path)}", params=params) + response = @session.do_get build_url("/metadata/#{@root}#{format_path(path)}", params=params) if response.kind_of? Net::HTTPRedirection raise DropboxNotModified.new("metadata not modified") end parse_response(response) end @@ -445,20 +531,18 @@ # Returns: # * A Hash object with a list the metadata of the file or folders matching query # inside path. For a detailed description of what this call returns, visit: # https://www.dropbox.com/developers/docs#search def search(path, query, file_limit=10000, include_deleted=false) - params = { 'query' => query, 'file_limit' => file_limit.to_s, 'include_deleted' => include_deleted.to_s } - response = @token.get(build_url("/search/#{@root}#{format_path(path)}", params)) + response = @session.do_get build_url("/search/#{@root}#{format_path(path)}", params) parse_response(response) - end # Retrive revisions of a file # # Arguments: @@ -476,11 +560,11 @@ params = { 'rev_limit' => rev_limit.to_s } - response = @token.get(build_url("/revisions/#{@root}#{format_path(path)}", params)) + response = @session.do_get build_url("/revisions/#{@root}#{format_path(path)}", params) parse_response(response) end # Restore a file to a previous revision. @@ -496,11 +580,11 @@ def restore(path, rev) params = { 'rev' => rev.to_s } - response = @token.get(build_url("/restore/#{@root}#{format_path(path)}", params)) + response = @session.do_post build_url("/restore/#{@root}#{format_path(path)}", params) parse_response(response) end # Returns a direct link to a media file # All of Dropbox's API methods require OAuth, which may cause problems in @@ -513,11 +597,11 @@ # # Returns: # * A Hash object that looks like the following: # {'url': 'https://dl.dropbox.com/0/view/wvxv1fw6on24qw7/file.mov', 'expires': 'Thu, 16 Sep 2011 01:01:25 +0000'} def media(path) - response = @token.get(build_url("/media/#{@root}#{format_path(path)}")) + response = @session.do_get build_url("/media/#{@root}#{format_path(path)}") parse_response(response) end # Get a URL to share a media file # Shareable links created on Dropbox are time-limited, but don't require any @@ -532,11 +616,11 @@ # * A Hash object that looks like the following example: # {'url': 'http://www.dropbox.com/s/m/a2mbDa2', 'expires': 'Thu, 16 Sep 2011 01:01:25 +0000'} # For a detailed description of what this call returns, visit: # https://www.dropbox.com/developers/docs#share def shares(path) - response = @token.get(build_url("/shares/#{@root}#{format_path(path)}")) + response = @session.do_get build_url("/shares/#{@root}#{format_path(path)}") parse_response(response) end # Download a thumbnail for an image. # @@ -558,18 +642,18 @@ "size" => size } url = build_url("/thumbnails/#{@root}#{from_path}", params, content_server=true) - response = @token.get(url) + response = @session.do_get url parse_response(response, raw=true) end def build_url(url, params=nil, content_server=false) # :nodoc: port = 443 - host = content_server ? DROPBOX_API_CONTENT_SERVER : DROPBOX_API_SERVER - versioned_url = "/#{API_VERSION}#{url}" + host = content_server ? Dropbox::API_CONTENT_SERVER : Dropbox::API_SERVER + versioned_url = "/#{Dropbox::API_VERSION}#{url}" target = URI::Generic.new("https", nil, host, port, nil, versioned_url, nil, nil, nil) #add a locale param if we have one #initialize a params object is we don't have one @@ -583,25 +667,24 @@ }.join("&") end target.to_s end -end + #From the oauth spec plus "/". Slash should not be ecsaped + RESERVED_CHARACTERS = /[^a-zA-Z0-9\-\.\_\~\/]/ -#From the oauth spec plus "/". Slash should not be ecsaped -RESERVED_CHARACTERS = /[^a-zA-Z0-9\-\.\_\~\/]/ + def format_path(path, escape=true) # :nodoc: + path = path.gsub(/\/+/,"/") + # replace multiple slashes with a single one -def format_path(path, escape=true) # :nodoc: - path = path.gsub(/\/+/,"/") - # replace multiple slashes with a single one + path = path.gsub(/^\/?/,"/") + # ensure the path starts with a slash - path = path.gsub(/^\/?/,"/") - # ensure the path starts with a slash + path.gsub(/\/?$/,"") + # ensure the path doesn't end with a slash - path.gsub(/\/?$/,"") - # ensure the path doesn't end with a slash + return URI.escape(path, RESERVED_CHARACTERS) if escape + path + end - return URI.escape(path, RESERVED_CHARACTERS) if escape - path end -