nebulab-dropbox
Version:
Client library for the Dropbox API
704 lines (641 loc) • 26.5 kB
text/coffeescript
if Dropbox.Env.global.XMLHttpRequest
# Browser or Web Worker.
if Dropbox.Env.global.XDomainRequest and
not ('withCredentials' of new XMLHttpRequest())
DbxXhrRequest = XDomainRequest
DbxXhrIeMode = true
# IE's XDR doesn't allow setting requests' Content-Type to anything other
# than text/plain, so it can't send _any_ forms.
DbxXhrCanSendForms = false
else
DbxXhrRequest = XMLHttpRequest
DbxXhrIeMode = false
# Web Workers don't support FormData at all.
# Also, Firefox doesn't support adding named files to FormData.
# https://bugzilla.mozilla.org/show_bug.cgi?id=690659
DbxXhrCanSendForms = typeof FormData isnt 'undefined' and
navigator.userAgent.indexOf('Firefox') is -1
DbxXhrDoesPreflight = true
else
# Node.js.
DbxXhrRequest = Dropbox.Env.require 'xhr2' # We need an XHR emulation.
DbxXhrIeMode = false
# Node.js doesn't have FormData. We wouldn't want to bother putting together
# upload forms in node.js anyway, because it doesn't do CORS preflight
# checks, so we can use PUT requests without a performance hit.
DbxXhrCanSendForms = false
# Our XHR emulation skips CORS checks, which don't make sense for a server.
DbxXhrDoesPreflight = false
if !Dropbox.Env.global.Uint8Array
# IE <= 9
DbxXhrArrayBufferView = null
DbxXhrWrapBlob = false
DbxXhrSendArrayBufferView = false
else
# The ArrayBufferView constructor is not exposed.
if Object.getPrototypeOf
DbxXhrArrayBufferView = Object.getPrototypeOf(
Object.getPrototypeOf(new Uint8Array(0))).constructor
else if Object.__proto__
DbxXhrArrayBufferView =
(new Uint8Array(0)).__proto__.__proto__.constructor
if !Dropbox.Env.global.Blob
DbxXhrWrapBlob = false
DbxXhrSendArrayBufferView = true
else
try
do ->
if (new Blob [new Uint8Array(2)]).size is 2
DbxXhrWrapBlob = true
DbxXhrSendArrayBufferView = true
else
DbxXhrSendArrayBufferView = false
DbxXhrWrapBlob = (new Blob [new ArrayBuffer(2)]).size is 2
catch
DbxXhrSendArrayBufferView = false
DbxXhrWrapBlob = false
if Dropbox.Env.global.WebKitBlobBuilder
# Android's WebView doesn't support adding named files to FormData.
if navigator.userAgent.indexOf('Android') isnt -1
DbxXhrCanSendForms = false
if DbxXhrArrayBufferView is Object
# Browsers that haven't implemented XHR#send(ArrayBufferView) also don't
# have a real ArrayBufferView prototype. (Safari, Firefox)
DbxXhrSendArrayBufferView = false
# Dispatches low-level HTTP requests.
#
# This wraps XMLHttpRequest or its equivalents (XDomainRequest on IE 9 and
# below) and works around bugs and inconsistencies in various implementations.
class Dropbox.Util.Xhr
# The constructor used to build AJAX requests (XMLHttpRequest).
#
# @private
# {Dropbox.Util.Xhr} instances will wrap instances built by this constructor.
#
# This is XMLHttpRequest on modern browsers.
@Request = DbxXhrRequest
# Set to true when using the XDomainRequest API.
#
# @private
# This is used by {Dropbox.Util.Xhr} and {Dropbox.Client}, to decide when to
# use workarounds for IE limitations.
@ieXdr = DbxXhrIeMode
# Set to true if the platform has proper support for FormData.
#
# @private
# This is used by {Dropbox.Util.Xhr} and {Dropbox.Client} to decide what REST
# API calls to use.
@canSendForms = DbxXhrCanSendForms
# Set to true if the platform performs CORS preflight checks.
#
# @private
# This is used by {Dropbox.Util.Xhr} and {Dropbox.Client} to decide when to
# use HTTP headers vs query parameters.
@doesPreflight = DbxXhrDoesPreflight
# The closest superclass for all ArrayBufferView objects.
#
# @private
# This is used by {Dropbox.Util.Xhr} to work around bugs in browsers' XHR
# level 2 implementation.
@ArrayBufferView = DbxXhrArrayBufferView
# True if we think we can send ArrayBufferView objects via XHR.
#
# @private
# This is used by {Dropbox.Util.Xhr} to work around bugs in browsers' XHR
# level 2 implementation.
@sendArrayBufferView = DbxXhrSendArrayBufferView
# True if ArrayBuffer and ArrayBufferView instances get wrapped in Blobs
# before sending via XHR.
#
# @private
# This is used by {Dropbox.Util.Xhr} to work around bugs in browsers' XHR
# level 2 implementation.
@wrapBlob = DbxXhrWrapBlob
# Sets up an AJAX request.
#
# @param {String} method the HTTP method used to make the request ('GET',
# 'POST', 'PUT', etc.)
# @param {String} baseUrl the URL that receives the request; this URL might
# be modified, e.g. by appending parameters for GET requests
constructor: (@method, baseUrl) ->
@isGet = @method is 'GET'
@url = baseUrl
@wantHeaders = false
@headers = {}
@params = null
@body = null
@preflight = not (@isGet or (@method is 'POST'))
@signed = false
@completed = false
@responseType = null
@callback = null
@xhr = null
@onError = null
# The XMLHttpRequest object used to make the request.
#
# This is null before {Dropbox.Util.Xhr#prepare} is called
#
# @property {XMLHttpRequest}
xhr: null
# Called when the HTTP request fails.
#
# If the underlying XMLHttpRequest fails and this is not null, this callback
# will receive a {Dropbox.ApiError} instance as its first argument. The
# function is responsible for calling its 2nd argument and passing it the
# {Dropbox.ApiError}. If the function does not do that, the callback passed
# to {Dropbox.Util.Xhr#send} or {Dropbox.Util.Xhr#} will not be called
#
# @property {function(Dropbox.ApiError, function(Dropbox.ApiError))}
onError: null
# Sets the parameters (form field values) that will be sent with the request.
#
# @param {?Object} params an associative array (hash) containing the HTTP
# request parameters
# @return {Dropbox.Util.Xhr} this, for easy call chaining
setParams: (params) ->
if @signed
throw new Error 'setParams called after addOauthParams or addOauthHeader'
if @params
throw new Error 'setParams cannot be called twice'
@params = params
@
# Sets the function called when the HTTP request completes.
#
# This function can also be set when calling {Dropbox.Util.Xhr#send}.
#
# @param {function(Dropbox.ApiError, Object, Object, Object)} callback called
# when the XMLHttpRequest completes; if an error occurs, the first
# parameter will be a {Dropbox.ApiError}; otherwise, the first parameter
# will be null, the second parameter will be an instance of the required
# response type (e.g., String, Blob), the third parameter will be the
# JSON-parsed 'x-dropbox-metadata' header, and the fourth parameter will be
# an object containing all the headers
#
# @return {Dropbox.Util.Xhr} this, for easy call chaining
setCallback: (@callback) ->
@
# Amends the request parameters to include an OAuth signature.
#
# The OAuth signature will become invalid if the parameters are changed after
# the signing process.
#
# This method automatically decides the best way to add the OAuth signature
# to the current request. Modifying the request in any way (e.g., by adding
# headers) might result in a valid signature that is applied in a sub-optimal
# fashion. For best results, call this right before Dropbox.Util.Xhr#prepare.
#
# @param {Dropbox.Util.Oauth} oauth OAuth instance whose key and secret will
# be used to sign the request
# @param {Boolean} cacheFriendly if true, the signing process choice will be
# biased towards allowing the HTTP cache to work; by default, the choice
# attempts to avoid the CORS preflight request whenever possible
# @return {Dropbox.Util.Xhr} this, for easy call chaining
signWithOauth: (oauth, cacheFriendly) ->
if Dropbox.Util.Xhr.ieXdr
@addOauthParams oauth
else if @preflight or !Dropbox.Util.Xhr.doesPreflight
@addOauthHeader oauth
else
if @isGet and cacheFriendly
@addOauthHeader oauth
else
@addOauthParams oauth
# Amends the request parameters to include an OAuth signature.
#
# The OAuth signature will become invalid if the parameters are changed after
# the signing process.
#
# @param {Dropbox.Util.Oauth} oauth OAuth instance whose key and secret will
# be used to sign the request
# @return {Dropbox.Util.Xhr} this, for easy call chaining
addOauthParams: (oauth) ->
if @signed
throw new Error 'Request already has an OAuth signature'
@params or= {}
oauth.addAuthParams @method, @url, @params
@signed = true
@
# Adds an Authorize header containing an OAuth signature.
#
# The OAuth signature will become invalid if the parameters are changed after
# the signing process.
#
# @param {Dropbox.Util.Oauth} oauth OAuth instance whose key and secret will
# be used to sign the request
# @return {Dropbox.Util.Xhr} this, for easy call chaining
addOauthHeader: (oauth) ->
if @signed
throw new Error 'Request already has an OAuth signature'
@params or= {}
@signed = true
@setHeader 'Authorization', oauth.authHeader(@method, @url, @params)
# Sets the body (piece of data) that will be sent with the request.
#
# @param {String, Blob, ArrayBuffer} body the body to be sent in a request;
# GET requests cannot have a body
# @return {Dropbox.Util.Xhr} this, for easy call chaining
setBody: (body) ->
if @isGet
throw new Error 'setBody cannot be called on GET requests'
if @body isnt null
throw new Error 'Request already has a body'
if typeof body is 'string'
# Content-Type will be set automatically.
else if (typeof FormData isnt 'undefined') and (body instanceof FormData)
# Content-Type will be set automatically.
else
@headers['Content-Type'] = 'application/octet-stream'
@preflight = true
@body = body
@
# Changes the type of the response that will be passed to the callback.
#
# This method requires XMLHttpRequest Level 2 support, which is not available
# in Internet Explorer 9 and older.
#
# @param {String} responseType the value that will be assigned to the XHR's
# responseType property, such as "blob" or "arraybuffer"
# @return {Dropbox.Util.Xhr} this, for easy call chaining
setResponseType: (@responseType) ->
@
# Sets the value of a custom HTTP header.
#
# Custom HTTP headers require a CORS preflight in browsers, so requests that
# use them will take more time to complete, especially on high-latency mobile
# connections.
#
# @param {String} headerName the name of the HTTP header
# @param {String} value the value that the header will be set to
# @return {Dropbox.Util.Xhr} this, for easy call chaining
setHeader: (headerName, value) ->
if @headers[headerName]
oldValue = @headers[headerName]
throw new Error "HTTP header #{headerName} already set to #{oldValue}"
if headerName is 'Content-Type'
throw new Error 'Content-Type is automatically computed based on setBody'
@preflight = true
@headers[headerName] = value
@
# Requests that the response headers be reported to the callback.
#
# Response headers are not returned by default because the parsing is
# non-trivial and produces many intermediate strings.
#
# Response headers are not available on Internet Explorer 9 and below.
#
# @return {Dropbox.Util.Xhr} this, for easy call chaining
reportResponseHeaders: ->
@wantHeaders = true
# Simulates having an `<input type="file">` being sent with the request.
#
# @param {String} fieldName the name of the form field / parameter (not of
# the uploaded file)
# @param {String} fileName the name of the uploaded file (not the name of the
# form field / parameter)
# @param {String, Blob, File} fileData contents of the file to be uploaded
# @param {?String} contentType the MIME type of the file to be uploaded; if
# fileData is a Blob or File, its MIME type is used instead
setFileField: (fieldName, fileName, fileData, contentType) ->
if @body isnt null
throw new Error 'Request already has a body'
if @isGet
throw new Error 'setFileField cannot be called on GET requests'
if typeof(fileData) is 'object'
if typeof ArrayBuffer isnt 'undefined'
if fileData instanceof ArrayBuffer
# Convert ArrayBuffer -> ArrayBufferView on standard-compliant
# browsers, to avoid warnings from the Blob constructor.
if Dropbox.Util.Xhr.sendArrayBufferView
fileData = new Uint8Array fileData
else
# Convert ArrayBufferView -> ArrayBuffer on older browsers, to avoid
# having a Blob that contains "[object Uint8Array]" instead of the
# actual data.
if !Dropbox.Util.Xhr.sendArrayBufferView and
fileData.byteOffset is 0 and
fileData.buffer instanceof ArrayBuffer
fileData = fileData.buffer
contentType or= 'application/octet-stream'
try
fileData = new Blob [fileData], type: contentType
catch blobError
# Stock Android / iPhone browsers don't implement the Blob contructor.
# This code is only used on iPhone Safari / WebView (Cordova), because
# Android's browser has a bug in sending Blobs.
if window.WebKitBlobBuilder
builder = new WebKitBlobBuilder
builder.append fileData
if blob = builder.getBlob contentType
fileData = blob
# Workaround for http://crbug.com/165095
if typeof File isnt 'undefined' and fileData instanceof File
fileData = new Blob [fileData], type: fileData.type
useFormData = fileData instanceof Blob
else
useFormData = false
if useFormData
@body = new FormData()
@body.append fieldName, fileData, fileName
else
contentType or= 'application/octet-stream'
boundary = @multipartBoundary()
@headers['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
@body = ['--', boundary, "\r\n",
'Content-Disposition: form-data; name="', fieldName,
'"; filename="', fileName, "\"\r\n",
'Content-Type: ', contentType, "\r\n",
"Content-Transfer-Encoding: binary\r\n\r\n",
fileData,
"\r\n", '--', boundary, '--', "\r\n"].join ''
# Generates a MIME multipart boundary.
#
# @private
# This should only be called by {Dropbox.Util.Xhr#prepare}.
#
# @return {String} a nonce suitable for use as a part boundary in a multipart
# MIME message; it is highly unlikely that the parts of the MIME message
# will contain the nonce
multipartBoundary: ->
[Date.now().toString(36), Math.random().toString(36)].join '----'
# Moves this request's parameters to its URL.
#
# @private
# This should only be called by {Dropbox.Util.Xhr#prepare}.
#
# @return {Dropbox.Util.Xhr} this, for easy call chaining
paramsToUrl: ->
if @params
queryString = Dropbox.Util.Xhr.urlEncode @params
if queryString.length isnt 0
@url = [@url, '?', queryString].join ''
@params = null
@
# Moves this request's parameters to its body.
#
# @private
# This should only be called by {Dropbox.Util.Xhr#prepare}.
#
# @return {Dropbox.Util.Xhr} this, for easy call chaining
paramsToBody: ->
if @params
if @body isnt null
throw new Error 'Request already has a body'
if @isGet
throw new Error 'paramsToBody cannot be called on GET requests'
@headers['Content-Type'] = 'application/x-www-form-urlencoded'
@body = Dropbox.Util.Xhr.urlEncode @params
@params = null
@
# Sets up an XHR request.
#
# This method completely sets up a native XHR object and stops short of
# calling its send() method, so the API client has a chance of customizing
# the XHR. After customizing the XHR, {Dropbox.Util.Xhr#send} should be
# called.
#
# @return {Dropbox.Util.Xhr} this, for easy call chaining
prepare: ->
ieXdr = Dropbox.Util.Xhr.ieXdr
if @isGet or @body isnt null or ieXdr
@paramsToUrl()
if @body isnt null and typeof @body is 'string'
@headers['Content-Type'] = 'text/plain; charset=utf8'
else
@paramsToBody()
@xhr = new Dropbox.Util.Xhr.Request()
if ieXdr
@xhr.onload = => @onXdrLoad()
@xhr.onerror = => @onXdrError()
@xhr.ontimeout = => @onXdrError()
# NOTE: there are reports that XHR somtimes fails if onprogress doesn't
# have any handler
@xhr.onprogress = ->
else
@xhr.onreadystatechange = => @onReadyStateChange()
@xhr.open @method, @url, true
unless ieXdr
for own header, value of @headers
@xhr.setRequestHeader header, value
if @responseType
if @responseType is 'b'
if @xhr.overrideMimeType
@xhr.overrideMimeType 'text/plain; charset=x-user-defined'
else
@xhr.responseType = @responseType
@
# Fires off the prepared XHR request.
#
# {Dropbox.Util.Xhr#prepare} should be called exactly once before this
# method is called.
#
# @param {function(?Dropbox.ApiError, ?Object, ?Object)} callback called when
# the XHR completes; if an error occurs, the first parameter will be a
# Dropbox.ApiError instance; otherwise, the second parameter will be an
# instance of the required response type (e.g., String, Blob), and the
# third parameter will be the JSON-parsed 'x-dropbox-metadata' header
# @return {Dropbox.Util.Xhr} this, for easy call chaining
send: (callback) ->
@callback = callback or @callback
if @body isnt null
body = @body
if Dropbox.Util.Xhr.sendArrayBufferView
# Standards-compliant browsers don't like to send() naked ArrayBuffers
if body instanceof ArrayBuffer
body = new Uint8Array body
else
# Convert ArrayBufferView -> ArrayBuffer on older browsers, because
# they will send "[object Uint8Array]" instead of the actual data.
if body.byteOffset is 0 and body.buffer instanceof ArrayBuffer
body = body.buffer
try
@xhr.send body
catch xhrError
# Node.js doesn't implement Blob.
if !Dropbox.Util.Xhr.sendArrayBufferView and Dropbox.Util.Xhr.wrapBlob
# Firefox doesn't support sending ArrayBufferViews.
body = new Blob [body], type: 'application/octet-stream'
@xhr.send body
else
throw xhrError
else
@xhr.send()
@
# Encodes an associative array (hash) into a x-www-form-urlencoded String.
#
# For consistency, the keys are sorted in alphabetical order in the encoded
# output.
#
# @param {Object} object the JavaScript object whose keys will be encoded
# @return {String} the object's keys and values, encoded using
# x-www-form-urlencoded
@urlEncode: (object) ->
chunks = []
for key, value of object
chunks.push @urlEncodeValue(key) + '=' + @urlEncodeValue(value)
chunks.sort().join '&'
# Encodes an object into a x-www-form-urlencoded key or value.
#
# @param {Object} object the object to be encoded; the encoding calls
# toString() on the object to obtain its string representation
# @return {String} encoded string, suitable for use as a key or value in an
# x-www-form-urlencoded string
@urlEncodeValue: (object) ->
encodeURIComponent(object.toString()).replace(/\!/g, '%21').
replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29').
replace(/\*/g, '%2A')
# Decodes an x-www-form-urlencoded String into an associative array (hash).
#
# @param {String} string the x-www-form-urlencoded String to be decoded
# @return {Object} an associative array whose keys and values are all strings
@urlDecode: (string) ->
result = {}
for token in string.split '&'
kvp = token.split '='
result[decodeURIComponent(kvp[0])] = decodeURIComponent kvp[1]
result
# Handles the XHR readystate event.
onReadyStateChange: ->
return true if @xhr.readyState isnt 4 # XMLHttpRequest.DONE is 4
# WebKit might fire this multiple times.
# http://crbug.com/159827
return true if @completed
@completed = true
if @xhr.status < 200 or @xhr.status >= 300
apiError = new Dropbox.ApiError @xhr, @method, @url
if @onError
@onError apiError, @callback
else
@callback apiError
return true
if @wantHeaders
allHeaders = @xhr.getAllResponseHeaders()
if allHeaders
headers = Dropbox.Util.Xhr.parseResponseHeaders allHeaders
else
# Work around https://bugzilla.mozilla.org/show_bug.cgi?id=608735
headers = @guessResponseHeaders()
metadataJson = headers['x-dropbox-metadata']
else
headers = undefined
metadataJson = @xhr.getResponseHeader 'x-dropbox-metadata'
if metadataJson?.length
try
metadata = JSON.parse metadataJson
catch jsonError
# The metadata header gets doubled up in Chrome with buggy extensions.
duplicateIndex = metadataJson.search /\}\,\s*\{/
if duplicateIndex isnt -1
try
metadataJson = metadataJson.substring 0, duplicateIndex + 1
metadata = JSON.parse metadataJson
catch jsonError
# Make sure the app doesn't crash if the server goes crazy.
metadata = undefined
else
# Make sure the app doesn't crash if the server goes crazy.
metadata = undefined
else
metadata = undefined
if @responseType
if @responseType is 'b'
dirtyText = if @xhr.responseText?
@xhr.responseText
else
@xhr.response
bytes = []
for i in [0...dirtyText.length]
bytes.push String.fromCharCode(dirtyText.charCodeAt(i) & 0xFF)
text = bytes.join ''
@callback null, text, metadata, headers
else
@callback null, @xhr.response, metadata, headers
return true
text = if @xhr.responseText? then @xhr.responseText else @xhr.response
contentType = @xhr.getResponseHeader 'Content-Type'
if contentType
offset = contentType.indexOf ';'
contentType = contentType.substring(0, offset) if offset isnt -1
switch contentType
when 'application/x-www-form-urlencoded'
@callback null, Dropbox.Util.Xhr.urlDecode(text), metadata, headers
when 'application/json', 'text/javascript'
@callback null, JSON.parse(text), metadata, headers
else
@callback null, text, metadata, headers
true
# Parses a block of raw HTTP headers.
#
# @private
# Called by XHR's response processing code.
#
# @param {String} allHeaders the return value of an getAllResponseHeaders()
# call on a XMLHttpRequest object
# @return {Object<String, String>} object whose keys are the lowercased HTTP
# header names, and whose values are the corresponding HTTP header values
@parseResponseHeaders: (allHeaders) ->
headers = {}
headerLines = allHeaders.split "\n"
for line in headerLines
# NOTE: IE8 doesn't support trim(); we don't implement a fallback because
# XDR (used on IE < 10) doesn't support headers, so this won't get
# called anyway
colonIndex = line.indexOf ':'
name = line.substring(0, colonIndex).trim().toLowerCase()
value = line.substring(colonIndex + 1).trim()
headers[name] = value
headers
# Emulates getAllResponseHeaders()+parseResponseHeaders() on buggy browsers.
#
# @private
# Called by XHR's response processing code.
#
# @return {Object<String, String>} object whose keys are the lowercased HTTP
# header names, and whose values are the corresponding HTTP header values
guessResponseHeaders: ->
# TODO(pwnall): investigate removing this when Firefox 21 gets released.
headers = {}
# Using ther header names listed at
# http://www.w3.org/TR/cors/#simple-response-header
# and the names used by the Dropbox API server in
# access-control-expose-headers.
for name in ['cache-control', 'content-language', 'content-range',
'content-type', 'expires', 'last-modified', 'pragma',
'x-dropbox-metadata']
value = @xhr.getResponseHeader name
headers[name] = value if value
headers
# Handles the XDomainRequest onload event. (IE 8, 9)
onXdrLoad: ->
# WebKit fires onreadystatechange multiple times, might as well include the
# same fix in IE-specific code.
return true if @completed
@completed = true
text = @xhr.responseText
if @wantHeaders
headers = 'content-type': @xhr.contentType
else
headers = undefined
metadata = undefined
if @responseType
@callback null, text, metadata, headers
return true
switch @xhr.contentType
when 'application/x-www-form-urlencoded'
@callback null, Dropbox.Util.Xhr.urlDecode(text), metadata, headers
when 'application/json', 'text/javascript'
@callback null, JSON.parse(text), metadata, headers
else
@callback null, text, metadata, headers
true
# Handles the XDomainRequest onload event. (IE 8, 9)
onXdrError: ->
# WebKit fires onreadystatechange multiple times, might as well include the
# same fix in IE-specific code.
return true if @completed
@completed = true
apiError = new Dropbox.ApiError @xhr, @method, @url
if @onError
@onError apiError, @callback
else
@callback apiError
return true