Exportar arquivos para o Dropbox em Ruby com o SDK do Dropbox
Use a gem comunitária dropbox_api para exportar
arquivos locais a partir do Ruby. O comando abaixo renova seu token de acesso, faz upload para um
caminho explícito do Dropbox e baixa o resultado para conferir cada byte antes de informar a
conclusão. Em um app com acesso à própria pasta, /report.csv representa um arquivo
dentro da pasta do app; não acrescente /Apps/YourAppFolder ao início.
Pré-requisitos
Este guia se destina a aplicações Ruby 3.4 existentes no Linux com Bash e
Bundler instalado.
As versões usadas na reprodução são Ruby 3.4.10, Bundler 4.0.23 e
dropbox_api 0.1.21. O Ruby 3.4
continua recebendo manutenção; use na sua aplicação a versão mais recente dessa
série com as correções disponíveis. O Ruby 4.0 é a versão principal estável atual,
mas o ambiente de execução deste guia se limita à série 3.4.
A versão publicada mais recente da gem ainda é a
0.1.21, de março de 2022, portanto a compatibilidade aqui corresponde
a uma combinação testada, e não a uma promessa dos mantenedores de suporte ao Ruby atual.
Você precisa de uma conta do Dropbox e de um ambiente privado no servidor que possa manter um segredo do app e um token de atualização. Use arquivos regulares estáveis com nomes em UTF-8. O exemplo aceita arquivos vazios e dados binários e limita cada arquivo a 16 MiB, pois a verificação armazena o download completo em memória. Os caminhos remotos devem conter caracteres ASCII; use o modo de arquivo para atribuir um nome remoto ASCII a um arquivo local com nome em UTF-8. Por isso, o modo de diretório exige nomes de arquivo em ASCII. Execute as exportações sequencialmente e não modifique os arquivos de entrada durante a execução. A seleção de namespace em espaços de equipe e as gravações simultâneas estão fora do escopo deste exemplo.
Configurar um app do Dropbox e obter tokens de acesso
Crie um app no Dropbox App Console. Escolha
App folder para um exportador que precise
acessar apenas seus próprios arquivos.
Um app existente do tipo Full Dropbox pode usar
o mesmo código, mas, nesse caso, os caminhos começam na raiz padrão do Dropbox do usuário.
O acesso à pasta do app limita o conteúdo disponível;
os escopos OAuth limitam as operações separadamente.
Habilite files.content.write, files.content.read e
files.metadata.read antes de autorizar:
o comando faz upload, baixa o arquivo e verifica se o destino já existe.
Em um app com acesso à própria pasta, o caminho da API /Exports/report.csv aparece dentro
da pasta do app no Dropbox. Em um app com acesso completo, ele aparece dentro de
/Exports na raiz padrão do usuário. Crie primeiro todas as pastas pai no Dropbox
e use exatamente as mesmas letras maiúsculas e minúsculas dos seus nomes. Esses são
caminhos da API, não URLs do site nem caminhos do sistema de
arquivos local. A restrição a ASCII evita caracteres não ASCII nos cabeçalhos de argumentos desta
versão da gem; as regras de codificação de cabeçalhos do Dropbox
exigem o uso de sequências de escape para esses caracteres.
Forneça DROPBOX_APP_KEY e DROPBOX_APP_SECRET como variáveis de ambiente
por meio do seu gerenciador de segredos ou de um ambiente de shell privado. Não coloque segredos no
controle de versão nem em argumentos de comandos. Solicitaremos autorização para acesso offline
uma vez e usaremos seu token de atualização nas execuções posteriores sem intervenção manual.
O guia do fluxo de código do Dropbox permite omitir um URI de
redirecionamento neste fluxo de autorização local com cópia e colagem.
Instalar o SDK do Dropbox para Ruby
Em uma sessão Bash sem sobrescritas de BUNDLE_*, cole este bloco a partir de
um diretório pai com permissão de gravação. Ele cria um novo projeto
dropbox-export; se o diretório já existir, ocorrerá um erro e ele não será alterado.
O subshell mantém seu diretório atual e as opções do shell intactos se a instalação falhar.
(
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
)
Abra o diretório resultante no seu editor e use-o como diretório de trabalho para os comandos
restantes. Mantenha o Gemfile.lock gerado junto da sua aplicação para que
instalações posteriores usem as mesmas dependências resolvidas. Se a instalação falhar após a
criação do projeto, inspecione seus arquivos e execute novamente bundle install
nesse diretório; não exclua um projeto existente para reiniciar a configuração.
Autenticar com o Dropbox em Ruby
Salve este código como authorize_dropbox.rb. Seu comando url
imprime uma URL de autorização. Seu comando exchange lê
DROPBOX_AUTH_CODE do ambiente e salva apenas o token de atualização em um novo arquivo
chamado refresh-token, com permissão de leitura apenas para o proprietário.
Ele se recusa a substituir um arquivo 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
Execute:
bundle exec ruby authorize_dropbox.rb url
Abra você mesmo a URL impressa, autorize o app e forneça o código exibido por meio de
DROPBOX_AUTH_CODE no mesmo ambiente privado. Em seguida, execute:
bundle exec ruby authorize_dropbox.rb exchange
Mantenha refresh-token fora do controle de versão e restrinja também o acesso aos
seus backups. Você pode movê-lo para um armazenamento privado e definir
DROPBOX_REFRESH_TOKEN_FILE com seu caminho absoluto. O exportador gera um novo token de acesso
a partir desse token de atualização a cada invocação; ele não depende de um token de acesso gerado
no console ainda estar válido. Se a autorização for revogada, será necessário repetir o fluxo de
autorização.
Fazer upload de arquivos para o Dropbox com Ruby
Salve este programa completo como export_dropbox.rb. Arquivos menores que 4 MiB usam
uma única requisição de upload; a partir de 4 MiB, a gem usa sessões de upload com blocos de 4 MiB,
seu tamanho padrão. Esse limite deliberadamente baixo permite usar o fluxo de sessões dentro do
limite de 16 MiB do exemplo. O endpoint de upload do Dropbox
permite requisições únicas maiores, mas este exemplo não precisa se aproximar desse limite.
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
A partir do diretório deste projeto, exporte um arquivo local existente para a raiz do app:
bundle exec ruby export_dropbox.rb file ./report.csv /report.csv
Quando o comando termina com sucesso, ele imprime um registro JSON com o caminho da API, a contagem de bytes e o SHA-256 comum dos bytes locais, seguido de Export complete. O download usa a revisão enviada no upload, portanto a comparação verifica o conteúdo completo dessa versão. A gem armazena esse download em memória; dividir o upload em blocos não faz deste um exportador com consumo constante de memória.
Tratar erros e exceções
O comando termina com status diferente de zero em caso de arquivos ou configurações ausentes,
autorização recusada, destinos existentes, falha na conclusão de sessões e divergências na leitura
de verificação. Seu diagnóstico informa a classe da exceção sem imprimir corpos de resposta do
provedor nem credenciais. Um NotFoundError durante a leitura de verificação
significa que não foi possível ler a revisão solicitada; confira o destino e as permissões do app.
Um OAuth2::Error durante a renovação significa que a autorização não foi concluída
com sucesso.
Apenas TooManyRequestsError gera novas tentativas, até três por operação. O intervalo usa
um valor positivo de retry_after; caso contrário, usa 2, 4 e 8 segundos.
As demais falhas são propagadas. Se um upload for concluído com sucesso, mas sua resposta ou
verificação se perder, o arquivo remoto ainda poderá existir: inspecione-o antes de tentar
novamente. O script não exclui dados remotos em caso de falha. Uma sessão iniciada também pode
permanecer inacabada.
A verificação de destino existente faz com que novas execuções sequenciais falhem mesmo quando os
bytes são idênticos. Ela não é um mecanismo de bloqueio: impeça outras gravações na pasta de destino
durante uma exportação. Os uploads solicitam mode: :add e
autorename: false; o script nunca solicita sobrescrita nem aceita um resultado
renomeado como sucesso.
Automatizar exportações de arquivos na sua aplicação Ruby
Crie /Exports dentro da pasta do seu app no Dropbox e exporte os arquivos
regulares que estão diretamente dentro de um diretório local exports
existente:
bundle exec ruby export_dropbox.rb directory ./exports /Exports
O modo de diretório inclui arquivos regulares ocultos, ignora subdiretórios e para na primeira falha. Os arquivos anteriores permanecem exportados e verificados; esta não é uma transação de diretório do tipo tudo ou nada. Um diretório vazio não tem arquivos para exportar. Use uma nova pasta de destino para cada lote agendado ou registre os caminhos concluídos antes de selecionar os arquivos de entrada para outra execução.
Em uma tarefa do Rails, carregue export_dropbox.rb com require, crie o cliente dentro da
tarefa e chame upload_file ou DropboxExporter.new(client).process_directory. Esses métodos lançam
exceções em caso de falha, portanto deixe a tarefa registrar essa falha. Mantenha as credenciais da
tarefa disponíveis por meio da mesma configuração privada e inspecione qualquer resultado remoto
incerto antes que a tarefa tente novamente.
