Exporta archivos a Dropbox en Ruby con el SDK de Dropbox
Usa la gema comunitaria dropbox_api para exportar
archivos locales desde Ruby. El comando siguiente renueva su token de acceso, sube el archivo a una
ruta explícita de Dropbox y descarga el resultado para verificar cada byte antes de indicar que
terminó. En una app con acceso a su propia carpeta, /report.csv representa un
archivo dentro de la carpeta de tu app; no antepongas /Apps/YourAppFolder.
Requisitos previos
Esta guía está dirigida a aplicaciones existentes de Ruby 3.4 en Linux con Bash y
Bundler instalado.
Las versiones para reproducir el ejemplo son Ruby 3.4.10, Bundler 4.0.23 y
dropbox_api 0.1.21. Ruby 3.4
sigue recibiendo mantenimiento; usa su versión actual con parches
para tu aplicación. Ruby 4.0 es la versión principal estable actual, pero el entorno de ejecución
de esta guía se limita a 3.4. La última versión publicada de la gema sigue siendo
0.1.21, de marzo de 2022, por lo que la compatibilidad aquí
corresponde a una combinación probada y no a una promesa de soporte para las versiones actuales
de Ruby por parte del proyecto original.
Necesitas una cuenta de Dropbox y un entorno privado del lado del servidor que pueda guardar el secreto de la app y el token de actualización. Usa archivos regulares estables con nombres en UTF-8. El ejemplo acepta archivos vacíos y datos binarios, y limita cada archivo a 16 MiB porque la verificación almacena la descarga completa en memoria. Las rutas remotas deben contener caracteres ASCII; usa el modo de archivo para asignar un nombre remoto ASCII a un archivo local con nombre UTF-8. Por lo tanto, el modo de directorio requiere nombres base ASCII. Ejecuta las exportaciones de forma secuencial y no modifiques sus archivos de entrada durante una ejecución. La selección del espacio de nombres de un espacio de equipo y las escrituras concurrentes quedan fuera del alcance de este ejemplo.
Configura una app de Dropbox y obtén tokens de acceso
Crea una app en la Dropbox App Console. Elige
App folder para un exportador que solo necesite
acceder a sus propios archivos. Una app existente con
Full Dropbox puede usar el mismo código, pero en
ese caso las rutas parten de la raíz predeterminada de Dropbox del usuario. El acceso a la carpeta
de la app limita el contenido disponible; los
ámbitos de OAuth limitan las operaciones de forma independiente.
Habilita files.content.write, files.content.read y
files.metadata.read antes de autorizar: el comando sube archivos, los descarga y comprueba
si el destino ya existe.
En una app con acceso a su propia carpeta, la ruta de API /Exports/report.csv aparece
dentro de la carpeta de la app en Dropbox. En una app con acceso completo, aparece dentro de
/Exports en la raíz predeterminada del usuario. Primero crea las carpetas
superiores necesarias en Dropbox y respeta exactamente sus mayúsculas y minúsculas. Estas son
rutas de API, no URL del sitio web ni rutas del sistema de archivos
local. La restricción a ASCII evita caracteres no ASCII en los encabezados de argumentos de esta
versión de la gema; las reglas de codificación de encabezados de
Dropbox requieren escapar esos caracteres.
Proporciona DROPBOX_APP_KEY y DROPBOX_APP_SECRET como variables de entorno
a través de tu gestor de secretos o de un entorno de shell privado. No incluyas secretos en el
control de versiones ni en los argumentos de comandos. Solicitaremos autorización para acceso sin
conexión una vez y luego usaremos su token de actualización para ejecuciones posteriores sin
intervención. La guía del flujo de código de Dropbox permite omitir
un URI de redirección en este flujo local de autorización mediante copiar y pegar.
Instala el SDK de Dropbox para Ruby
En una sesión de Bash sin modificaciones mediante BUNDLE_*, pega este bloque
desde un directorio superior con permisos de escritura. Crea un proyecto nuevo llamado
dropbox-export; si el directorio ya existe, se considera un error y no se modifica.
El subshell conserva tu directorio actual y las opciones del shell si la instalación falla.
(
set -eu
command -v ruby >/dev/null
command -v bundle >/dev/null
mkdir dropbox-export
cd dropbox-export
cat > Gemfile <<'RUBY'
source 'https://rubygems.org'
gem 'dropbox_api', '0.1.21'
RUBY
bundle config set --local path vendor/bundle
bundle install
)
Abre el directorio resultante en tu editor y úsalo como directorio de trabajo para los comandos
restantes. Conserva el archivo Gemfile.lock generado junto con tu aplicación para
que las instalaciones posteriores usen las mismas dependencias resueltas. Si la instalación falla
después de crear el proyecto, inspecciona sus archivos y vuelve a ejecutar
bundle install desde ese directorio; no elimines un proyecto existente para reiniciar
la configuración.
Autentícate con Dropbox en Ruby
Guarda lo siguiente como authorize_dropbox.rb. Su comando
url imprime una URL de autorización. Su comando
exchange lee DROPBOX_AUTH_CODE del entorno y guarda únicamente el
token de actualización en un archivo nuevo llamado refresh-token, que solo su
propietario puede leer. Se niega a reemplazar un archivo de token existente.
require 'dropbox_api'
def authorize
auth = DropboxApi::Authenticator.new(
ENV.fetch('DROPBOX_APP_KEY'), ENV.fetch('DROPBOX_APP_SECRET')
)
case ARGV
when ['url']
puts auth.auth_code.authorize_url(token_access_type: 'offline')
when ['exchange']
raise 'refresh-token already exists' if File.exist?('refresh-token')
token = auth.auth_code.get_token(ENV.fetch('DROPBOX_AUTH_CODE'))
raise 'No refresh token returned' if token.refresh_token.to_s.empty?
File.open('refresh-token', File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
file.write(token.refresh_token)
end
puts 'Authorization saved to refresh-token'
else
raise ArgumentError, 'Use url or exchange'
end
end
begin
authorize
rescue StandardError => error
warn "Authorization failed (#{error.class})"
exit 1
end
Ejecuta:
bundle exec ruby authorize_dropbox.rb url
Abre tú mismo la URL impresa, autoriza la app y proporciona el código mostrado mediante
DROPBOX_AUTH_CODE en el mismo entorno privado. Luego ejecuta:
bundle exec ruby authorize_dropbox.rb exchange
Mantén refresh-token fuera del control de versiones y restringe también el acceso
a sus copias de seguridad. Puedes moverlo a un almacenamiento privado y establecer
DROPBOX_REFRESH_TOKEN_FILE con su ruta absoluta. El exportador genera un token de acceso nuevo
a partir de este token de actualización en cada invocación; no depende de que siga siendo válido
un token de acceso generado en la consola. Si se revoca la autorización, es necesario repetir el
flujo de autorización.
Sube archivos a Dropbox con Ruby
Guarda este programa completo como export_dropbox.rb. Los archivos de menos de 4 MiB
usan una sola solicitud de subida; a partir de 4 MiB, la gema usa sesiones de subida con sus
fragmentos predeterminados de 4 MiB. Este umbral, deliberadamente pequeño, permite usar las sesiones
dentro del límite de 16 MiB del ejemplo. El
endpoint de subida de Dropbox
permite solicitudes individuales más grandes, pero este ejemplo no necesita acercarse a ese límite.
require 'digest'
require 'dropbox_api'
require 'json'
require 'stringio'
MAX_SINGLE_REQUEST_UPLOAD = 4 * 1024 * 1024
MAX_FILE_BYTES = 16 * 1024 * 1024
def utf8_path(value)
path = value.dup.force_encoding(Encoding::UTF_8)
raise ArgumentError, 'Paths must be valid UTF-8' unless path.valid_encoding?
path
end
def remote_path(value)
path = utf8_path(value)
raise ArgumentError, 'Dropbox paths must contain ASCII only' unless path.ascii_only?
parts = path.split('/', -1)
unless path.start_with?('/') && parts.drop(1).all? { |part| !['', '.', '..'].include?(part) }
raise ArgumentError, 'Use an absolute Dropbox file or folder path without empty, . or .. segments'
end
path
end
def initialize_dropbox_client
auth = DropboxApi::Authenticator.new(
ENV.fetch('DROPBOX_APP_KEY'), ENV.fetch('DROPBOX_APP_SECRET')
)
refresh_token = File.read(ENV.fetch('DROPBOX_REFRESH_TOKEN_FILE', 'refresh-token')).strip
raise ArgumentError, 'Refresh token is empty' if refresh_token.empty?
token = OAuth2::AccessToken.new(auth, '', refresh_token: refresh_token, expires_at: 0)
DropboxApi::Client.new(access_token: token)
end
def with_rate_limit(max_retries = 3)
retries = 0
begin
yield
rescue DropboxApi::Errors::TooManyRequestsError => error
raise if retries >= max_retries
retries += 1
wait = error.retry_after.to_i.positive? ? error.retry_after.to_i : 2**retries
sleep(wait)
retry
end
end
def upload_file(client, file_path, dropbox_path)
local = utf8_path(file_path)
destination = remote_path(dropbox_path)
raise ArgumentError, 'Input must be a regular file' unless File.file?(local)
raise ArgumentError, 'File exceeds 16 MiB' if File.size(local) > MAX_FILE_BYTES
bytes = File.binread(local)
raise ArgumentError, 'File exceeds 16 MiB' if bytes.bytesize > MAX_FILE_BYTES
existing = begin
with_rate_limit { client.get_metadata(destination) }
rescue DropboxApi::Errors::NotFoundError
nil
end
raise 'Destination already exists' unless existing.nil?
uploaded = if bytes.bytesize >= MAX_SINGLE_REQUEST_UPLOAD
with_rate_limit { client.upload_by_chunks(destination, StringIO.new(bytes), mode: :add, autorename: false) }
else
with_rate_limit { client.upload(destination, bytes, mode: :add, autorename: false) }
end
downloaded = nil
stored = with_rate_limit do
client.download("rev:#{uploaded.rev}") do |body|
downloaded = body.nil? ? ''.b : body.b
end
end
unless uploaded.path_display == destination && stored.path_display == destination &&
uploaded.size == bytes.bytesize && stored.size == bytes.bytesize && downloaded == bytes
raise 'Stored file did not match the requested path and bytes'
end
puts JSON.generate(path: destination, bytes: bytes.bytesize, sha256: Digest::SHA256.hexdigest(bytes))
end
class DropboxExporter
def initialize(client)
@client = client
end
def process_directory(local_dir, dropbox_dir)
local = utf8_path(local_dir)
remote = remote_path(dropbox_dir)
raise ArgumentError, 'Input must be a directory' unless File.directory?(local)
Dir.children(local).sort.each do |name|
path = File.join(local, name)
next unless File.file?(path)
upload_file(@client, path, "#{remote}/#{utf8_path(name)}")
end
end
end
if $PROGRAM_NAME == __FILE__
begin
raise ArgumentError, 'Use file|directory LOCAL DROPBOX_PATH' unless ARGV.length == 3
mode, local, remote = ARGV
raise ArgumentError, 'Use file or directory' unless ['file', 'directory'].include?(mode)
client = initialize_dropbox_client
if mode == 'file'
upload_file(client, local, remote)
else
DropboxExporter.new(client).process_directory(local, remote)
end
puts 'Export complete'
rescue StandardError => error
warn "Export failed (#{error.class}); inspect the destination before retrying"
exit 1
end
end
Desde el directorio de este proyecto, exporta un archivo local existente a la raíz de la app:
bundle exec ruby export_dropbox.rb file ./report.csv /report.csv
Cuando el comando termina correctamente, imprime un registro JSON con la ruta de API, el número de bytes y el SHA-256 convencional de los bytes locales, seguido de Export complete. La descarga usa la revisión subida, por lo que la comparación comprueba el contenido completo de esa versión. La gema almacena esa descarga en memoria; dividir la subida en fragmentos no convierte este programa en un exportador con uso constante de memoria.
Gestiona errores y excepciones
El comando termina con un código distinto de cero si faltan archivos o configuración, se rechaza
la autorización, el destino ya existe, falla la finalización de una sesión o la lectura de
verificación detecta diferencias. Su diagnóstico indica la clase de excepción sin imprimir los
cuerpos de respuesta del proveedor ni las credenciales. Un NotFoundError durante
la lectura de verificación significa que no se pudo leer la revisión solicitada; comprueba el
destino y los permisos de la app. Un OAuth2::Error durante la renovación significa
que la autorización no se completó correctamente.
Solo se reintenta TooManyRequestsError, hasta tres veces por operación. La espera usa un
valor positivo de retry_after; de lo contrario, es de 2, 4 y 8 segundos. Los demás
errores se propagan. Si una subida se completa correctamente pero se pierde su respuesta o su
verificación, el archivo remoto puede seguir existiendo: inspecciónalo antes de reintentar.
El script no elimina datos remotos si ocurre un error. Una sesión iniciada también puede quedar
sin finalizar.
La comprobación de si el destino ya existe hace que las ejecuciones secuenciales posteriores
fallen incluso si los bytes son idénticos. No es un bloqueo: no permitas otras escrituras en la carpeta
de destino durante una exportación. Las subidas solicitan mode: :add y
autorename: false; el script nunca solicita sobrescribir ni acepta un resultado con
otro nombre como un éxito.
Automatiza las exportaciones de archivos en tu aplicación Ruby
Crea /Exports dentro de la carpeta de tu app en Dropbox y luego exporta los
archivos regulares ubicados directamente en un directorio local existente
exports:
bundle exec ruby export_dropbox.rb directory ./exports /Exports
El modo de directorio incluye los archivos regulares ocultos, omite los subdirectorios y se detiene ante el primer error. Los archivos anteriores permanecen exportados y verificados; esta operación no es una transacción de directorio de todo o nada. Un directorio vacío no tiene archivos para exportar. Usa una carpeta de destino nueva para cada lote programado o lleva un registro de las rutas completadas antes de seleccionar los archivos de entrada para otra ejecución.
En una tarea de Rails, carga export_dropbox.rb, crea el cliente dentro de la tarea y
llama a upload_file o DropboxExporter.new(client).process_directory. Estos métodos lanzan
excepciones si fallan, así que deja que la tarea registre ese error. Mantén sus credenciales
disponibles mediante la misma configuración privada e inspecciona cualquier resultado remoto
incierto antes de que la tarea vuelva a intentarlo.
